diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index bce64bd..1e547dd 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -78,6 +78,17 @@ jobs: # No token, no network, no build — so it runs early and fails fast. run: npm run check:sidebar + - name: Screenshots + # PLAN.md §12, phase 9 (D45). src/data/screens.mjs is the one list of what the site + # shows of itself: every entry must have a file, at the size the markup declares, and + # every file must have an entry. The size half is the one that repays the check — + # a re-capture taken at the wrong viewport looks perfectly fine on its own and only + # reveals itself as a page that reflows while it decodes. + # + # No browser and no game server: the capture tool is an authoring script whose output + # is committed, exactly like the brand assets, so CI only reads what it produced. + run: npm run check:screens + - name: Production build run: npm run build diff --git a/PLAN.md b/PLAN.md index f76daca..80e01da 100644 --- a/PLAN.md +++ b/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 | | Plugin overlay | **v1.0.0** (2026-08-19) | release; in bundle 2026.08.19 | | Installer | **v0.1.1** (2026-08-24) | release | -| `module-uo` | **v1.0.1** (2026-08-19) | release | +| `module-uo` | **v1.0.2** (2026-08-25) | release | | Android app | **v0.5.0** (2026-08-08), id `com.runicgateway.app` | release; `app/build.gradle.kts` | | ServUO | **57.4** — min version, and the only version the patch tier is verified against | bundle `overlay.servuo` | | `website` | **no releases** — ships as container images, never tagged | Gitea releases API (empty) | @@ -236,7 +236,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r **Decisions after D13 are recorded where they were taken**, in the section describing the phase that raised them, rather than appended here — a decision is only re-litigated when its reasoning is -somewhere other than the thing it decided. The count of record is **forty-one**: +somewhere other than the thing it decided. The count of record is **forty-six**: | # | Where | What it settled | |---|---|---| @@ -247,6 +247,7 @@ somewhere other than the thing it decided. The count of record is **forty-one**: | 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 | | 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 forward into phase 5. +*Filled in phase 9:* six captures, from an emulator pointed at the same seeded deployment the web +screenshots came from, on the same day — see "How phase 9 took the screenshots" in §10. The +component now reads `src/data/screens.mjs` rather than a list of its own, which is what made it a +data change in the end. + **D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on `Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has been entered and validated — so an installed app with no deployment behind it is a text field. The @@ -670,7 +676,7 @@ Organised by what a reader is trying to do. A reader should never need to know t | `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it | | `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) | | `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list | -| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and a screenshot slot **phase 9 fills** (D26 — the 14 existing screenshots are the wrong fourteen) | +| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and six phone captures **phase 9 filled** (D26 — the 14 existing screenshots were the wrong fourteen) | | `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) | | `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 | | `/privacy/`, `/terms/` | §9 | @@ -1012,6 +1018,121 @@ security boundary, and a set comparison alone would have passed it. --- +### How phase 9 took the screenshots + +D4 said real screenshots from the review stack rather than placeholders, and left the how +open. Five decisions settled it, taken by the org lead before the rig was built. + +**D42 — the full rig: a real shard, a real sidecar, a real site.** ServUO with the bridge +overlay on this machine, the Rust sidecar beside it, `website` `main` with `module-uo` +installed, and the demo database seeded on top for what a fresh shard cannot produce. + +The alternatives were cheaper and both of them lie a little. Sidecar-only screenshots the +degraded state — a reachable bridge with nothing behind it. Everything-database-seeded +produces pages that look identical to the real thing and were produced by nothing: the +marketplace would be rows somebody typed. This is the one option where the marketplace rows +are player vendors the game actually holds, the atlas is parsed from the shard's own spawn +files, and "Candlewick House is now IDOC" happened. + +**D43 — a neutral demo brand.** The deployment is "Runic Gateway Demo", not UOMysticmoon. +The screenshots show the platform rather than one private community, which is the same +instinct as D27's refusal to publicise a real shard — and §15's demo instance can wear this +identity the day it exists, so the imagery stays true rather than becoming a period piece. +The name says "Demo" deliberately: nobody should have to wonder whether they are looking at +a server they could join. + +**D44 — the captures sit beside the claims they support, in two places.** A figure set on +`/features/`, one on the homepage, and inline shots on the phase-7 administration pages that +describe a screen in prose. Eleven web captures. + +The administration pages are where a screenshot does the most work, because phase 7 +described thirteen screens it could not show. A dedicated `/screenshots/` gallery was +rejected for the reason galleries usually are: a page nobody visits does less than a figure +sitting under the sentence it proves. + +**D45 — the rig is committed, not remembered.** Three files rather than a folder of images: +`scripts/seedDemo.mjs` puts the content there by driving the site's own API, +`src/data/screens.mjs` declares every capture with its route, viewport, scroll offset and +caption, and `scripts/captureScreens.mjs` turns the second into files. +`scripts/checkScreens.mjs` is the ninth check script and runs in CI. + +The argument is the same one D35 made for the install quickstart: the way real screenshots +rot is that the recipe for taking them lives in somebody's memory. Re-taking the set after a +redesign is now `npm run screens:capture`, and the check fails if an entry has no file, a +file is the wrong size, a file is orphaned, or a declared screen is rendered nowhere. + +**Why the seed drives the API and never the database.** Every row it creates could have been +an `INSERT`, and every `INSERT` would be a second implementation of a rule the website owns — +how a body is sanitized, which excerpt is derived, how a password is hashed. A seed that +writes SQL produces a database the product could not have produced, and screenshots of that +database show a product that does not exist. + +**D46 — the world gets dressed in `servuo-plugins/tools`.** `BridgeSeeder` builds a world at +realistic scale; it never needed the world to look like anything, so a vendor traded as +"Seed Shop 810" and a character was "Seed004A" — and every one of those strings travels the +whole bridge and lands on the marketplace, the guild roster and the housing pages. +`BridgeDemoDress` renames them in place and seeds nothing, drawing names from fixed tables +hashed off each object's serial, so a re-run reproduces the same world and a screenshot can +be retaken later and still match. + +**What the rig found.** A screenshot rig is an integration test with a human in the loop, and +this one turned up five things nothing else had: + +- **A fresh `module-uo` install pinned wire protocol 3 while the sidecar speaks 4**, so a new + deployment 409s on every shard read until an admin edits the number by hand. The protocol-4 + cutover bumped `link`, `servuo-plugins` and `docs` and missed the module's own default. + Fixed upstream and released as `module-uo` **v1.0.2** — which is what this repository's own + facts check then noticed, since `platform.json` still said v1.0.1. +- **A renamed guild member never reaches the site.** The plugin folds name, abbreviation, + leader, member count and alliance into the signature it compares, and re-emits the roster + only when the member *set* changes — so renaming a member leaves the published roster stale + indefinitely. +- **A guild deleted while the shard is offline is a ghost row forever.** The "gone" pass + compares against a cache that is cleared on reconnect, so nothing emits `guild.remove`. The + demo's guild board was showing two guilds the world no longer had, a week after they went. +- **"Houses in danger" cannot show a house that was already collapsing.** The ingest writes + that column only from the `house.decay` transition feed, while the registry frame's stage is + deliberately left alone so the two cannot clobber each other. A house already in IDOC when + the site connects is therefore invisible — the page said none while the shard had two. +- **The Android news list prints raw ISO timestamps.** Found while choosing the phone + captures; the news screen was dropped from that set rather than shipping a picture of it. + +The first is fixed. The rest are raised as product observations, with the rig working around +them: the guilds are built *after* the rename, and the IDOC staging is two passes with a wait +between them so the site watches the collapse happen. All of that is scaffolding under +`servuo-plugins/tools/`, which is never deployed. + +**The emulator pass (D26), and the AVD that would not take it.** The six phone captures come +from an emulator pointed at the same deployment on the same day, signed in as an ordinary +player, reached through `adb reverse` — the app's debug network policy permits cleartext to +`localhost` only, which is a better default than the one that would have made `10.0.2.2` +work. The device is API 35 rather than the API 36 the plan named: the API 36 image on this +machine had 200 MB free and refused the install, and wiping somebody's development device to +take a screenshot is not a trade worth making. + +The shard screen is the one worth having. It shows the two houses entering IDOC in its live +activity feed — the same event that reached `/uo/houses` in the browser, on the same rig, in +the same minute. + +**One thing that did not happen: a character logged in.** The org lead asked for it, so the +scaffolding sets a known password on a seeded account and a client was driven as far as its +login screen. ClassicUO stores its password crypted and mangles a plaintext one from +`settings.json`, so auto-login fails; synthetic clicks reach the client but synthetic text +does not; and the remaining route — taking the foreground and typing — was tried once, missed +the client, and typed into the browser window the person at this machine was using. It was +not tried again. **Players online therefore reads 0, and the page is honest about a quiet +realm.** The client is left at its login screen: one manual sign-in and +`npm run screens:capture shard-status app-shard` retakes the two frames that would change. + +**A layout decision worth recording.** The `/features/` figures are one-up at the column's +full width, not a two-column grid. Two-up was built first and is the obvious layout for a set +of figures — but these are screenshots of a dense interface, and halving the width puts the +product's own type at about a third of its real size, which reads as a thumbnail of something +rather than a picture of it. A long section of legible evidence beats a tidy grid of +unreadable tiles. + +--- + ## 11. Visual direction **"Modern infrastructure software with an arcane identity."** Dark-first. Marketing pages are @@ -1110,6 +1231,13 @@ a mechanism rather than diligence: disagreement. Two-directional, like `PLANNED_ROUTES`: a value that drifts fails, **and** a service or variable that appears upstream fails until it is either included or recorded as deliberately omitted with a reason. Its own first run found two stale entries. +- **`scripts/checkScreens.mjs`** — added in phase 9 for D45. `src/data/screens.mjs` is the one + list of what the site shows of itself, and this proves every entry has a file at the size the + markup declares, that nothing in `public/screens/` is orphaned, and that every declared + capture is rendered somewhere. The size half is the one that repays it: a re-capture taken at + the wrong viewport looks perfectly fine on its own and only reveals itself as a page that + reflows while it decodes. No browser and no game server — the capture tool is an authoring + script whose output is committed, exactly like the brand assets. - **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7). - `astro check` plus a production build, in CI on every PR. @@ -1130,7 +1258,7 @@ a mechanism rather than diligence: | **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) — twenty pages in one PR (D38), with Reference enumerating names and **checking every one of them** against its source (D39), and `plannedSidebar` becoming a checked invariant (D41) | | **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) | | **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers | -| **11** | Validation: `astro check`, production build, **all eight check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar), mobile layout verified in a real browser, a signup walked end to end | +| **11** | Validation: `astro check`, production build, **all nine check scripts** (tokens, brand, links, facts, quickstart, data safety, reference, sidebar, screens), mobile layout verified in a real browser, a signup walked end to end | | **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) | Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and diff --git a/package-lock.json b/package-lock.json index 8ba171e..9533e8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "opentype.js": "^2.0.0", + "puppeteer-core": "^23.11.1", "typescript": "^6.0.3", "yaml": "^2.8.1" }, @@ -2254,6 +2255,164 @@ "win32" ] }, + "node_modules/@puppeteer/browsers": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.0", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@puppeteer/browsers/node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@puppeteer/browsers/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", @@ -2618,6 +2777,13 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2721,6 +2887,17 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -2853,6 +3030,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -2991,6 +3178,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -3115,6 +3315,21 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3125,6 +3340,91 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3145,6 +3445,16 @@ ], "license": "MIT" }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bcp-47": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz", @@ -3234,6 +3544,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3321,6 +3641,30 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/chromium-bidi": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", + "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.1", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3564,6 +3908,16 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3624,6 +3978,21 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3676,6 +4045,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1367902", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", + "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -3941,6 +4317,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-util-attach-comments": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", @@ -4032,6 +4465,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4047,6 +4490,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -4074,6 +4527,27 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4081,6 +4555,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -4122,6 +4603,16 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4241,6 +4732,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "5.0.0-beta.4", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", @@ -4256,6 +4763,21 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -4711,6 +5233,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18next": { "version": "26.3.6", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", @@ -4777,6 +5327,16 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -4835,6 +5395,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -6357,6 +6927,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -6418,6 +6995,16 @@ "node": ">= 10" } }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nlcst-to-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", @@ -6597,6 +7184,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-manager-detector": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", @@ -6683,6 +7304,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -6834,6 +7462,16 @@ "node": ">=18.0.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -6844,6 +7482,43 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -6854,6 +7529,24 @@ "once": "^1.3.1" } }, + "node_modules/puppeteer-core": { + "version": "23.11.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", + "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.11.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -7223,6 +7916,16 @@ "dev": true, "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7576,6 +8279,17 @@ "npm": ">=10.8.2" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/smol-toml": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", @@ -7588,6 +8302,36 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -7631,6 +8375,18 @@ "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", "license": "MIT" }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7788,6 +8544,33 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -7861,8 +8644,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "devOptional": true, + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -7876,6 +8659,13 @@ "node": "*" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/typesafe-path": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", @@ -7919,6 +8709,17 @@ "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", "license": "MIT" }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/uncrypto": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", @@ -8706,6 +9507,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", @@ -8812,6 +9635,17 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", diff --git a/package.json b/package.json index 0d46eae..21bf522 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "check:quickstart": "node scripts/checkQuickstart.mjs", "check:reference": "node scripts/checkReference.mjs", "check:sidebar": "node scripts/checkSidebar.mjs", + "check:screens": "node scripts/checkScreens.mjs", "play:datasafety": "node scripts/playDataSafety.mjs", "beta": "node scripts/beta.mjs", "test": "node --test test/beta.test.mjs test/legal.test.mjs", "brand:assets": "node scripts/buildBrandAssets.mjs", - "verify": "npm run check:sidebar && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference" + "screens:capture": "node scripts/captureScreens.mjs", + "verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference" }, "dependencies": { "@astrojs/node": "^11.1.4", @@ -40,7 +42,8 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "opentype.js": "^2.0.0", - "yaml": "^2.8.1", - "typescript": "^6.0.3" + "puppeteer-core": "^23.11.1", + "typescript": "^6.0.3", + "yaml": "^2.8.1" } } diff --git a/public/screens/admin-appearance.webp b/public/screens/admin-appearance.webp new file mode 100644 index 0000000..5b5d7ba Binary files /dev/null and b/public/screens/admin-appearance.webp differ diff --git a/public/screens/admin-dashboard.webp b/public/screens/admin-dashboard.webp new file mode 100644 index 0000000..5db7f52 Binary files /dev/null and b/public/screens/admin-dashboard.webp differ diff --git a/public/screens/admin-modules.webp b/public/screens/admin-modules.webp new file mode 100644 index 0000000..a81ed3f Binary files /dev/null and b/public/screens/admin-modules.webp differ diff --git a/public/screens/admin-shard.webp b/public/screens/admin-shard.webp new file mode 100644 index 0000000..9c3ee3d Binary files /dev/null and b/public/screens/admin-shard.webp differ diff --git a/public/screens/admin-users.webp b/public/screens/admin-users.webp new file mode 100644 index 0000000..8c259c8 Binary files /dev/null and b/public/screens/admin-users.webp differ diff --git a/public/screens/app-account.webp b/public/screens/app-account.webp new file mode 100644 index 0000000..4b5f028 Binary files /dev/null and b/public/screens/app-account.webp differ diff --git a/public/screens/app-drawer.webp b/public/screens/app-drawer.webp new file mode 100644 index 0000000..7800ddb Binary files /dev/null and b/public/screens/app-drawer.webp differ diff --git a/public/screens/app-home.webp b/public/screens/app-home.webp new file mode 100644 index 0000000..ab08798 Binary files /dev/null and b/public/screens/app-home.webp differ diff --git a/public/screens/app-market.webp b/public/screens/app-market.webp new file mode 100644 index 0000000..dfa5450 Binary files /dev/null and b/public/screens/app-market.webp differ diff --git a/public/screens/app-shard.webp b/public/screens/app-shard.webp new file mode 100644 index 0000000..75d01cf Binary files /dev/null and b/public/screens/app-shard.webp differ diff --git a/public/screens/app-wiki.webp b/public/screens/app-wiki.webp new file mode 100644 index 0000000..8101948 Binary files /dev/null and b/public/screens/app-wiki.webp differ diff --git a/public/screens/guilds.webp b/public/screens/guilds.webp new file mode 100644 index 0000000..3e07ec7 Binary files /dev/null and b/public/screens/guilds.webp differ diff --git a/public/screens/houses.webp b/public/screens/houses.webp new file mode 100644 index 0000000..07b0800 Binary files /dev/null and b/public/screens/houses.webp differ diff --git a/public/screens/marketplace.webp b/public/screens/marketplace.webp new file mode 100644 index 0000000..eb8212f Binary files /dev/null and b/public/screens/marketplace.webp differ diff --git a/public/screens/news.webp b/public/screens/news.webp new file mode 100644 index 0000000..b8efbc9 Binary files /dev/null and b/public/screens/news.webp differ diff --git a/public/screens/shard-status.webp b/public/screens/shard-status.webp new file mode 100644 index 0000000..7923977 Binary files /dev/null and b/public/screens/shard-status.webp differ diff --git a/public/screens/spawn-atlas.webp b/public/screens/spawn-atlas.webp new file mode 100644 index 0000000..d82b851 Binary files /dev/null and b/public/screens/spawn-atlas.webp differ diff --git a/scripts/captureScreens.mjs b/scripts/captureScreens.mjs new file mode 100644 index 0000000..63ababc --- /dev/null +++ b/scripts/captureScreens.mjs @@ -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); diff --git a/scripts/checkScreens.mjs b/scripts/checkScreens.mjs new file mode 100644 index 0000000..01c607b --- /dev/null +++ b/scripts/checkScreens.mjs @@ -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 — + * `` 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.`); diff --git a/scripts/seedDemo.mjs b/scripts/seedDemo.mjs new file mode 100644 index 0000000..c3ecd11 --- /dev/null +++ b/scripts/seedDemo.mjs @@ -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: + '

