feat(screens): phase 9 — real screenshots, from a real shard
All checks were successful
PR checks / checks (pull_request) Successful in 1m25s

D4 asked for screenshots of the review stack rather than placeholders. Seventeen
of them: eleven of the site in a browser, six of the app on a phone, all from one
demo deployment wired to a running ServUO shard over a real sidecar, captured on
one day (D42).

The deployment is branded "Runic Gateway Demo" rather than a real community (D43),
and the captures sit beside the claims they support — the homepage, /features/, and
five of the administration pages phase 7 could describe but not show (D44).

The rig is committed rather than remembered (D45):

  scripts/seedDemo.mjs        content, by driving the site's own API — never SQL,
                              because a row the product could not have produced is
                              a screenshot of a product that does not exist
  src/data/screens.mjs        every capture: route, viewport, scroll, alt, caption
  scripts/captureScreens.mjs  npm run screens:capture
  scripts/checkScreens.mjs    the ninth check script, in CI

Shard-side dressing is servuo-plugins' scaffolding (D46), never deployed.

The rig found five things nothing else had. One is fixed upstream — a fresh
module-uo install pinned wire protocol 3 against a sidecar speaking 4, released as
v1.0.2, which this repo's own facts check then caught in platform.json. Four are
raised as product observations and worked around in the rig: a renamed guild
member never reaches the site, a guild deleted while the shard is down is a ghost
row forever, "Houses in danger" cannot show a house that was already collapsing,
and the app's news list prints raw ISO timestamps.

Players online reads 0. Logging a character in needs a UO client driven by hand,
and that is where this stopped — PLAN.md §10 says exactly why, and how to retake
the two frames that would change.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-25 09:55:55 -05:00
parent 31d914ba44
commit c29ec94f46
36 changed files with 2253 additions and 59 deletions

View File

@@ -78,6 +78,17 @@ jobs:
# No token, no network, no build — so it runs early and fails fast.
run: npm run check:sidebar
- name: Screenshots
# PLAN.md §12, phase 9 (D45). src/data/screens.mjs is the one list of what the site
# shows of itself: every entry must have a file, at the size the markup declares, and
# every file must have an entry. The size half is the one that repays the check —
# a re-capture taken at the wrong viewport looks perfectly fine on its own and only
# reveals itself as a page that reflows while it decodes.
#
# No browser and no game server: the capture tool is an authoring script whose output
# is committed, exactly like the brand assets, so CI only reads what it produced.
run: npm run check:screens
- name: Production build
run: npm run build

136
PLAN.md
View File

