feat(screens): phase 9 — real screenshots, from a real shard #13
@@ -78,6 +78,17 @@ jobs:
|
|||||||
# No token, no network, no build — so it runs early and fails fast.
|
# No token, no network, no build — so it runs early and fails fast.
|
||||||
run: npm run check:sidebar
|
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
|
- name: Production build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
|||||||
136
PLAN.md
@@ -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 |
|
| 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 |
|
| Plugin overlay | **v1.0.0** (2026-08-19) | release; in bundle 2026.08.19 |
|
||||||
| Installer | **v0.1.1** (2026-08-24) | release |
|
| 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` |
|
| 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` |
|
| 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) |
|
| `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
|
**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
|
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 |
|
| # | Where | What it settled |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -247,6 +247,7 @@ somewhere other than the thing it decided. The count of record is **forty-one**:
|
|||||||
| D30–D33 | §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 |
|
| D30–D33 | §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 |
|
||||||
| D34–D37 | §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 |
|
| D34–D37 | §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 |
|
||||||
| D38–D41 | §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 |
|
| D38–D41 | §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 |
|
||||||
|
| D42–D46 | §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
|
data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig
|
||||||
forward into phase 5.
|
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
|
**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
|
`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
|
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 |
|
| `/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) |
|
| `/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 |
|
| `/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) |
|
| `/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 |
|
| `/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 |
|
| `/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
|
## 11. Visual direction
|
||||||
|
|
||||||
**"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are
|
**"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
|
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
|
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.
|
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).
|
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
|
||||||
- `astro check` plus a production build, in CI on every PR.
|
- `astro check` plus a production build, in CI on every PR.
|
||||||
|
|
||||||
@@ -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) |
|
| **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) |
|
| **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 |
|
| **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) |
|
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
|
||||||
|
|
||||||
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and
|
||||||
|
|||||||
838
package-lock.json
generated
@@ -22,11 +22,13 @@
|
|||||||
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
"check:quickstart": "node scripts/checkQuickstart.mjs",
|
||||||
"check:reference": "node scripts/checkReference.mjs",
|
"check:reference": "node scripts/checkReference.mjs",
|
||||||
"check:sidebar": "node scripts/checkSidebar.mjs",
|
"check:sidebar": "node scripts/checkSidebar.mjs",
|
||||||
|
"check:screens": "node scripts/checkScreens.mjs",
|
||||||
"play:datasafety": "node scripts/playDataSafety.mjs",
|
"play:datasafety": "node scripts/playDataSafety.mjs",
|
||||||
"beta": "node scripts/beta.mjs",
|
"beta": "node scripts/beta.mjs",
|
||||||
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
"test": "node --test test/beta.test.mjs test/legal.test.mjs",
|
||||||
"brand:assets": "node scripts/buildBrandAssets.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": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.1.4",
|
"@astrojs/node": "^11.1.4",
|
||||||
@@ -40,7 +42,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.10",
|
"@astrojs/check": "^0.9.10",
|
||||||
"opentype.js": "^2.0.0",
|
"opentype.js": "^2.0.0",
|
||||||
"yaml": "^2.8.1",
|
"puppeteer-core": "^23.11.1",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3",
|
||||||
|
"yaml": "^2.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/screens/admin-appearance.webp
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
public/screens/admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/screens/admin-modules.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/admin-shard.webp
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/screens/admin-users.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
public/screens/app-account.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
public/screens/app-drawer.webp
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/screens/app-home.webp
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/screens/app-market.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/screens/app-shard.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/screens/app-wiki.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/screens/guilds.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/screens/houses.webp
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/screens/marketplace.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
public/screens/news.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/screens/shard-status.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/screens/spawn-atlas.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
189
scripts/captureScreens.mjs
Normal 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
@@ -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
@@ -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',
|
||||||
|
);
|
||||||
81
src/components/Screenshot.astro
Normal 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>
|
||||||
@@ -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 —
|
* 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:
|
* `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: a
|
||||||
* a trusted-device and recovery-code smoke test from 2026-07-22, captured against 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
|
* 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.
|
* 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
|
* Shipping them would have broken D4 and §1 at once, so D26 reserved the slot for the phase
|
||||||
* review stack rather than placeholders, and §1, because they would show an app that no
|
* that stands up the review stack anyway. The shape was defined then and the data arrived
|
||||||
* longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills
|
* now, which is exactly what it was for: filling it was a data change.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* ---------------------------------------------------------------------------------------
|
* ---------------------------------------------------------------------------------------
|
||||||
* 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
|
* The draft carried its own `shots` array, written before there was anywhere else to put
|
||||||
* seeded review stack, one per idea rather than one per screen: the shard hub with live
|
* one. There is now: `src/data/screens.mjs` holds every capture the site ships, web and
|
||||||
* data, the marketplace, a character sheet, the news list, the notification settings, and
|
* phone alike, and `scripts/checkScreens.mjs` proves each one exists at the size the markup
|
||||||
* the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a
|
* claims. A second list here would be the one nothing checks.
|
||||||
* target — it was the number that happened to exist.
|
|
||||||
*
|
*
|
||||||
* They belong in `public/`, not `brand-default/`: these are editorial content shipped with
|
* The phone captures come from an emulator pointed at the same seeded deployment the web
|
||||||
* the image, not branding an operator overrides (§7).
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
const shots = screensOf('phone');
|
||||||
* 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[] = [];
|
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -63,8 +38,8 @@ const shots: Shot[] = [];
|
|||||||
<section class="page section shots">
|
<section class="page section shots">
|
||||||
<h2>What it looks like</h2>
|
<h2>What it looks like</h2>
|
||||||
<p class="prose shots__lede">
|
<p class="prose shots__lede">
|
||||||
Captured against a real deployment with real content, not mocked up. The app takes
|
Captured against a real deployment with real content, not mocked up. The app takes its
|
||||||
its colours, type and navigation from the site it is connected to, so these show one
|
colours, type and navigation from the site it is connected to, so these show one
|
||||||
community's app rather than a neutral one.
|
community's app rather than a neutral one.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -72,10 +47,10 @@ const shots: Shot[] = [];
|
|||||||
{shots.map((shot) => (
|
{shots.map((shot) => (
|
||||||
<li class="shots__item">
|
<li class="shots__item">
|
||||||
<img
|
<img
|
||||||
src={shot.src}
|
src={`/screens/${shot.id}.webp`}
|
||||||
alt={shot.alt}
|
alt={shot.alt}
|
||||||
width={shot.width}
|
width={PHONE.width}
|
||||||
height={shot.height}
|
height={PHONE.height}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
|
|||||||
44
src/components/home/WhatItLooksLike.astro
Normal 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>
|
||||||
@@ -4,6 +4,7 @@ description: Colours, fonts and corners from the Appearance screen; logo, hero a
|
|||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
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
|
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
|
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
|
- **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.
|
from this site's public branding. Changing it here changes them.
|
||||||
|
|
||||||
|
<Screenshot id="admin-appearance" />
|
||||||
|
|
||||||
## Brand assets
|
## Brand assets
|
||||||
|
|
||||||
The same screen uploads three images, and each applies as soon as the upload finishes —
|
The same screen uploads three images, and each applies as soon as the upload finishes —
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ description: The five states a module can be in, installing and upgrading, disab
|
|||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
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/).
|
Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/).
|
||||||
This is what the screen means afterwards.
|
This is what the screen means afterwards.
|
||||||
|
|
||||||
|
<Screenshot id="admin-modules" />
|
||||||
|
|
||||||
## The five states
|
## The five states
|
||||||
|
|
||||||
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable
|
`installed → enabled → started`, with `disabled` and `startup_failed` as recoverable
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ description: The module's shard screen — connection settings, what the status
|
|||||||
---
|
---
|
||||||
|
|
||||||
import platform from '../../../../data/platform.json';
|
import platform from '../../../../data/platform.json';
|
||||||
|
import Screenshot from '../../../../components/Screenshot.astro';
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at
|
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
|
Setting it up for the first time is
|
||||||
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
[Connect a game server](/docs/getting-started/connect-a-game-server/).
|
||||||
|
|
||||||
|
<Screenshot id="admin-shard" />
|
||||||
|
|
||||||
## Connection
|
## Connection
|
||||||
|
|
||||||
Four fields, all four printed by the installer, plus the switch that turns the integration
|
Four fields, all four printed by the installer, plus the switch that turns the integration
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ description: The four roles and what each one reaches, creating accounts, and in
|
|||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
import { Aside } from '@astrojs/starlight/components';
|
||||||
|
import Screenshot from '../../../../components/Screenshot.astro';
|
||||||
|
|
||||||
## The four roles
|
## 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
|
Demoting an account takes effect at once — the open session does not keep its access until
|
||||||
it expires.
|
it expires.
|
||||||
|
|
||||||
|
<Screenshot id="admin-users" />
|
||||||
|
|
||||||
## Creating an account
|
## Creating an account
|
||||||
|
|
||||||
**Admin → Users → + Add user** creates one directly: username, password, role, and it is
|
**Admin → Users → + Add user** creates one directly: username, password, role, and it is
|
||||||
|
|||||||
@@ -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 { 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
|
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
|
**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.
|
**Web Bot Activity** screen shows and how to lift a ban.
|
||||||
</Aside>
|
</Aside>
|
||||||
|
|
||||||
|
<Screenshot id="admin-dashboard" />
|
||||||
|
|
||||||
## What is already there
|
## What is already there
|
||||||
|
|
||||||
The first boot seeds a working site rather than an empty one:
|
The first boot seeds a working site rather than an empty one:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"releases": {
|
"releases": {
|
||||||
"link": "v2.0.0",
|
"link": "v2.0.0",
|
||||||
"installer": "v0.1.1",
|
"installer": "v0.1.1",
|
||||||
"Module-uo": "v1.0.1",
|
"Module-uo": "v1.0.2",
|
||||||
"Android-app": "v0.5.0"
|
"Android-app": "v0.5.0"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
240
src/data/screens.mjs
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* screens.mjs — every screenshot the site ships, and where it came from.
|
||||||
|
*
|
||||||
|
* PLAN.md §13 phase 9, D4 / D42–D45.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------------------
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import PageHeader from '../components/PageHeader.astro';
|
import PageHeader from '../components/PageHeader.astro';
|
||||||
import NotBuilt from '../components/NotBuilt.astro';
|
import NotBuilt from '../components/NotBuilt.astro';
|
||||||
|
import Screenshot from '../components/Screenshot.astro';
|
||||||
|
|
||||||
import platform from '../data/platform.json';
|
import platform from '../data/platform.json';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,26 @@ import {
|
|||||||
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
assertCapabilityCoverage(platform.moduleUoCapabilities);
|
||||||
assertDetailCoverage();
|
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 title = 'Features';
|
||||||
const description =
|
const description =
|
||||||
'What a Runic Gateway deployment does — core, and what the installed game module adds.';
|
'What a Runic Gateway deployment does — core, and what the installed game module adds.';
|
||||||
@@ -110,6 +131,14 @@ const description =
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{groupScreens[group.id] && (
|
||||||
|
<div class="group__shots">
|
||||||
|
{groupScreens[group.id].map((id: string) => (
|
||||||
|
<Screenshot id={id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -118,6 +147,16 @@ const description =
|
|||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.group__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { brand } from '../lib/brand.mjs';
|
|||||||
|
|
||||||
import Hero from '../components/home/Hero.astro';
|
import Hero from '../components/home/Hero.astro';
|
||||||
import DataPath from '../components/home/DataPath.astro';
|
import DataPath from '../components/home/DataPath.astro';
|
||||||
|
import WhatItLooksLike from '../components/home/WhatItLooksLike.astro';
|
||||||
import SelfHosted from '../components/home/SelfHosted.astro';
|
import SelfHosted from '../components/home/SelfHosted.astro';
|
||||||
import Capabilities from '../components/home/Capabilities.astro';
|
import Capabilities from '../components/home/Capabilities.astro';
|
||||||
import GetStarted from '../components/home/GetStarted.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>
|
<Base title={`${brand.siteName} — ${brand.tagline}`} description={brand.tagline} bareTitle>
|
||||||
<Hero />
|
<Hero />
|
||||||
<DataPath />
|
<DataPath />
|
||||||
|
<WhatItLooksLike />
|
||||||
<SelfHosted />
|
<SelfHosted />
|
||||||
<Capabilities />
|
<Capabilities />
|
||||||
<GetStarted />
|
<GetStarted />
|
||||||
|
|||||||