The autumn patch is live. The headline change is that every player ' + + 'vendor on the shard is now searchable from this site — the marketplace ' + + 'page reads the same live feed the game does, so a listing appears within a minute ' + + 'of being priced.

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.

Full notes are on the wiki.

', + 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: + '

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.

' + + '

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.

', + 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: + '

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.

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.

', + 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: + '

Five people who made the week better for everybody else:

  1. Sable, for ' + + 'restocking the free reagent stall three times without being asked.
  2. Tobin, ' + + 'for guiding two new players through their first dungeon.
  3. Mirena, for the ' + + 'map corrections on the wiki.
  4. Brannwen, for writing up the champion ' + + 'rotation.
  5. Aldric, for handling a difficult report quietly and well.
  6. ' + + '
', + 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: + '

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.

Next month we are looking at the champion boards and at making the ' + + 'atlas easier to read on a phone.

', + 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: + '

Before you connect

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.

The first hour

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.

', + }, + { + slug: 'player-vendors', + title: 'Player vendors', + excerpt: 'How to hire one, how to price, and how the site search finds you.', + body: + '

Hiring a vendor

Any house you own or co-own can hold vendors. Hire one ' + + 'from an innkeeper and place it inside.

Being findable

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.

', + }, + { + slug: 'housing-and-decay', + title: 'Housing and decay', + excerpt: 'Placement rules, the decay timer, and what IDOC actually means here.', + body: + '