@@ -87,7 +87,7 @@ All values re-read from the Gitea API on **2026-08-19**, after revision 1.
| uo-link sidecar | **v2.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
| Plugin overlay | **v1.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
| Installer | **v0.1.1** (2026-08-24) | release |
| `module-uo` | **v1.0.1** (2026-08-19) | release |
| `module-uo` | **v1.0.2** (2026-08-25) | release |
| Android app | **v0.5.0** (2026-08-08), id `com.runicgateway.app` | release; `app/build.gradle.kts` |
| ServUO | **57.4** — min version, and the only version the patch tier is verified against | bundle `overlay.servuo` |
| `website` | **no releases** — ships as container images, never tagged | Gitea releases API (empty) |
@@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
**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 **forty-one**:
somewhere other than the thing it decided. The count of record is **forty-six**:
| # | Where | What it settled |
|---|---|---|
@@ -247,6 +247,7 @@ somewhere other than the thing it decided. The count of record is **forty-one**:
| D30D33 | §9, "How phase 6 built the legal pages" | One logging hop and no edge provider, eighteen or older, no governing-law clause, the Data Safety notes as a generated document |
| D34D37 | §10, "How phase 7 built the documentation journey" | One PR for all twenty pages, a self-contained install quickstart with a drift check, every admin screen walked before it was described, a thirteenth Administration page for content |
| D38D41 | §10, "How phase 8 built the builder and reference docs" | One PR for all twenty pages again, Reference enumerates names and checks every one of them, the docs section links to the drawn diagrams rather than importing them, `plannedSidebar` becomes a checked invariant |
| D42D46 | §10, "How phase 9 took the screenshots" | The full rig behind the imagery, a neutral demo brand, the captures beside the claims, a committed and checked capture pipeline, the world dressed in the plugin repo's scaffolding |
---
@@ -508,6 +509,11 @@ same day. `src/components/app/Screenshots.astro` exists now, rendering nothing,
data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
forward into phase 5.
*Filled in phase 9:* six captures, from an emulator pointed at the same seeded deployment the web
screenshots came from, on the same day — see "How phase 9 took the screenshots" in §10. The
component now reads `src/data/screens.mjs` rather than a list of its own, which is what made it a
data change in the end.
**D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on
`Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has
been entered and validated — so an installed app with no deployment behind it is a text field. The
@@ -670,7 +676,7 @@ Organised by what a reader is trying to do. A reader should never need to know t
| `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it |
| `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) |
| `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list |
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and a screenshot slot **phase 9 fills** (D26 — the 14 existing screenshots are the wrong fourteen) |
| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and six phone captures **phase 9 filled** (D26 — the 14 existing screenshots were the wrong fourteen) |
| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) |
| `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 |
| `/privacy/`, `/terms/` | §9 |
@@ -1012,6 +1018,121 @@ security boundary, and a set comparison alone would have passed it.
---
### How phase 9 took the screenshots
D4 said real screenshots from the review stack rather than placeholders, and left the how
open. Five decisions settled it, taken by the org lead before the rig was built.
**D42 — the full rig: a real shard, a real sidecar, a real site.** ServUO with the bridge
overlay on this machine, the Rust sidecar beside it, `website` `main` with `module-uo`
installed, and the demo database seeded on top for what a fresh shard cannot produce.
The alternatives were cheaper and both of them lie a little. Sidecar-only screenshots the
degraded state — a reachable bridge with nothing behind it. Everything-database-seeded
produces pages that look identical to the real thing and were produced by nothing: the
marketplace would be rows somebody typed. This is the one option where the marketplace rows
are player vendors the game actually holds, the atlas is parsed from the shard's own spawn
files, and "Candlewick House is now IDOC" happened.
**D43 — a neutral demo brand.** The deployment is "Runic Gateway Demo", not UOMysticmoon.
The screenshots show the platform rather than one private community, which is the same
instinct as D27's refusal to publicise a real shard — and §15's demo instance can wear this
identity the day it exists, so the imagery stays true rather than becoming a period piece.
The name says "Demo" deliberately: nobody should have to wonder whether they are looking at
a server they could join.
**D44 — the captures sit beside the claims they support, in two places.** A figure set on
`/features/`, one on the homepage, and inline shots on the phase-7 administration pages that
describe a screen in prose. Eleven web captures.
The administration pages are where a screenshot does the most work, because phase 7
described thirteen screens it could not show. A dedicated `/screenshots/` gallery was
rejected for the reason galleries usually are: a page nobody visits does less than a figure
sitting under the sentence it proves.
**D45 — the rig is committed, not remembered.** Three files rather than a folder of images:
`scripts/seedDemo.mjs` puts the content there by driving the site's own API,
`src/data/screens.mjs` declares every capture with its route, viewport, scroll offset and
caption, and `scripts/captureScreens.mjs` turns the second into files.
`scripts/checkScreens.mjs` is the ninth check script and runs in CI.
The argument is the same one D35 made for the install quickstart: the way real screenshots
rot is that the recipe for taking them lives in somebody's memory. Re-taking the set after a
redesign is now `npm run screens:capture`, and the check fails if an entry has no file, a
file is the wrong size, a file is orphaned, or a declared screen is rendered nowhere.
**Why the seed drives the API and never the database.** Every row it creates could have been
an `INSERT`, and every `INSERT` would be a second implementation of a rule the website owns —
how a body is sanitized, which excerpt is derived, how a password is hashed. A seed that
writes SQL produces a database the product could not have produced, and screenshots of that
database show a product that does not exist.
**D46 — the world gets dressed in `servuo-plugins/tools`.** `BridgeSeeder` builds a world at
realistic scale; it never needed the world to look like anything, so a vendor traded as
"Seed Shop 810" and a character was "Seed004A" — and every one of those strings travels the
whole bridge and lands on the marketplace, the guild roster and the housing pages.
`BridgeDemoDress` renames them in place and seeds nothing, drawing names from fixed tables
hashed off each object's serial, so a re-run reproduces the same world and a screenshot can
be retaken later and still match.
**What the rig found.** A screenshot rig is an integration test with a human in the loop, and
this one turned up five things nothing else had:
- **A fresh `module-uo` install pinned wire protocol 3 while the sidecar speaks 4**, so a new
deployment 409s on every shard read until an admin edits the number by hand. The protocol-4
cutover bumped `link`, `servuo-plugins` and `docs` and missed the module's own default.
Fixed upstream and released as `module-uo` **v1.0.2** — which is what this repository's own
facts check then noticed, since `platform.json` still said v1.0.1.
- **A renamed guild member never reaches the site.** The plugin folds name, abbreviation,
leader, member count and alliance into the signature it compares, and re-emits the roster
only when the member *set* changes — so renaming a member leaves the published roster stale
indefinitely.
- **A guild deleted while the shard is offline is a ghost row forever.** The "gone" pass
compares against a cache that is cleared on reconnect, so nothing emits `guild.remove`. The
demo's guild board was showing two guilds the world no longer had, a week after they went.
- **"Houses in danger" cannot show a house that was already collapsing.** The ingest writes
that column only from the `house.decay` transition feed, while the registry frame's stage is
deliberately left alone so the two cannot clobber each other. A house already in IDOC when
the site connects is therefore invisible — the page said none while the shard had two.
- **The Android news list prints raw ISO timestamps.** Found while choosing the phone
captures; the news screen was dropped from that set rather than shipping a picture of it.
The first is fixed. The rest are raised as product observations, with the rig working around
them: the guilds are built *after* the rename, and the IDOC staging is two passes with a wait
between them so the site watches the collapse happen. All of that is scaffolding under
`servuo-plugins/tools/`, which is never deployed.
**The emulator pass (D26), and the AVD that would not take it.** The six phone captures come
from an emulator pointed at the same deployment on the same day, signed in as an ordinary
player, reached through `adb reverse` — the app's debug network policy permits cleartext to
`localhost` only, which is a better default than the one that would have made `10.0.2.2`
work. The device is API 35 rather than the API 36 the plan named: the API 36 image on this
machine had 200 MB free and refused the install, and wiping somebody's development device to
take a screenshot is not a trade worth making.
The shard screen is the one worth having. It shows the two houses entering IDOC in its live
activity feed — the same event that reached `/uo/houses` in the browser, on the same rig, in
the same minute.
**One thing that did not happen: a character logged in.** The org lead asked for it, so the
scaffolding sets a known password on a seeded account and a client was driven as far as its
login screen. ClassicUO stores its password crypted and mangles a plaintext one from
`settings.json`, so auto-login fails; synthetic clicks reach the client but synthetic text
does not; and the remaining route — taking the foreground and typing — was tried once, missed
the client, and typed into the browser window the person at this machine was using. It was
not tried again. **Players online therefore reads 0, and the page is honest about a quiet
realm.** The client is left at its login screen: one manual sign-in and
`npm run screens:capture shard-status app-shard` retakes the two frames that would change.
**A layout decision worth recording.** The `/features/` figures are one-up at the column's
full width, not a two-column grid. Two-up was built first and is the obvious layout for a set
of figures — but these are screenshots of a dense interface, and halving the width puts the
product's own type at about a third of its real size, which reads as a thumbnail of something
rather than a picture of it. A long section of legible evidence beats a tidy grid of
unreadable tiles.
---
## 11. Visual direction
**"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are
@@ -1110,6 +1231,13 @@ a mechanism rather than diligence:
disagreement. Two-directional, like `PLANNED_ROUTES`: a value that drifts fails, **and** a service
or variable that appears upstream fails until it is either included or recorded as deliberately
omitted with a reason. Its own first run found two stale entries.
- **`scripts/checkScreens.mjs`** — added in phase 9 for D45. `src/data/screens.mjs` is the one
list of what the site shows of itself, and this proves every entry has a file at the size the
markup declares, that nothing in `public/screens/` is orphaned, and that every declared
capture is rendered somewhere. The size half is the one that repays it: a re-capture taken at
the wrong viewport looks perfectly fine on its own and only reveals itself as a page that
reflows while it decodes. No browser and no game server — the capture tool is an authoring
script whose output is committed, exactly like the brand assets.
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
- `astro check` plus a production build, in CI on every PR.
@@ -1130,7 +1258,7 @@ a mechanism rather than diligence:
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) |
| **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. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
| **11** | Validation: `astro check`, production build, **all eight check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar), mobile layout verified in a real browser, a signup walked end to end |
| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end |
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and

838
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,11 +22,13 @@
"check:quickstart": "node scripts/checkQuickstart.mjs",
"check:reference": "node scripts/checkReference.mjs",
"check:sidebar": "node scripts/checkSidebar.mjs",
"check:screens": "node scripts/checkScreens.mjs",
"play:datasafety": "node scripts/playDataSafety.mjs",
"beta": "node scripts/beta.mjs",
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
"brand:assets": "node scripts/buildBrandAssets.mjs",
"verify": "npm run check:sidebar && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference"
"screens:capture": "node scripts/captureScreens.mjs",
"verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference"
},
"dependencies": {
"@astrojs/node": "^11.1.4",
@@ -40,7 +42,8 @@
"devDependencies": {
"@astrojs/check": "^0.9.10",
"opentype.js": "^2.0.0",
"yaml": "^2.8.1",
"typescript": "^6.0.3"
"puppeteer-core": "^23.11.1",
"typescript": "^6.0.3",
"yaml": "^2.8.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
public/screens/guilds.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
public/screens/houses.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
public/screens/news.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

189
scripts/captureScreens.mjs Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* captureScreens.mjs — retakes the screenshots in `src/data/screens.mjs`.
*
* PLAN.md §13 phase 9, D4 / D45.
*
* node scripts/captureScreens.mjs # every web screen
* node scripts/captureScreens.mjs shard-status admin-users
* RG_DEMO=http://localhost:3000 node scripts/captureScreens.mjs
*
* ---------------------------------------------------------------------------------------
* AN AUTHORING TOOL, LIKE buildBrandAssets.mjs — NOT A CHECK
* ---------------------------------------------------------------------------------------
* This never runs in CI and CI never needs it: its output is committed, because the site
* must build from a clean checkout with no game server, no database and no browser. What
* CI runs is `checkScreens.mjs`, which only reads the files this produced.
*
* It exists because D4 asks for real screenshots of a real deployment, and the way real
* screenshots rot is that the recipe for taking them lives in somebody's memory. The rig
* is written down in PLAN.md §13; the framing — route, viewport, scroll offset, whether to
* sign in — is written down in `screens.mjs`; and this turns the two into files.
*
* ---------------------------------------------------------------------------------------
* WHY puppeteer-core AND NOT puppeteer
* ---------------------------------------------------------------------------------------
* `puppeteer` downloads its own Chromium — a hundred-odd megabytes fetched on every clean
* install of a repository that needs a browser once per redesign. `puppeteer-core` drives
* a Chrome that is already on the machine, which every machine that can look at this site
* has. Point `RG_CHROME` at it if it is somewhere unusual.
*
* ---------------------------------------------------------------------------------------
* WHY IT SIGNS IN THROUGH THE API RATHER THAN THE LOGIN FORM
* ---------------------------------------------------------------------------------------
* The administration screens need a session, and typing into the login form is the part of
* a browser script most likely to break on a redesign — a moved field, a renamed button, a
* React input that ignores synthetic typing. The session cookie is the only thing actually
* wanted, so this asks the API for one from inside the page and lets the browser store it.
* If that call stops returning 200 the script says so and stops, rather than quietly
* screenshotting a login screen twelve times.
*/
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import { screens, screensOf, WEB } from '../src/data/screens.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(HERE, '..', 'public', 'screens');
const BASE = (process.env.RG_DEMO || 'http://localhost:3000').replace(/\/+$/, '');
const USER = process.env.RG_ADMIN_USER || 'demoadmin';
const PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
/** Where Chrome usually is, per platform. First hit wins; `RG_CHROME` beats all of them. */
const CHROME_CANDIDATES = [
process.env.RG_CHROME,
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
].filter(Boolean);
const wanted = process.argv.slice(2).filter((arg) => !arg.startsWith('-'));
const todo = screensOf('web').filter((shot) => wanted.length === 0 || wanted.includes(shot.id));
if (todo.length === 0) {
const known = screens.map((shot) => shot.id).join(', ');
console.error(`Nothing to capture. Known ids: ${known}`);
process.exit(1);
}
const chrome = CHROME_CANDIDATES.find((candidate) => existsSync(candidate));
if (!chrome) {
console.error(
'No Chrome found. Set RG_CHROME to the browser executable — this script drives an\n' +
'installed Chrome rather than downloading one (see the header).',
);
process.exit(1);
}
const puppeteer = (await import('puppeteer-core')).default;
mkdirSync(OUT, { recursive: true });
const browser = await puppeteer.launch({
executablePath: chrome,
headless: 'new',
defaultViewport: { ...WEB.viewport, deviceScaleFactor: WEB.scale },
// Scrollbars are the browser's furniture, not the product's, and a colour profile that
// is not sRGB makes the palette in a screenshot disagree with the palette on the page.
args: ['--hide-scrollbars', '--force-color-profile=srgb'],
});
/**
* One page per privilege level rather than signing in and out around each shot: signing
* out is the step that gets forgotten, and a public page captured with an admin session
* shows a navigation bar the public never sees.
*/
const anon = await browser.newPage();
const admin = await browser.newPage();
await admin.goto(BASE, { waitUntil: 'domcontentloaded' });
const status = await admin.evaluate(
async (username, password) => {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
return res.status;
},
USER,
PASS,
);
if (status !== 200) {
console.error(
`Could not sign in as "${USER}" at ${BASE} (HTTP ${status}).\n` +
'Seed the demo first — see PLAN.md §13 phase 9 and scripts/seedDemo.mjs.',
);
await browser.close();
process.exit(1);
}
let failures = 0;
for (const shot of todo) {
const page = shot.admin ? admin : anon;
const url = BASE + shot.route;
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
if (shot.scrollY) {
await page.evaluate((y) => window.scrollTo(0, y), shot.scrollY);
}
// Live pages settle after their first paint: a shard panel fills in from an event
// stream, a list re-sorts once its data lands. A second is cheap and the difference
// between a screenshot of the product and a screenshot of its loading state.
await new Promise((resolve) => setTimeout(resolve, 1200));
const png = await page.screenshot({ type: 'png' });
const file = path.join(OUT, `${shot.id}.webp`);
// Quality 82 is where UI text stops visibly softening; the files land near 150 KB,
// which is what makes a page with five of them still a page and not a download.
await sharp(png).webp({ quality: 82 }).toFile(file);
const meta = await sharp(file).metadata();
if (meta.width !== WEB.width || meta.height !== WEB.height) {
console.error(
` ! ${shot.id}: got ${meta.width}x${meta.height}, expected ${WEB.width}x${WEB.height}`,
);
failures++;
continue;
}
console.log(` + ${shot.id.padEnd(18)} ${shot.route.padEnd(20)} ${meta.width}x${meta.height}`);
} catch (err) {
console.error(` ! ${shot.id}: ${err.message}`);
failures++;
}
}
await browser.close();
// A file left behind by a screen that has since been renamed or dropped is a file the
// site still ships and nothing points at. Say so; do not delete somebody's work silently.
if (wanted.length === 0) {
const declared = new Set(screensOf('web').map((shot) => `${shot.id}.webp`));
const phones = new Set(screensOf('phone').map((shot) => `${shot.id}.webp`));
const orphans = readdirSync(OUT).filter((name) => !declared.has(name) && !phones.has(name));
if (orphans.length > 0) {
console.log(`\nNot declared in screens.mjs, left alone: ${orphans.join(', ')}`);
}
}
console.log(`\n${todo.length - failures} captured, ${failures} failed.`);
process.exit(failures > 0 ? 1 : 0);

171
scripts/checkScreens.mjs Normal file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env node
/**
* checkScreens.mjs — the screenshots agree with what the pages say about them.
*
* PLAN.md §12, §13 phase 9, D45.
*
* node scripts/checkScreens.mjs
*
* ---------------------------------------------------------------------------------------
* WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK
* ---------------------------------------------------------------------------------------
* 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page
* still builds, still lays out, and only a reader sees the broken frame.
*
* 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic
* attributes, and an attribute that disagrees with the file is a page that jumps as the
* image decodes. It also catches a re-capture taken at the wrong viewport, which looks
* fine on its own and wrong beside the others.
*
* 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a
* file the container still ships and nobody looks at — and, worse, one that never gets
* retaken, so it silently becomes the oldest thing in the repository.
*
* 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs`
* that no page renders is a capture being maintained for nothing. Usage is a literal
* search for the id across `src/`, which is how both readers of the data refer to one —
* `<Screenshot id="admin-users" />` and the `groupScreens` map on `/features/`.
*
* 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an
* accessibility failure the build cannot otherwise see, and a caption is the sentence
* that makes a screenshot evidence rather than decoration.
*
* ---------------------------------------------------------------------------------------
* WHY IT READS THE PNG HEADER ITSELF
* ---------------------------------------------------------------------------------------
* It does not: it reads the WebP header, and it does it with twenty lines rather than a
* dependency. `sharp` is already here for the brand assets and could answer this, but this
* check runs in CI on every pull request and a check that needs a native image library to
* tell you a file is 1920 pixels wide is a check that will one day fail for a reason that
* has nothing to do with screenshots.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { screens, WEB, PHONE } from '../src/data/screens.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(HERE, '..');
const DIR = path.join(ROOT, 'public', 'screens');
const SRC = path.join(ROOT, 'src');
const problems = [];
/**
* The pixel size of a WebP file, from its header.
*
* A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and
* lossless ("VP8L") pack the dimensions differently, and an animated or extended file
* ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of
* encoder should not turn this check into a mystery, so all three are handled.
*/
function webpSize(file) {
const buf = readFileSync(file);
if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
return null;
}
const chunk = buf.toString('ascii', 12, 16);
if (chunk === 'VP8X') {
return {
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
};
}
if (chunk === 'VP8L') {
const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24);
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
}
if (chunk === 'VP8 ') {
return {
width: buf.readUInt16LE(26) & 0x3fff,
height: buf.readUInt16LE(28) & 0x3fff,
};
}
return null;
}
/** Every file under `src/`, read once, so usage is a search rather than a guess. */
function sourceText() {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8'));
}
};
walk(SRC);
return out;
}
const sources = sourceText();
const declared = new Set();
for (const shot of screens) {
const name = `${shot.id}.webp`;
const file = path.join(DIR, name);
declared.add(name);
if (!existsSync(file)) {
problems.push(
`${shot.id}: no file at public/screens/${name}. ` +
`Retake it: node scripts/captureScreens.mjs ${shot.id}`,
);
continue;
}
const want = shot.family === 'web' ? WEB : PHONE;
const size = webpSize(file);
if (!size) {
problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`);
} else if (size.width !== want.width || size.height !== want.height) {
problems.push(
`${shot.id}: file is ${size.width}x${size.height}, ` +
`declared ${want.width}x${want.height} for the "${shot.family}" family.`,
);
}
if (!shot.alt || shot.alt.length < 20) {
problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`);
}
if (!shot.caption) {
problems.push(`${shot.id}: no caption.`);
}
const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`));
if (!used) {
problems.push(
`${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`,
);
}
}
if (existsSync(DIR)) {
for (const name of readdirSync(DIR)) {
if (!declared.has(name)) {
problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`);
}
}
}
if (problems.length > 0) {
console.error(`\ncheckScreens: ${problems.length} problem(s)\n`);
for (const problem of problems) console.error(` - ${problem}`);
console.error('');
process.exit(1);
}
console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);

462
scripts/seedDemo.mjs Normal file
View File

@@ -0,0 +1,462 @@
#!/usr/bin/env node
/**
* seedDemo.mjs — the deployment the screenshots are taken of. PLAN.md §13 phase 9, D45.
*
* node scripts/seedDemo.mjs → seed (idempotent; safe to re-run)
* node scripts/seedDemo.mjs --dry-run → say what it would do, write nothing
*
* Environment (all optional; the defaults are this machine's review stack):
*
* RG_BASE http://localhost:3000 the website the seed drives
* RG_ADMIN_USER demoadmin an existing admin, created by website's own
* RG_ADMIN_PASS DemoReview!2026 `npm run seed` — see PLAN.md §13 phase 9
* RG_DEMO_PASS DemoReview!2026 the password every seeded cast member gets
* UOLINK_BASE http://127.0.0.1:8080 sidecar REST, written to Admin → Shard
* UOLINK_WS ws://127.0.0.1:8080/ws sidecar WebSocket
* UOLINK_TOKEN (unset) sidecar auth token; skipped when absent
* UOLINK_PROTOCOL 4 wire protocol to pin — see the note below
*
* ---------------------------------------------------------------------------------------
* WHY THE SEED DRIVES THE API AND NEVER THE DATABASE
* ---------------------------------------------------------------------------------------
* Every row this creates could have been an INSERT, and every INSERT would have been a
* second implementation of a rule the website already owns: how a body is sanitized, what
* a slug may contain, which excerpt is derived when none is given, how a password is
* hashed. A seed that writes SQL directly produces a database the product could not have
* produced, and screenshots of that database show a product that does not exist.
*
* So this speaks HTTP to a running site, as an admin, through the same endpoints the admin
* panel calls. The cost is that the site has to be up; the benefit is that the content is
* real, and that this script keeps working when a column moves.
*
* ---------------------------------------------------------------------------------------
* WHY IT IS IDEMPOTENT RATHER THAN DESTRUCTIVE
* ---------------------------------------------------------------------------------------
* Re-running must not double the news list, and must not erase a screenshot rig somebody
* has been adjusting by hand. Every step therefore looks before it writes and reports
* `= exists` rather than failing. That also makes the script usable as a repair: point it
* at a stack that has drifted and it puts back only what is missing.
*
* What it deliberately does NOT create: anything the shard owns. Teams arrive from the
* guild board over the bridge, the marketplace from player vendors, the atlas from real
* spawners (PLAN.md §13 phase 9, D42). Seeding those would be inventing game state that
* the product is supposed to be showing, which is exactly what D4 forbids.
*/
import { readFileSync } from 'node:fs';
const BASE = (process.env.RG_BASE || 'http://localhost:3000').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const ADMIN_USER = process.env.RG_ADMIN_USER || 'demoadmin';
const ADMIN_PASS = process.env.RG_ADMIN_PASS || 'DemoReview!2026';
const DEMO_PASS = process.env.RG_DEMO_PASS || 'DemoReview!2026';
const UOLINK_BASE = process.env.UOLINK_BASE || 'http://127.0.0.1:8080';
const UOLINK_WS = process.env.UOLINK_WS || 'ws://127.0.0.1:8080/ws';
const UOLINK_TOKEN = process.env.UOLINK_TOKEN || '';
// The pinned wire protocol has to be STATED, not left to the module's default.
//
// `module-uo`'s schema fragment still carries `protocol INT NOT NULL DEFAULT 3`, from the
// protocol-3 cutover; the sidecar on `link` `main` speaks 4. The module handles protocol 4's
// frames — `guild.roster` and `guild.leave` ingest landed with the Teams cutover — but a
// FRESH install pins 3, and the sidecar answers a 3 with `409 protocol version mismatch` on
// every REST call. So a new deployment reads nothing from its shard until somebody edits the
// number in Admin → Shard. Raised with the org lead rather than patched from here: the fix
// belongs in `module-uo`, not in this repo's screenshot rig (PLAN.md §13 phase 9).
const UOLINK_PROTOCOL = Number(process.env.UOLINK_PROTOCOL || 4);
const DRY = process.argv.includes('--dry-run');
// ── The demo deployment's identity (D43) ───────────────────────────────────────────────
//
// A neutral demo brand rather than UOMysticmoon: the screenshots show the platform, not a
// private shard, and §15's demo VM can wear the same identity so the imagery stays true the
// day it exists. The name is deliberately "… Demo" rather than an invented community —
// nobody should have to wonder whether they are looking at a real server they could join.
// The published contact address lives in exactly one file in this repository (D13), and
// `checkFacts.mjs` fails the build if a literal address appears anywhere else — including
// here. So the demo wears the same address the site publishes, read from the same place.
const brandDefault = JSON.parse(
readFileSync(new URL('../brand-default/brand.json', import.meta.url), 'utf8'),
);
const SETTINGS = {
site_title: 'Runic Gateway Demo',
site_mode: 'live',
status_message: 'Live — the demo shard is up.',
homepage_teaser:
'A public demonstration of Runic Gateway: a self-hosted community site wired to a ' +
'live game server. Everything on this site is real data from the shard behind it.',
contact_email: brandDefault.contactEmail,
};
// ── The cast ───────────────────────────────────────────────────────────────────────────
//
// Five accounts, one per role the admin screens distinguish, so a screenshot of the users
// table shows the role column doing something. Names are ordinary fantasy given names and
// belong to nobody.
const USERS = [
{ username: 'aldricmoss', role: 'moderator' },
{ username: 'brannwen', role: 'editor' },
{ username: 'sablequill', role: 'player' },
{ username: 'tobinreed', role: 'player' },
{ username: 'mirenavox', role: 'player' },
];
// ── News, five-on-friday, the newsletter ───────────────────────────────────────────────
//
// Written as a small community's real output rather than lorem: a patch note, an event, a
// maintenance notice and a Friday post. Bodies are short HTML because that is what the
// editor stores, and the list screens show the excerpt anyway.
const POSTS = [
{
category: 'news',
title: 'Autumn patch: vendor search, and a fix for house decay',
excerpt:
'Player-vendor listings are now searchable from the site, and the decay timer no ' +
'longer resets when a co-owner logs in.',
body:
'<p>The autumn patch is live. The headline change is that <strong>every player ' +
'vendor on the shard is now searchable from this site</strong> — the marketplace ' +
'page reads the same live feed the game does, so a listing appears within a minute ' +
'of being priced.</p><p>We also fixed the house decay timer resetting when a ' +
'co-owner logged in. That bug had been quietly keeping condemned houses alive since ' +
'spring.</p><p>Full notes are on the wiki.</p>',
published: true,
},
{
category: 'news',
title: 'The Harvest Moon festival opens this weekend',
excerpt:
'Three days of gatherings at the crossroads, with a champion spawn on the last ' +
'night. Everyone is welcome, no signup needed.',
body:
'<p>The Harvest Moon festival runs from Friday evening to Sunday night at the ' +
'crossroads north of town. There is no signup and no entry fee — turn up.</p>' +
'<p>Saturday is the market day; bring anything you want to sell and we will set out ' +
'extra vendor stalls. Sunday night closes with a champion spawn, which will be ' +
'announced in game and on the shard status page here.</p>',
published: true,
},
{
category: 'news',
title: 'Scheduled maintenance, Tuesday 03:00 UTC',
excerpt:
'About twenty minutes of downtime for a server restart and a world save. The site ' +
'stays up throughout.',
body:
'<p>We are restarting the shard on Tuesday at 03:00 UTC for a world save and a ' +
'server update. Expect about twenty minutes of downtime.</p><p>This site stays up ' +
'while the shard is down — the status panel will simply show the shard as offline, ' +
'and the marketplace and atlas will show their last known state.</p>',
published: true,
},
{
category: 'five-on-friday',
title: 'Five on Friday: the ones who keep the roads clear',
excerpt:
'Five players who spent the week doing unglamorous work, and what they were up to.',
body:
'<p>Five people who made the week better for everybody else:</p><ol><li>Sable, for ' +
'restocking the free reagent stall three times without being asked.</li><li>Tobin, ' +
'for guiding two new players through their first dungeon.</li><li>Mirena, for the ' +
'map corrections on the wiki.</li><li>Brannwen, for writing up the champion ' +
'rotation.</li><li>Aldric, for handling a difficult report quietly and well.</li>' +
'</ol>',
published: true,
},
{
category: 'newsletter',
title: 'Monthly notes — what changed, and what is next',
excerpt:
'A month of changes in one place: the vendor search, the new guides, and what we ' +
'are working on next.',
body:
'<p>A quiet, productive month. The vendor search shipped, the wiki gained four ' +
'guides, and the guild boards now update on the site within a minute of a change in ' +
'game.</p><p>Next month we are looking at the champion boards and at making the ' +
'atlas easier to read on a phone.</p>',
published: true,
},
];
// ── The wiki ───────────────────────────────────────────────────────────────────────────
//
// One category and four pages, because the wiki index screenshot needs a category with
// enough in it to look like a wiki rather than a placeholder.
const WIKI_CATEGORY = {
slug: 'guides',
title: 'Guides',
description: 'How things work here, written by the people who play here.',
};
const WIKI_PAGES = [
{
slug: 'getting-started',
title: 'Getting started',
excerpt: 'What to install, how to connect, and the first hour.',
body:
'<h2>Before you connect</h2><p>You need a game client and an account. Make the ' +
'account on this site — the shard accepts accounts created here, and it saves you ' +
'typing your password into a chat window.</p><h2>The first hour</h2><p>Start in ' +
'town, take the newcomer quest, and do not sell your starting tools. If you get ' +
'stuck, ask in Discord: somebody is usually around.</p>',
},
{
slug: 'player-vendors',
title: 'Player vendors',
excerpt: 'How to hire one, how to price, and how the site search finds you.',
body:
'<h2>Hiring a vendor</h2><p>Any house you own or co-own can hold vendors. Hire one ' +
'from an innkeeper and place it inside.</p><h2>Being findable</h2><p>Everything a ' +
'vendor holds is published to the marketplace on this site within about a minute, ' +
'including the price and the house it stands in. If a listing looks stale, the ' +
'shard was probably down when you priced it — it will correct itself on the next ' +
'sweep.</p>',
},
{
slug: 'housing-and-decay',
title: 'Housing and decay',
excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.',
body:
'<h2>Placement</h2><p>Houses can be placed anywhere the client allows, with the ' +
'usual clearance rules. There is no lottery.</p><h2>Decay</h2><p>A house decays if ' +
'nobody with access logs in for long enough. The site lists houses approaching ' +
'collapse on the housing page, which is the same data the game uses — not a ' +
'prediction.</p>',
},
{
slug: 'community-rules',
title: 'Community rules',
excerpt: 'The short version: do not be the reason somebody stops playing.',
body:
'<h2>The rules</h2><ol><li>No harassment, in game or on the site.</li><li>No ' +
'exploiting bugs — report them instead, and you will usually be thanked in ' +
'public.</li><li>One account per person for events with prizes.</li></ol>' +
'<h2>Appeals</h2><p>Every moderation action can be appealed from your account page. ' +
'An appeal is read by somebody who was not involved in the original action.</p>',
},
];
// ── HTTP plumbing ──────────────────────────────────────────────────────────────────────
//
// One cookie jar, because the session is a cookie and `fetch` has no jar of its own. Only
// the value of the auth cookie matters, so this keeps exactly that.
let cookie = '';
let created = 0;
let existed = 0;
function keepCookies(res) {
const raw = res.headers.getSetCookie?.() ?? [];
for (const line of raw) {
const [pair] = line.split(';');
if (pair.trim()) cookie = pair.trim();
}
}
async function call(method, path, body) {
const res = await fetch(`${API}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
keepCookies(res);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { ok: res.ok, status: res.status, data };
}
function say(mark, what) {
console.log(` ${mark} ${what}`);
if (mark === '+') created += 1;
if (mark === '=') existed += 1;
}
function fail(what, res) {
console.error(`\n ! ${what} failed — HTTP ${res.status}`);
console.error(` ${JSON.stringify(res.data)?.slice(0, 400)}`);
process.exitCode = 1;
}
// ── The steps ──────────────────────────────────────────────────────────────────────────
async function login() {
const res = await call('POST', '/auth/login', { username: ADMIN_USER, password: ADMIN_PASS });
if (!res.ok) {
console.error(
`\nCould not log in as "${ADMIN_USER}". Create the admin first, from the website repo:\n` +
` cd website/server && DB_NAME=<demo db> ADMIN_USERNAME=${ADMIN_USER} ` +
`ADMIN_PASSWORD='…' node db/seed.js\n`,
);
fail('login', res);
process.exit(1);
}
console.log(`\nsigned in as ${ADMIN_USER} at ${BASE}`);
}
async function settings() {
console.log('\nsite settings (D43 — the neutral demo identity)');
if (DRY) {
for (const [k, v] of Object.entries(SETTINGS)) say('~', `${k} = ${v}`);
return;
}
const res = await call('PUT', '/admin/settings', SETTINGS);
if (!res.ok) return fail('settings', res);
for (const [k, v] of Object.entries(SETTINGS)) say('+', `${k} = ${String(v).slice(0, 60)}`);
}
async function uoLink() {
console.log('\nshard connection (Admin → Shard)');
if (!UOLINK_TOKEN) {
say('~', 'UOLINK_TOKEN unset — leaving the sidecar config alone');
return;
}
const now = await call('GET', '/admin/uo-link/config');
if (now.status === 404) {
say('~', 'no /admin/uo-link route — the uo module is not installed');
return;
}
if (
now.ok &&
now.data?.config?.baseUrl === UOLINK_BASE &&
now.data?.config?.protocol === UOLINK_PROTOCOL &&
now.data?.config?.enabled
) {
say('=', `already pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
return;
}
if (DRY) return say('~', `would point the site at ${UOLINK_BASE}`);
const res = await call('PUT', '/admin/uo-link/config', {
baseUrl: UOLINK_BASE,
wsUrl: UOLINK_WS,
token: UOLINK_TOKEN,
protocol: UOLINK_PROTOCOL,
enabled: true,
});
if (!res.ok) return fail('uo-link config', res);
say('+', `pointed at ${UOLINK_BASE} (protocol ${UOLINK_PROTOCOL})`);
}
async function users() {
console.log('\naccounts');
const list = await call('GET', '/admin/users');
if (!list.ok) return fail('list users', list);
const rows = Array.isArray(list.data) ? list.data : (list.data?.users ?? []);
const have = new Set(rows.map((u) => u.username));
for (const user of USERS) {
if (have.has(user.username)) {
say('=', `${user.username} (${user.role})`);
continue;
}
if (DRY) {
say('~', `${user.username} (${user.role})`);
continue;
}
const res = await call('POST', '/admin/users', {
username: user.username,
password: DEMO_PASS,
role: user.role,
});
if (!res.ok) {
fail(`create ${user.username}`, res);
continue;
}
say('+', `${user.username} (${user.role})`);
}
}
async function posts() {
console.log('\nposts');
const list = await call('GET', '/admin/posts');
if (!list.ok) return fail('list posts', list);
const rows = Array.isArray(list.data) ? list.data : (list.data?.posts ?? []);
const have = new Set(rows.map((p) => p.title));
for (const post of POSTS) {
if (have.has(post.title)) {
say('=', `${post.category}: ${post.title}`);
continue;
}
if (DRY) {
say('~', `${post.category}: ${post.title}`);
continue;
}
const res = await call('POST', '/admin/posts', post);
if (!res.ok) {
fail(`create post "${post.title}"`, res);
continue;
}
say('+', `${post.category}: ${post.title}`);
}
}
async function wiki() {
console.log('\nwiki');
const cats = await call('GET', '/admin/wiki/categories');
if (!cats.ok) return fail('list wiki categories', cats);
const catRows = Array.isArray(cats.data) ? cats.data : (cats.data?.categories ?? []);
let category = catRows.find((c) => c.slug === WIKI_CATEGORY.slug);
if (category) {
say('=', `category ${WIKI_CATEGORY.slug}`);
} else if (DRY) {
say('~', `category ${WIKI_CATEGORY.slug}`);
} else {
const res = await call('POST', '/admin/wiki/categories', WIKI_CATEGORY);
if (!res.ok) return fail('create wiki category', res);
category = res.data?.category ?? res.data;
say('+', `category ${WIKI_CATEGORY.slug}`);
}
const pages = await call('GET', '/admin/wiki');
if (!pages.ok) return fail('list wiki pages', pages);
const pageRows = Array.isArray(pages.data) ? pages.data : (pages.data?.pages ?? []);
const have = new Set(pageRows.map((p) => p.slug));
for (const page of WIKI_PAGES) {
if (have.has(page.slug)) {
say('=', `page ${page.slug}`);
continue;
}
if (DRY) {
say('~', `page ${page.slug}`);
continue;
}
const res = await call('POST', '/admin/wiki', {
...page,
category_id: category?.id ?? null,
published: true,
});
if (!res.ok) {
fail(`create wiki page "${page.slug}"`, res);
continue;
}
say('+', `page ${page.slug}`);
}
}
// ── main ───────────────────────────────────────────────────────────────────────────────
console.log(DRY ? '\nseedDemo — DRY RUN, nothing will be written' : '\nseedDemo');
await login();
await settings();
await uoLink();
await users();
await posts();
await wiki();
console.log(
`\n${DRY ? 'would create' : 'created'} ${created}, already present ${existed}` +
(process.exitCode ? ' — with failures above' : ''),
);
console.log(
'\nWhat this does NOT seed, on purpose: teams, the marketplace, houses, points boards\n' +
'and the atlas. Those arrive from the shard over the bridge (D42) — start the sidecar\n' +
'and the shard, and they populate themselves.\n',
);