Placement

Houses can be placed anywhere the client allows, with the ' + + 'usual clearance rules. There is no lottery.

Decay

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.

', + }, + { + slug: 'community-rules', + title: 'Community rules', + excerpt: 'The short version: do not be the reason somebody stops playing.', + body: + '

The rules

  1. No harassment, in game or on the site.
  2. No ' + + 'exploiting bugs — report them instead, and you will usually be thanked in ' + + 'public.
  3. One account per person for events with prizes.
' + + '

Appeals

Every moderation action can be appealed from your account page. ' + + 'An appeal is read by somebody who was not involved in the original action.

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

What it looks like

- Captured against a real deployment with real content, not mocked up. The app takes - its colours, type and navigation from the site it is connected to, so these show one + Captured against a real deployment with real content, not mocked up. The app takes its + colours, type and navigation from the site it is connected to, so these show one community's app rather than a neutral one.

@@ -72,10 +47,10 @@ const shots: Shot[] = []; {shots.map((shot) => (
  • {shot.alt} diff --git a/src/components/home/WhatItLooksLike.astro b/src/components/home/WhatItLooksLike.astro new file mode 100644 index 0000000..a15169c --- /dev/null +++ b/src/components/home/WhatItLooksLike.astro @@ -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. + */ +--- + +
    +

    What it looks like

    +

    The other end of that diagram

    +

    + 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. +

    + + +
    + + diff --git a/src/content/docs/docs/administration/branding-and-theming.mdx b/src/content/docs/docs/administration/branding-and-theming.mdx index a967bac..c2c0be0 100644 --- a/src/content/docs/docs/administration/branding-and-theming.mdx +++ b/src/content/docs/docs/administration/branding-and-theming.mdx @@ -4,6 +4,7 @@ description: Colours, fonts and corners from the Appearance screen; logo, hero a --- import { Aside } from '@astrojs/starlight/components'; +import Screenshot from '../../../../components/Screenshot.astro'; One prebuilt image runs as any community's site. Nothing about your identity is compiled in — it is a theme row in the database, three image files on a mount, and a few environment @@ -31,6 +32,8 @@ Two things the screen tells you that are easy to miss: - **The accent reaches the mobile app and the Discord bot**, both of which theme themselves from this site's public branding. Changing it here changes them. + + ## Brand assets The same screen uploads three images, and each applies as soon as the upload finishes — diff --git a/src/content/docs/docs/administration/managing-modules.mdx b/src/content/docs/docs/administration/managing-modules.mdx index 3654a34..c6da49d 100644 --- a/src/content/docs/docs/administration/managing-modules.mdx +++ b/src/content/docs/docs/administration/managing-modules.mdx @@ -4,10 +4,13 @@ description: The five states a module can be in, installing and upgrading, disab --- import { Aside } from '@astrojs/starlight/components'; +import Screenshot from '../../../../components/Screenshot.astro'; Installing your first module is [Getting started](/docs/getting-started/install-a-game-module/). This is what the screen means afterwards. + + ## The five states `installed → enabled → started`, with `disabled` and `startup_failed` as recoverable diff --git a/src/content/docs/docs/administration/the-shard-connection.mdx b/src/content/docs/docs/administration/the-shard-connection.mdx index b42b942..5a75edd 100644 --- a/src/content/docs/docs/administration/the-shard-connection.mdx +++ b/src/content/docs/docs/administration/the-shard-connection.mdx @@ -4,6 +4,7 @@ description: The module's shard screen — connection settings, what the status --- import platform from '../../../../data/platform.json'; +import Screenshot from '../../../../components/Screenshot.astro'; import { Aside } from '@astrojs/starlight/components'; With the `uo` module installed, **Shard (uo-link)** appears in the admin sidebar at @@ -13,6 +14,8 @@ the controls that ride on it. Setting it up for the first time is [Connect a game server](/docs/getting-started/connect-a-game-server/). + + ## Connection Four fields, all four printed by the installer, plus the switch that turns the integration diff --git a/src/content/docs/docs/administration/users-and-roles.mdx b/src/content/docs/docs/administration/users-and-roles.mdx index cc44efd..51a0111 100644 --- a/src/content/docs/docs/administration/users-and-roles.mdx +++ b/src/content/docs/docs/administration/users-and-roles.mdx @@ -4,6 +4,7 @@ description: The four roles and what each one reaches, creating accounts, and in --- import { Aside } from '@astrojs/starlight/components'; +import Screenshot from '../../../../components/Screenshot.astro'; ## The four roles @@ -24,6 +25,8 @@ Admin routes are re-validated against the database on **every request**, not jus Demoting an account takes effect at once — the open session does not keep its access until it expires. + + ## Creating an account **Admin → Users → + Add user** creates one directly: username, password, role, and it is diff --git a/src/content/docs/docs/getting-started/first-run.mdx b/src/content/docs/docs/getting-started/first-run.mdx index 54cc450..e5e71f7 100644 --- a/src/content/docs/docs/getting-started/first-run.mdx +++ b/src/content/docs/docs/getting-started/first-run.mdx @@ -4,6 +4,7 @@ description: Signing in as the first admin, what the site does before anyone vis --- import { Aside, Steps } from '@astrojs/starlight/components'; +import Screenshot from '../../../../components/Screenshot.astro'; The site is up and nobody can see it yet. That is the intended state: a new deployment **starts in maintenance mode**, showing visitors a "coming soon" page while the admin panel @@ -33,6 +34,8 @@ minute, and see [Authentication](/docs/administration/authentication/) for what **Web Bot Activity** screen shows and how to lift a ban. + + ## What is already there The first boot seeds a working site rather than an empty one: diff --git a/src/data/platform.json b/src/data/platform.json index 9948f9d..3da8d3e 100644 --- a/src/data/platform.json +++ b/src/data/platform.json @@ -28,7 +28,7 @@ "releases": { "link": "v2.0.0", "installer": "v0.1.1", - "Module-uo": "v1.0.1", + "Module-uo": "v1.0.2", "Android-app": "v0.5.0" }, diff --git a/src/data/screens.mjs b/src/data/screens.mjs new file mode 100644 index 0000000..6bc4957 --- /dev/null +++ b/src/data/screens.mjs @@ -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); +} diff --git a/src/pages/features.astro b/src/pages/features.astro index 09405ee..ea83d0c 100644 --- a/src/pages/features.astro +++ b/src/pages/features.astro @@ -2,6 +2,7 @@ import Base from '../layouts/Base.astro'; import PageHeader from '../components/PageHeader.astro'; import NotBuilt from '../components/NotBuilt.astro'; +import Screenshot from '../components/Screenshot.astro'; import platform from '../data/platform.json'; import { @@ -55,6 +56,26 @@ import { assertCapabilityCoverage(platform.moduleUoCapabilities); assertDetailCoverage(); +/** + * Which screenshots sit under which group (PLAN.md §13 phase 9, D44). + * + * Deliberately here and not in `capabilities.mjs`. That file is a contract — two build-time + * assertions read it and `/modules/` and the homepage render from it — and a group is + * defined by what the software does, not by what somebody has got round to photographing. + * A group with no figure is the normal case, not an omission: Infrastructure is about where + * the container runs and who owns the database, and a picture of a web page says nothing + * true about either. + * + * `Screenshot` throws on an id that `screens.mjs` does not declare, so a typo here fails the + * build rather than rendering a broken image. + */ +const groupScreens: Record = { + community: ['news'], + 'game-intelligence': ['marketplace', 'spawn-atlas', 'guilds', 'houses'], + administration: ['admin-dashboard', 'admin-users'], + integration: ['admin-modules'], +}; + const title = 'Features'; const description = 'What a Runic Gateway deployment does — core, and what the installed game module adds.'; @@ -110,6 +131,14 @@ const description =
  • ))} + + {groupScreens[group.id] && ( +
    + {groupScreens[group.id].map((id: string) => ( + + ))} +
    + )}
    )) } @@ -118,6 +147,16 @@ const description =