View File

@@ -0,0 +1,81 @@
---
import { screenById, WEB, PHONE } from '../data/screens.mjs';
/**
* One screenshot, as a figure with its caption. PLAN.md §13 phase 9, D4 / D44.
*
* -----------------------------------------------------------------------------------------
* WHY THE PAGE PASSES AN ID AND NOTHING ELSE
* -----------------------------------------------------------------------------------------
* A marketing page and a documentation page show the same administration screen for
* different reasons, and the thing they must not do is describe it differently. The alt
* text and the caption therefore live with the capture in `screens.mjs`, next to the route
* they came from, and a page asks for `admin-shard` rather than restating what is in it.
*
* It also means a re-capture cannot silently invalidate a caption: the sentence and the
* frame it describes are edited in the same file.
*
* -----------------------------------------------------------------------------------------
* WHY IT FAILS THE BUILD ON AN UNKNOWN ID
* -----------------------------------------------------------------------------------------
* The alternative is a page that renders a broken image, which looks like a deployment
* problem rather than a typo and survives review. `checkScreens.mjs` covers the other
* direction — a declared screen whose file is missing — so between them a screenshot is
* either complete or the build stops.
*/
interface Props {
/** An `id` from `src/data/screens.mjs`. */
id: string;
/** Suppress the caption where the surrounding prose already says it. */
bare?: boolean;
}
const { id, bare = false } = Astro.props;
const shot = screenById(id);
if (!shot) {
throw new Error(`Screenshot "${id}" is not declared in src/data/screens.mjs`);
}
const src = `/screens/${shot.id}.webp`;
// Intrinsic size comes from the family rather than the entry: every capture in a family is
// taken at one geometry (see screens.mjs), and `checkScreens.mjs` asserts the files really
// are that size, so these attributes cannot drift from the pixels.
const { width, height } = shot.family === 'web' ? WEB : PHONE;
---
<figure class="shot">
<img
src={src}
alt={shot.alt}
width={width}
height={height}
loading="lazy"
decoding="async"
/>
{!bare && <figcaption>{shot.caption}</figcaption>}
</figure>
<style>
.shot {
margin: 2rem 0;
}
.shot img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
}
.shot figcaption {
margin: 0.85rem 0 0;
color: var(--dim);
font-size: 0.9rem;
line-height: 1.5;
}
</style>

View File

@@ -1,61 +1,36 @@
---
import { screensOf, PHONE } from '../../data/screens.mjs';
/**
* The app's screenshot strip — defined now, empty until phase 9 (D26).
* The app's screenshot strip. Reserved in phase 5 (D26), filled in phase 9.
*
* ---------------------------------------------------------------------------------------
* WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING
* WHY THIS COMPONENT EXISTED FOR A PHASE WITH NOTHING IN IT
* ---------------------------------------------------------------------------------------
* PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist —
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen:
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against a
* PLAN.md §10 said `/app/` shows "the 14 existing screenshots". They exist —
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: a
* trusted-device and recovery-code smoke test from 2026-07-22, captured against a
* development instance with no seeded content, before the theming work that changed how
* every screen looks. Five of them are two-factor prompts. The home shot is an empty page.
*
* Shipping them would break two things at once: D4, which says real screenshots from the
* review stack rather than placeholders, and §1, because they would show an app that no
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills
* it, and phase 9 is already the phase that stands up the review stack and seeds the
* content the web screenshots need. Adding an emulator pass to a rig that is being built
* anyway is most of the work already done, and it has the property that the phone shots
* and the browser shots then show the same deployment on the same day.
*
* The component exists rather than the page carrying a `TODO` because a defined shape is
* what makes phase 9 a data change instead of a design task: fill `shots`, and the section
* appears with a heading, a caption line and a grid. Nothing else has to be decided then.
* Shipping them would have broken D4 and §1 at once, so D26 reserved the slot for the phase
* that stands up the review stack anyway. The shape was defined then and the data arrived
* now, which is exactly what it was for: filling it was a data change.
*
* ---------------------------------------------------------------------------------------
* WHAT PHASE 9 SHOULD PUT HERE
* WHY IT READS screens.mjs RATHER THAN HOLDING ITS OWN LIST
* ---------------------------------------------------------------------------------------
* Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the
* seeded review stack, one per idea rather than one per screen: the shard hub with live
* data, the marketplace, a character sheet, the news list, the notification settings, and
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a
* target — it was the number that happened to exist.
* The draft carried its own `shots` array, written before there was anywhere else to put
* one. There is now: `src/data/screens.mjs` holds every capture the site ships, web and
* phone alike, and `scripts/checkScreens.mjs` proves each one exists at the size the markup
* claims. A second list here would be the one nothing checks.
*
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with
* the image, not branding an operator overrides (§7).
* The phone captures come from an emulator pointed at the same seeded deployment the web
* screenshots were taken from, on the same day — which is the property D26 was really
* after, since the app takes its colours, type and navigation from the site it connects to.
*/
/**
* One capture. `width` and `height` are the real pixel dimensions and are required rather
* than optional: without them the page reflows as each image decodes, and a strip of six
* phone screenshots is the worst possible place for that.
*
* Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the
* `.mjs` data files use — and it has to be typed explicitly, because an empty array
* annotated by inference is `any[]` and `astro check` is right to refuse it.
*/
interface Shot {
/** Site-absolute path under `/screens/`. */
src: string;
/** What the screen shows, for somebody who cannot see it. */
alt: string;
caption: string;
width: number;
height: number;
}
const shots: Shot[] = [];
const shots = screensOf('phone');
---
{
@@ -63,8 +38,8 @@ const shots: Shot[] = [];
<section class="page section shots">
<h2>What it looks like</h2>
<p class="prose shots__lede">
Captured against a real deployment with real content, not mocked up. The app takes
its colours, type and navigation from the site it is connected to, so these show one
Captured against a real deployment with real content, not mocked up. The app takes its
colours, type and navigation from the site it is connected to, so these show one
community's app rather than a neutral one.
</p>
@@ -72,10 +47,10 @@ const shots: Shot[] = [];
{shots.map((shot) => (
<li class="shots__item">
<img
src={shot.src}
src={`/screens/${shot.id}.webp`}
alt={shot.alt}
width={shot.width}
height={shot.height}
width={PHONE.width}
height={PHONE.height}
loading="lazy"
decoding="async"
/>

View File

@@ -0,0 +1,44 @@
---
import Screenshot from '../Screenshot.astro';
/**
* The homepage's one screenshot. PLAN.md §13 phase 9, D4 / D44.
*
* -----------------------------------------------------------------------------------------
* WHY ONE, AND WHY THIS ONE
* -----------------------------------------------------------------------------------------
* `DataPath` above it draws the claim — a private game server, a sidecar, a public site —
* and a diagram of a data path is a promise that the data arrives. This is the page where
* it arrives, captured from a deployment wired to a running shard, so the section directly
* under the diagram is the diagram's evidence.
*
* A gallery here would compete with `Capabilities` further down, which is the part of the
* homepage that enumerates. So: one figure, the signature screen, and the rest of the set
* on `/features/` where each one sits beside the claim it supports.
*/
---
<section class="page section looks">
<p class="eyebrow">What it looks like</p>
<h2>The other end of that diagram</h2>
<p class="prose looks__lede">
A demo deployment with a real shard behind it. The gold supply, the uptime and the online
count are read from the game server over the bridge — none of it is typed in, and the page
is honest about a quiet realm.
</p>
<Screenshot id="shard-status" />
</section>
<style>
.looks h2 {
margin: 0.35rem 0 0.75rem;
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
}
.looks__lede {
margin: 0;
max-width: 46rem;
color: var(--muted);
}
</style>

View File

@@ -4,6 +4,7 @@ description: Colours, fonts and corners from the Appearance screen; logo, hero a
---
import { Aside } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
One prebuilt image runs as any community's site. Nothing about your identity is compiled
in — it is a theme row in the database, three image files on a mount, and a few environment
@@ -31,6 +32,8 @@ Two things the screen tells you that are easy to miss:
- **The accent reaches the mobile app and the Discord bot**, both of which theme themselves
from this site's public branding. Changing it here changes them.
<Screenshot id="admin-appearance" />
## Brand assets
The same screen uploads three images, and each applies as soon as the upload finishes —

View File

@@ -4,10 +4,13 @@ description: The five states a module can be in, installing and upgrading, disab
---
import { Aside } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
This is what the screen means afterwards.
<Screenshot id="admin-modules" />
## The five states
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable

View File

@@ -4,6 +4,7 @@ description: The module's shard screen — connection settings, what the status
---
import platform from '../../../../data/platform.json';
import Screenshot from '../../../../components/Screenshot.astro';
import { Aside } from '@astrojs/starlight/components';
With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at
@@ -13,6 +14,8 @@ the controls that ride on it.
Setting it up for the first time is
[Connect a game server](/docs/getting-started/connect-a-game-server/).
<Screenshot id="admin-shard" />
## Connection
Four fields, all four printed by the installer, plus the switch that turns the integration

View File

@@ -4,6 +4,7 @@ description: The four roles and what each one reaches, creating accounts, and in
---
import { Aside } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
## The four roles
@@ -24,6 +25,8 @@ Admin routes are re-validated against the database on **every request**, not jus
Demoting an account takes effect at once — the open session does not keep its access until
it expires.
<Screenshot id="admin-users" />
## Creating an account
**Admin → Users → + Add user** creates one directly: username, password, role, and it is

View File

@@ -4,6 +4,7 @@ description: Signing in as the first admin, what the site does before anyone vis
---
import { Aside, Steps } from '@astrojs/starlight/components';
import Screenshot from '../../../../components/Screenshot.astro';
The site is up and nobody can see it yet. That is the intended state: a new deployment
**starts in maintenance mode**, showing visitors a "coming soon" page while the admin panel
@@ -33,6 +34,8 @@ minute, and see [Authentication](/docs/administration/authentication/) for what
**Web Bot Activity** screen shows and how to lift a ban.
</Aside>
<Screenshot id="admin-dashboard" />
## What is already there
The first boot seeds a working site rather than an empty one:

View File

@@ -28,7 +28,7 @@
"releases": {
"link": "v2.0.0",
"installer": "v0.1.1",
"Module-uo": "v1.0.1",
"Module-uo": "v1.0.2",
"Android-app": "v0.5.0"
},

240
src/data/screens.mjs Normal file
View File

@@ -0,0 +1,240 @@
/**
* screens.mjs — every screenshot the site ships, and where it came from.
*
* PLAN.md §13 phase 9, D4 / D42D45.
*
* ---------------------------------------------------------------------------------------
* ONE LIST, THREE READERS
* ---------------------------------------------------------------------------------------
* `scripts/captureScreens.mjs` reads this to know what to shoot and where to click before
* it shoots; `src/components/Screenshot.astro` reads it to render one figure by id; and
* `scripts/checkScreens.mjs` reads it to prove that every file exists at the declared size
* and that nothing in `public/screens/` is orphaned. A screenshot is therefore a data
* change: add an entry, re-run the capture, and the check tells you if you missed a step.
*
* That split is what makes a re-capture cheap. §1 says the site must not describe a
* product that no longer looks like that, and the way a screenshot goes stale is that
* nobody remembers how it was taken. The route, the viewport, the scroll offset and the
* signed-in state are all here, so the answer to "how do I retake this" is one command.
*
* ---------------------------------------------------------------------------------------
* WHY EVERY WEB SHOT IS THE SAME SIZE
* ---------------------------------------------------------------------------------------
* A 1280×800 viewport at 1.5× device pixels — 1920×1200 in the file. Uniform because the
* marketing pages lay them out in a grid and a grid of mixed aspect ratios reads as an
* accident, and because a check that asserts one pair of numbers cannot drift the way a
* per-file table can. Where a page's interesting part is below the fold, `scrollY` moves
* the frame rather than the size changing.
*
* The phone shots are the device's own portrait size and are declared per family for the
* same reason (see `PHONE`).
*
* ---------------------------------------------------------------------------------------
* WHAT IS IN THEM
* ---------------------------------------------------------------------------------------
* A demo deployment of this platform, wired to a real ServUO shard over a real sidecar
* (D42): the marketplace rows are player vendors the game actually holds, the atlas is
* parsed from the shard's own spawn files, the guild rosters came over the bridge. The
* deployment is branded "Runic Gateway Demo" rather than a real community's name (D43) —
* the screenshots show the platform, not somebody's private shard.
*
* Nothing here is a mock-up, and nothing here was drawn.
*/
/** Web capture geometry. The capture script and the check both read these. */
export const WEB = { viewport: { width: 1280, height: 800 }, scale: 1.5, width: 1920, height: 1200 };
/**
* Phone capture geometry — the emulator's own portrait pixels, unscaled.
*
* An API 35 device rather than the API 36 the plan named: the API 36 image on this machine
* had 200 MB left on its data partition and refused the install, and wiping somebody's
* development device to take a screenshot is not a trade worth making. The app targets both.
*/
export const PHONE = { width: 1440, height: 3088 };
/**
* @typedef {object} Screen
* @property {string} id File stem under `public/screens/`, and the handle a page uses.
* @property {string} route Route on the demo deployment. The capture script's only input.
* @property {boolean} admin Capture signed in as an administrator rather than anonymously.
* @property {number} [scrollY] Pixels to scroll before the shot, when the subject is below the fold.
* @property {string} alt What the screen shows, for somebody who cannot see it.
* @property {string} caption The sentence printed under the figure.
* @property {'web'|'phone'} family Which geometry the file follows.
*/
/** @type {Screen[]} */
export const screens = [
// ── The product's public surfaces ───────────────────────────────────────────────────
{
id: 'shard-status',
route: '/uo/shard',
admin: false,
alt: 'The shard page of a Runic Gateway site, showing the shard online, its gold supply, the state of the shard link and how many players are online.',
caption:
'The shard console. Every number on it came over the bridge from a running game server — nothing here is stored by hand.',
family: 'web',
},
{
id: 'marketplace',
route: '/uo/market',
admin: false,
scrollY: 470,
alt: 'The marketplace page, listing items for sale by player vendors with their prices, shop names and locations, above a search box and price filters.',
caption:
'Player vendors, searchable from the website — the same index the in-game vendor search reads, honouring the same per-vendor opt-out.',
family: 'web',
},
{
id: 'spawn-atlas',
route: '/uo/atlas',
admin: false,
scrollY: 430,
alt: 'The spawn atlas, listing creatures with how many of them spawn and on which facets, above a search box and facet filters.',
caption:
"The spawn atlas is parsed from the shard's own spawn files, so it stays accurate whether or not the server is up.",
family: 'web',
},
{
id: 'guilds',
route: '/uo/guilds',
admin: false,
alt: 'The guilds page, listing each guild on the shard with its abbreviation, how many members are online, and its leader.',
caption:
'Guilds arrive from the shard, not from a form — and a Team on the website is one of them, with its own forum and roster.',
family: 'web',
},
{
id: 'houses',
route: '/uo/houses',
admin: false,
alt: 'The houses page, listing homes that have entered their final decay stage with their owner and location.',
caption:
'Houses in danger, from the same decay data the game uses — a live process, not a nightly export.',
family: 'web',
},
{
id: 'news',
route: '/site/news',
admin: false,
alt: 'The news page, listing posts with their category, date, headline and summary.',
caption:
'News, five-on-friday and the newsletter are one posting system with three categories — and none of it knows what game you run.',
family: 'web',
},
// ── The administration screens the documentation describes ──────────────────────────
{
id: 'admin-dashboard',
route: '/admin',
admin: true,
alt: 'The administration dashboard, showing the site mode, counts of posts, wiki pages and users, and a feed of recent administrative activity.',
caption: 'The dashboard on first sign-in: site mode, what the site holds, and who has done what.',
family: 'web',
},
{
id: 'admin-users',
route: '/admin/users',
admin: true,
alt: 'The users screen, listing accounts with their role and status and the controls to change them.',
caption: 'Users and roles. A change of role takes effect on the next request, not the next login.',
family: 'web',
},
{
id: 'admin-shard',
route: '/admin/uo/link',
admin: true,
alt: 'The shard connection screen, showing the sidecar connected, the websocket ingest live, and fields for the sidecar URL, token and protocol version.',
caption:
'The shard connection, showing a live sidecar. The token is write-only: it is never sent back to any client, including this screen.',
family: 'web',
},
{
id: 'admin-modules',
route: '/admin/modules',
admin: true,
alt: 'The modules screen, showing the Ultima Online module running with the routes it mounts, and the list of hosts modules may be installed from.',
caption:
'A module is installed from a release URL and runs inside the server, so only listed hosts are allowed to serve one.',
family: 'web',
},
{
id: 'admin-appearance',
route: '/admin/appearance',
admin: true,
alt: 'The appearance screen, showing the colour and typography controls a deployment uses to set its own theme.',
caption: 'Branding is data. One image runs as any community, and the app takes its colours from here too.',
family: 'web',
},
// ── The Android app, against the same deployment on the same day (D26) ──────────────
//
// Captured from an emulator pointed at the demo stack through `adb reverse`, signed in as
// an ordinary player. The app takes its name, colours and navigation from the site it is
// connected to, so these are not a neutral app: they are one deployment's app.
{
id: 'app-home',
route: '/',
admin: false,
alt: "The app's home screen, showing the deployment's emblem and name, a live online indicator and its description.",
caption:
"The app is one screen of setup: type the address of a site, and it becomes that community's app.",
family: 'phone',
},
{
id: 'app-shard',
route: '/shard',
admin: false,
alt: "The app's shard screen, showing the shard online with its gold supply, links to champion spawns, guilds, governors and falling houses, and a live activity feed.",
caption:
'The same bridge feeds the phone. The activity list is live — those two houses entered their final decay stage while this was open.',
family: 'phone',
},
{
id: 'app-market',
route: '/market',
admin: false,
alt: "The app's marketplace, listing weapons for sale with their price, the shop selling them and where it stands.",
caption: 'Every player vendor on the shard, searchable from a phone.',
family: 'phone',
},
{
id: 'app-wiki',
route: '/wiki',
admin: false,
alt: "The app's wiki index, listing pages with their category and summary above a search box.",
caption:
"The wiki, the news and the rules are the site's own content, rendered natively rather than in a web view.",
family: 'phone',
},
{
id: 'app-account',
route: '/account',
admin: false,
alt: "The app's account screen for a signed-in player, with username and password controls, two-factor authentication, trusted devices and recovery codes.",
caption:
'Self-service, and the same session model as the website: two-factor, trusted devices and recovery codes all live here.',
family: 'phone',
},
{
id: 'app-drawer',
route: '/',
admin: false,
alt: "The app's navigation drawer for a signed-in player, listing the deployment's own pages above the player's own account, characters, vendors and houses.",
caption:
"The drawer is the deployment's own navigation, not a fixed menu — a site that renames or reorders its pages renames and reorders them here.",
family: 'phone',
},
];
/** One entry by id, or `undefined`. */
export function screenById(id) {
return screens.find((shot) => shot.id === id);
}
/** Every entry in a family, in declaration order. */
export function screensOf(family) {
return screens.filter((shot) => shot.family === family);
}

View File

@@ -2,6 +2,7 @@
import Base from '../layouts/Base.astro';
import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import Screenshot from '../components/Screenshot.astro';
import platform from '../data/platform.json';
import {
@@ -55,6 +56,26 @@ import {
assertCapabilityCoverage(platform.moduleUoCapabilities);
assertDetailCoverage();
/**
* Which screenshots sit under which group (PLAN.md §13 phase 9, D44).
*
* Deliberately here and not in `capabilities.mjs`. That file is a contract — two build-time
* assertions read it and `/modules/` and the homepage render from it — and a group is
* defined by what the software does, not by what somebody has got round to photographing.
* A group with no figure is the normal case, not an omission: Infrastructure is about where
* the container runs and who owns the database, and a picture of a web page says nothing
* true about either.
*
* `Screenshot` throws on an id that `screens.mjs` does not declare, so a typo here fails the
* build rather than rendering a broken image.
*/
const groupScreens: Record<string, string[]> = {
community: ['news'],
'game-intelligence': ['marketplace', 'spawn-atlas', 'guilds', 'houses'],
administration: ['admin-dashboard', 'admin-users'],
integration: ['admin-modules'],
};
const title = 'Features';
const description =
'What a Runic Gateway deployment does — core, and what the installed game module adds.';
@@ -110,6 +131,14 @@ const description =
</li>
))}
</ul>
{groupScreens[group.id] && (
<div class="group__shots">
{groupScreens[group.id].map((id: string) => (
<Screenshot id={id} />
))}
</div>
)}
</section>
))
}
@@ -118,6 +147,16 @@ const description =
</Base>
<style>
/* One up, at the column's full width, even where a group has four of them.
Two-up was tried first and is the obvious layout for a set of figures — but these are
screenshots of a dense interface, and halving the width puts the product's own type at
around a third of its real size, which reads as a thumbnail of something rather than a
picture of it. A long section of legible evidence beats a tidy grid of unreadable
tiles. */
.group__shots {
margin-top: 1.75rem;
}
.group__head {
display: flex;
flex-wrap: wrap;

View File

@@ -4,6 +4,7 @@ import { brand } from '../lib/brand.mjs';
import Hero from '../components/home/Hero.astro';
import DataPath from '../components/home/DataPath.astro';
import WhatItLooksLike from '../components/home/WhatItLooksLike.astro';
import SelfHosted from '../components/home/SelfHosted.astro';
import Capabilities from '../components/home/Capabilities.astro';
import GetStarted from '../components/home/GetStarted.astro';
@@ -28,6 +29,7 @@ import GetStarted from '../components/home/GetStarted.astro';
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
<Hero />
<DataPath />
<WhatItLooksLike />
<SelfHosted />
<Capabilities />
<GetStarted />