From 769d213c8049ba23c00ec4d5a7ef523c805e3796 Mon Sep 17 00:00:00 2001
From: wtclaude
Date: Wed, 28 Oct 2026 15:32:34 -0500
Subject: [PATCH] feat(demo): list the demo's sign-ins on /beta and link into
it per game
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The public demo (demo.runicgateway.com) is live, so the site's demo slot
can be switched on. Two decisions of record, PLAN.md §15:
- D82: /beta reads the demo's GET /api/v1/public/settings demo.logins on
the server, caches it for a minute, and lists the published sign-ins
with the next wipe. If the demo does not answer, /beta links to its
sign-in page instead. Gate two reads as in place.
- D83: a deep link into a game module's pages carries data-demo-game, and
applyBrand.mjs fills it only for games in the new mounted demoGames
list. The Rust features gain links into /rust and /rust/servers/demo.
UO's stay hidden on a Rust demo.
The public-demo not-built entry is removed, because its resolvedBy was met.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
---
DEPLOY.md | 10 +-
PLAN.md | 57 +++++++-
brand-default/brand.json | 17 ++-
package.json | 2 +-
scripts/applyBrand.mjs | 40 ++++--
scripts/checkBrand.mjs | 32 +++++
src/components/GameFeatures.astro | 9 +-
src/data/beta.mjs | 9 +-
src/data/capabilities.mjs | 10 +-
src/data/games/rust.mjs | 14 +-
src/data/games/uo.mjs | 3 +-
src/data/notBuilt.mjs | 20 +--
src/lib/demoLogins.mjs | 124 ++++++++++++++++++
src/pages/beta.astro | 153 +++++++++++++++++++---
src/pages/features.astro | 2 +-
src/pages/modules/rust.astro | 2 +
src/pages/modules/uo.astro | 1 +
test/demo.test.mjs | 208 ++++++++++++++++++++++++++++++
18 files changed, 650 insertions(+), 63 deletions(-)
create mode 100644 src/lib/demoLogins.mjs
create mode 100644 test/demo.test.mjs
diff --git a/DEPLOY.md b/DEPLOY.md
index f81b57b..13c3e1b 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -244,7 +244,7 @@ recolours the site and leaves every logo alone. `PLAN.md` §7 is the full accoun
```
brand/
brand.json Site name, tagline, contact address, Discord invite, Gitea org,
- demo URL, Play opt-in URL
+ demo URL and the games it runs, Play opt-in URL
logo.png ONE raster. Every size the site asks for — header at three pixel
ratios, install icons, apple-touch, favicons, a real .ico — is
derived from it on demand
@@ -264,6 +264,14 @@ mounted site name has to reach forty-nine pages that were rendered before the fi
day the Play closed test opens, pasting the URL into `brand/brand.json` puts a working link on the
confirmation screen on the very next request.
+**The public demo** is two fields. `demoUrl` shows the homepage's "See it running" button and the
+core features' links into it; `demoGames` lists the game modules it runs (`["rust"]` for
+`demo.runicgateway.com`), and only those games' links show. Both need the restart. `/beta` also
+reads `demoUrl` live: it fetches the demo's published sign-ins from
+`/api/v1/public/settings` and lists them, so the container must be able to reach the
+demo's public address. If it cannot, `/beta` links to the demo's sign-in page instead and logs
+`[beta] could not read the demo's sign-ins`.
+
To see which source answered a given asset:
```bash
diff --git a/PLAN.md b/PLAN.md
index cd3d769..2eaa9b9 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -232,12 +232,12 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
| **D9** | **No analytics.** No tracking scripts, no third-party requests, no cookie banner. | Reverse-proxy access logs are the only traffic data. |
| **D10** | **Support routes to Discord and Gitea issues.** Invite: `https://discord.gg/t2Jav8yT4g`. | §10. Discord is the front door; Gitea takes issues from anyone with an account, which today is nobody outside the org — §14, N3. |
| **D11** | **Keep the existing emblem.** `runic-emblem.png` is the mark on both the website and the Android launcher icon; the site adopts it rather than drawing a new one. | §11. Revision 1's "draw a new geometric mark" recommendation is withdrawn. |
-| **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. |
+| **D12** | **A public demo instance is planned but out of scope today** — a Proxmox VM running the full stack including ServUO, with restricted settings and an hourly automatic reset. | §15. The IA reserves the slot so it lands later without a restructure. **Built 2026-10-27** as `demo.runicgateway.com`, on Rust with a two-hour reset; the site reaches it through D82 and D83 (§15). |
| **D13** | **Publish the existing address.** No mailbox has been created at the domain and the org lead elected not to wait for one: `whitlocktech@gmail.com` is the published contact wherever the site needs one — `/privacy`, `/terms`, `/community`, the Play listing — and `docs/SECURITY.md` keeps the address it already carries. | Taken 2026-08-19, superseding §14 N2 as a blocker. The address lives **only** in `brand.json` (§7), never in prose, so moving to `privacy@`/`security@` later is a file edit and a restart. Phase 0's PR 0.10 is withdrawn, not deferred. |
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is
-somewhere other than the thing it decided. The count of record is **seventy-nine**:
+somewhere other than the thing it decided. The count of record is **eighty-three**:
| # | Where | What it settled |
|---|---|---|
@@ -261,6 +261,8 @@ somewhere other than the thing it decided. The count of record is **seventy-nine
| D70–D71 | §16, "How phase 15 built it" | The Rust group is fifteen pages, one per admin screen where it can be; the Rust event kinds are checked against the module's own classification |
| D72–D74 | §16, "How phase 16 built it" | The RunicNPC subgroup is eight pages with a shared profile-field reference; the API check compares signatures and every data shape's field names; the site's API page carries the current API only, with no decision numbers and no version history |
| D75–D79 | §16, "How phase 17 built it" | Core marketing pages describe what a game module adds in core's words and link to each game's page; the homepage keeps its game screenshot, labelled; `/app/` describes the Rust screens, released the same day; `/runicnpc/` is in the header; `checkTerms.mjs` gains a pass over the built pages |
+| D80–D81 | §16, "How phase 18 built it" | A name in a screenshot is a role; the Rust captures' players are mock data |
+| D82–D83 | §15, "How the site reaches it" | `/beta` lists the demo's sign-ins as the demo publishes them; a demo deep link names its game and shows only for the games the mounted `demoGames` lists |
---
@@ -1807,9 +1809,56 @@ whether or not N3 is actioned; only one sentence changes.
---
-## 15. Planned, not in scope: the demo instance
+## 15. The demo instance
-D12. Recorded now so the IA reserves room for it and nothing has to be restructured later.
+D12. Recorded on 2026-08-19 so the IA reserved room for it and nothing had to be restructured
+later. **It exists now:** `https://demo.runicgateway.com`, built in `website` and live since
+2026-10-27. Its design of record is `docs/website/DEMO_MODE.md` (DM1-DM29), not this section, and
+it came out different from the plan below in three ways that matter to this site:
+
+- **It runs Rust, not Ultima Online.** The site is the normal `website` image with the Rust module,
+ linked to the org's Oxide test server. So the `/uo/…` deep links D25 wrote have nothing to open
+ there.
+- **It resets every two hours, not hourly, and not by reverting a VM.** A scheduled workflow
+ restores the game server from a baseline, recreates the site's database, and seeds it again
+ (`DEMO_MODE.md` §7). The banner gives the next reset in UTC.
+- **Its sign-ins are generated on its host.** The passwords exist only in the demo's own `.env`
+ and in what the demo publishes: with `DEMO_MODE` on, `GET /api/v1/public/settings` answers
+ `demo.logins`, which is what its sign-in page lists.
+
+### How the site reaches it (2026-10-28)
+
+`DEMO_MODE.md` §10 step 6 says "runicgateway.com `/beta` publishes the logins". The org lead took
+two decisions to do that:
+
+**D82 — `/beta` lists the demo's sign-ins as the demo publishes them.** With `demoUrl` mounted,
+`/beta` (already rendered per request) reads the demo's `GET /api/v1/public/settings` on the
+server, caches the answer for a minute, and lists each `demo.logins` row with the next wipe
+time. A copy pasted into `brand.json` was the alternative, and it is one regenerated `.env` away
+from publishing passwords that do not work. The read is this server's, not the visitor's, so D9
+and the CSP are unchanged. It never fails the page: the demo is down for a few minutes at every
+wipe, and then (or after 1.5 seconds without an answer) `/beta` links to the demo's own sign-in
+page instead, and asks again 15 seconds later. Every field is shape- and length-checked before
+it is rendered. Gate two ("somewhere to point it") reads as in place, with the demo's host name
+to type into the app's first screen. `lib/demoLogins.mjs` holds it; `test/demo.test.mjs` tests it.
+
+**D83 — a demo deep link names its game, and shows only for the games the demo runs.** A link
+into a game module's pages carries `data-demo-game` after `data-demo-path`. A new mounted list,
+`demoGames` (stock `[]`), names the modules the demo runs, and `applyBrand.mjs` fills a game's
+links only for a game listed there. Core's links (`/wiki`, `/site/events`, `/site/news`) carry no
+game and follow `demoUrl` alone. The Rust features gain links into the demo's server page
+(`/rust`, and `/rust/servers/demo` with its `?tab=` for the feed, leaderboard, online, map,
+wipes and clans). `demo` is the server's id, fixed by the demo's seed fixture. UO's links stay
+in the markup and stay hidden, so a UO demo later is one more entry in the list. `checkBrand.mjs`
+holds `demoGames` to the games in `games/index.mjs` and checks that `DEEP_LINK` reads the game.
+
+The `public-demo` entry in `notBuilt.mjs` is gone, because its `resolvedBy` was met.
+
+**What the operator mounts:** `"demoUrl": "https://demo.runicgateway.com"` and
+`"demoGames": ["rust"]` in the site's `brand.json`, then a restart. `/beta` follows `demoUrl` on
+the next request; the prerendered pages follow both at the restart.
+
+### The plan as written (2026-08-19)
**Shape:** a VM on the org lead's Proxmox host running the full stack — website, `module-uo`, the
uo-link sidecar and a real ServUO shard — so an evaluator clicks one link and sees the actual
diff --git a/brand-default/brand.json b/brand-default/brand.json
index a44f89e..303a78e 100644
--- a/brand-default/brand.json
+++ b/brand-default/brand.json
@@ -23,13 +23,22 @@
"giteaOrg": "https://gitea.whitlocktech.com/RunicGateway",
"$comment_demo": [
- "§15 / D12. A public demo instance is planned and out of scope today. The homepage's",
- "'See it running' slot and /features/'s per-capability affordance render only when this",
- "is a non-empty URL, so the site gains a working demo by way of one line in a mounted",
- "file — no rebuild, consistent with §7."
+ "§15 / D12. The public demo's address, e.g. https://demo.runicgateway.com. The",
+ "homepage's 'See it running' slot and /features/'s per-capability affordance render only",
+ "when this is a non-empty URL, so the site gains a working demo by way of one line in a",
+ "mounted file — no rebuild, consistent with §7. /beta also reads the demo's published",
+ "sign-ins from it, on the next request (D82)."
],
"demoUrl": "",
+ "$comment_demoGames": [
+ "D83. The game modules the demo at demoUrl runs, by module id (\"rust\", \"uo\"). A",
+ "'See it running' link into a game's own pages shows only for a game listed here, so",
+ "a Rust demo never links to a /uo/ page it does not have. Core's links (the wiki, the",
+ "event calendar, news) follow demoUrl alone. Read at the next restart, like demoUrl."
+ ],
+ "demoGames": [],
+
"$comment_beta": [
"PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track",
"exists, and /beta renders a waiting state rather than a broken link while it is.",
diff --git a/package.json b/package.json
index de797a0..23150d8 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"check:csp": "node scripts/checkCsp.mjs",
"play:datasafety": "node scripts/playDataSafety.mjs",
"beta": "node scripts/beta.mjs",
- "test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs",
+ "test": "node --test test/beta.test.mjs test/legal.test.mjs test/footer.test.mjs test/demo.test.mjs",
"brand:assets": "node scripts/buildBrandAssets.mjs",
"screens:capture": "node scripts/captureScreens.mjs",
"csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs",
diff --git a/scripts/applyBrand.mjs b/scripts/applyBrand.mjs
index e5a7a82..ac2b5f9 100644
--- a/scripts/applyBrand.mjs
+++ b/scripts/applyBrand.mjs
@@ -193,17 +193,38 @@ if (demoFrom !== demoTo) {
* because `global.css` hides `[data-demo-url='']` — the visibility rule stays one rule for
* both kinds of link, and only the `href` differs.
*/
-const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"/g;
+const DEEP_LINK = /href="[^"]*" data-demo-url="[^"]*" data-demo-path="([^"]*)"(?: data-demo-game="([^"]*)")?/g;
-const deepLinkTo = (demoPath) => {
- const href = demoTo ? `${demoTo.replace(/\/+$/, '')}${demoPath}` : '';
+/**
+ * Which games the demo runs (D83). A deep link into a game module's pages carries that
+ * game's id in `data-demo-game`, and it is filled only when the mounted `demoGames` names
+ * it: the demo is a Rust deployment, and a `/uo/market` link into it is a 404. Core's own
+ * links (`/wiki`, `/site/events`) carry no game and follow `demoUrl` alone, because every
+ * deployment has them.
+ *
+ * An absent or malformed list means no game's links, not every game's. A demo whose games
+ * nobody named is one whose game links cannot be known to work.
+ */
+const gamesOf = (value) => (Array.isArray(value) ? value.filter((id) => typeof id === 'string') : []);
+const gamesFrom = gamesOf(previous.demoGames);
+const gamesTo = gamesOf(resolved.demoGames);
+const gamesChanged = gamesFrom.join(',') !== gamesTo.join(',');
+
+if ('demoGames' in mounted && !Array.isArray(mounted.demoGames)) {
+ console.warn('[brand] the mounted "demoGames" is not a list of game ids — no game links will show.');
+}
+
+const deepLinkTo = (demoPath, game) => {
+ const root = !game || gamesTo.includes(game) ? demoTo : '';
+ const href = root ? `${root.replace(/\/+$/, '')}${demoPath}` : '';
return (
- `href="${escapeHtml(href)}" data-demo-url="${escapeHtml(demoTo)}" ` +
- `data-demo-path="${demoPath}"`
+ `href="${escapeHtml(href)}" data-demo-url="${escapeHtml(root)}" ` +
+ `data-demo-path="${demoPath}"` +
+ (game === undefined ? '' : ` data-demo-game="${game}"`)
);
};
-if (!replacements.length) {
+if (!replacements.length && !gamesChanged) {
console.log('[brand] mount matches what is already applied; nothing to rewrite.');
process.exit(0);
}
@@ -303,8 +324,8 @@ for (const file of walk(CLIENT)) {
// After the literal swaps, never before: the plain-slot replacement also matches the
// first two attributes of a deep link, so it runs first and this pass corrects the
// `href` it just wrote. Recomputing rather than replacing is what makes that safe.
- after = after.replace(DEEP_LINK, (whole, demoPath) => {
- const rebuilt = deepLinkTo(demoPath);
+ after = after.replace(DEEP_LINK, (whole, demoPath, game) => {
+ const rebuilt = deepLinkTo(demoPath, game);
if (rebuilt !== whole) counts.set('demoDeep', counts.get('demoDeep') + 1);
return rebuilt;
});
@@ -330,6 +351,9 @@ if (counts.get('demoDeep')) {
` ${'demoUrl deep'.padEnd(14)} ${demoTo ? `linked -> ${demoTo}/…` : 'links hidden'} (${counts.get('demoDeep')}x)`
);
}
+if (gamesChanged) {
+ console.log(` ${'demoGames'.padEnd(14)} ${gamesTo.length ? gamesTo.join(', ') : 'no game links'}`);
+}
if (inlineCollisions.length) {
console.error(
diff --git a/scripts/checkBrand.mjs b/scripts/checkBrand.mjs
index 726dfad..b53133e 100644
--- a/scripts/checkBrand.mjs
+++ b/scripts/checkBrand.mjs
@@ -32,6 +32,7 @@ import path from 'node:path';
import sharp from 'sharp';
import { classify } from '../src/lib/brandAssets.mjs';
+import { games } from '../src/data/games/index.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DEFAULTS = path.join(ROOT, 'brand-default');
@@ -157,6 +158,20 @@ if (brand) {
}
}
+ // D83: the one list-valued field. Each entry is a game module's id, checked against the
+ // games this site describes, so a typo is a failed build rather than a demo whose game
+ // links silently never appear.
+ const gameIds = games.map((game) => game.key);
+ if (!Array.isArray(brand.demoGames)) {
+ fail('brand.json is missing the list field "demoGames" (an empty list is the stock value).');
+ } else {
+ for (const id of brand.demoGames) {
+ if (!gameIds.includes(id)) {
+ fail(`brand.json's "demoGames" names ${JSON.stringify(id)}, which is not one of ${gameIds.join(', ')}.`);
+ }
+ }
+ }
+
/**
* Kept in step with `TEXT_FIELDS` in `applyBrand.mjs` by reading that file rather than by
* restating the list. A field added there and forgotten here would be unchecked; a field
@@ -348,6 +363,23 @@ if (!deepPattern) {
);
}
+ // A game's link (D83) must also be read WITH its game. A pattern that matched the first
+ // three attributes and dropped the fourth would treat a /uo/ link as a core one and fill
+ // it on every demo, which is the 404 the attribute exists to prevent.
+ const gameSample = `${sample} data-demo-game="rust"`;
+ let gameRead = null;
+ try {
+ gameRead = new RegExp(deepPattern[1]).exec(gameSample)?.[2] ?? null;
+ } catch {
+ // Reported above.
+ }
+ if (gameRead !== 'rust') {
+ fail(
+ `applyBrand.mjs's DEEP_LINK does not read the game out of \`${gameSample}\`.\n` +
+ ' Game links would follow demoUrl alone and point into modules the demo may not run.'
+ );
+ }
+
const deepStrays = [];
let deepLinks = 0;
diff --git a/src/components/GameFeatures.astro b/src/components/GameFeatures.astro
index 8503300..35d33f9 100644
--- a/src/components/GameFeatures.astro
+++ b/src/components/GameFeatures.astro
@@ -12,14 +12,19 @@ import type { Capability } from '../data/capabilities.mjs';
* A feature with a `demoPath` gets the per-capability demo link of D25. The markup is the
* exact contract `scripts/checkBrand.mjs` enforces and `applyBrand.mjs` rewrites at boot —
* see the note in `/features/` before touching it. A stock build hides every one.
+ *
+ * `game` is the module's id, written as `data-demo-game` straight after the path: these
+ * links are into that module's pages, so they show only on a demo whose mounted
+ * `demoGames` names it (D83).
*/
interface Props {
title: string;
lede?: string;
+ game: string;
items: Capability[];
}
-const { title, lede, items } = Astro.props;
+const { title, lede, game, items } = Astro.props;
---
@@ -35,7 +40,7 @@ const { title, lede, items } = Astro.props;
{item.demoPath && (
See it running
diff --git a/src/data/beta.mjs b/src/data/beta.mjs
index 493225d..54c0ded 100644
--- a/src/data/beta.mjs
+++ b/src/data/beta.mjs
@@ -32,9 +32,9 @@
* 2. SOMEWHERE TO POINT IT. `ConnectScreen.kt` on `Android-app` `main` is blunt about
* this — "nothing else in the app runs until a valid Runic Gateway site is entered and
* validated". An installed app with no deployment behind it is a text field. D27 makes
- * the public demo (§15) the tester target rather than naming a private shard, which
- * means the beta opens when the demo VM does. `notBuilt.mjs` already carries that
- * absence; this page renders the demo link from `demoUrl` when there is one.
+ * the public demo (§15) the tester target rather than naming a private shard. The demo
+ * went up on 2026-10-27; with `demoUrl` mounted, `/beta` marks this gate in place and
+ * lists the demo's sign-ins as the demo publishes them (D82, `lib/demoLogins.mjs`).
*
* Neither gate is a reason not to collect addresses now — the list is what makes the first
* batch possible on day one — but a page that hid them would be advertising a beta that
@@ -180,8 +180,7 @@ export const requirements = [
body:
'The app ships pointed at nothing. Its first screen asks for the address of a site ' +
'running this platform and validates it before anything else in the app will run — ' +
- 'so a tester needs either their own deployment or the public demo, which is the ' +
- 'second of the two things this beta is waiting on.',
+ 'so a tester needs either their own deployment or the public demo.',
},
];
diff --git a/src/data/capabilities.mjs b/src/data/capabilities.mjs
index b1c28a7..547883d 100644
--- a/src/data/capabilities.mjs
+++ b/src/data/capabilities.mjs
@@ -51,7 +51,7 @@
* ---------------------------------------------------------------------------------------
* `demoPath` — DEEP LINKS THAT ONLY EXIST WHEN A DEMO DOES
* ---------------------------------------------------------------------------------------
- * §15/D12 keeps the public demo out of scope while requiring the site to gain one by way of
+ * §15/D12 kept the public demo out of scope while requiring the site to gain one by way of
* a line in a mounted `brand.json`. `brand.json`'s own comment promised `/features/` a
* "per-capability affordance" that had never been defined; D25 defines it as a deep link
* per capability that has a stable public route, appended to the mounted `demoUrl` by
@@ -63,8 +63,10 @@
* behind a Team id no static page can know. Paths are read from the real route tables —
* core's `client/src/App.jsx` and `module-uo`'s `client/src/entry.jsx` on `main` — never
* guessed. A module's public pages are namespaced under its own id, so a route into the
- * Ultima Online module is `/uo/…`: the demo §15 plans runs that game, which is why the
- * Teams item points there and the module-supplied items in `games/` carry their own.
+ * Ultima Online module is `/uo/…`. A link into a module's pages names that game
+ * (`demoGame` here, the `game` prop of `GameFeatures` for the lists in `games/`), and it
+ * shows only when the mounted `demoGames` lists it (D83): the demo that exists runs Rust,
+ * so the Teams item's `/uo/guilds` stays hidden there and Rust's clans link stands in.
*/
import { games } from './games/index.mjs';
@@ -82,6 +84,7 @@ import { games } from './games/index.mjs';
* @property {string[]} [caps] Module capability slugs, on a game's own items (`games/`) only.
* @property {boolean} [needsModule] Core machinery a module has to populate (D24).
* @property {string} [demoPath] A stable public route, deep-linked into a demo (D25).
+ * @property {string} [demoGame] The game module whose pages `demoPath` is in, if any (D83).
*
* @typedef {object} CapabilityGroup
* @property {string} id
@@ -116,6 +119,7 @@ const community = {
label: 'Teams',
needsModule: true,
demoPath: '/uo/guilds',
+ demoGame: 'uo',
detail:
'A roster, a leader, a private forum, its own notification streams and a Discord ' +
'voice channel, all hanging off one group. Core owns every part of that machinery ' +
diff --git a/src/data/games/rust.mjs b/src/data/games/rust.mjs
index f710a45..fe63726 100644
--- a/src/data/games/rust.mjs
+++ b/src/data/games/rust.mjs
@@ -16,7 +16,12 @@
* The staff items carry no `caps`: a capability is what a client feature-detects on, and
* the admin screens are not something any client detects.
*
- * No `demoPath` anywhere: the demo instance planned in §15 runs Ultima Online.
+ * `demoPath` is a public route in Module-Rust's own `client/src/entry.jsx` (`/rust`, and
+ * `/rust/servers/:id` with its `?tab=` search parameter, which `ServerDetail.jsx` reads).
+ * The public demo runs Rust (§15), and `demo` is its server's id, fixed by
+ * `website/deploy/demo/seed.json` (`modules.rust.servers`) and recreated at every wipe.
+ * The links show only while the mounted `demoGames` lists `rust` (D83). The staff items
+ * carry none: every admin page needs a sign-in, and D25's links are to public routes.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */
@@ -26,6 +31,7 @@ export const features = [
{
label: 'Every server, live',
caps: ['rust', 'servers'],
+ demoPath: '/rust',
detail:
'Every Rust server the community runs, as it last reported itself: up or down, who is ' +
'on, which map, and when the next wipe is. One sidecar serves one server, so a ' +
@@ -35,6 +41,7 @@ export const features = [
{
label: 'The kill feed',
caps: ['killfeed'],
+ demoPath: '/rust/servers/demo',
detail:
'Kills, chat, comings and goings, RunicNPC bosses, and the world itself — cargo, ' +
'helicopters, airdrops — as they happen. Every kind passes a default-deny allowlist ' +
@@ -44,6 +51,7 @@ export const features = [
{
label: 'Leaderboards, per wipe',
caps: ['leaderboard'],
+ demoPath: '/rust/servers/demo?tab=leaderboard',
detail:
'Kills, deaths, NPC kills, structures and time played, for one wipe or all-time — with ' +
'the chat titles players have earned. The record survives the wipe: the game forgets, ' +
@@ -52,6 +60,7 @@ export const features = [
{
label: 'Who is online',
caps: ['presence'],
+ demoPath: '/rust/servers/demo?tab=online',
detail:
'Who is on right now, and who is asleep. Nothing names a player to the public by ' +
'default: how far a name reaches — staff, signed-in players, everyone — is the ' +
@@ -60,6 +69,7 @@ export const features = [
{
label: 'Wipes',
caps: ['wipes'],
+ demoPath: '/rust/servers/demo?tab=wipes',
detail:
'Every wipe the site has seen, and the next one once the server has a wipe schedule — ' +
'the monthly forced wipe or a rule of your own.',
@@ -67,6 +77,7 @@ export const features = [
{
label: 'The live map',
caps: ['map'],
+ demoPath: '/rust/servers/demo?tab=map',
detail:
'The server’s own map picture with layers on it: monuments and world events, the ' +
'zones and NPCs your events placed, players, bases as positions only, and a player’s ' +
@@ -82,6 +93,7 @@ export const features = [
},
{
label: 'Clans are Teams',
+ demoPath: '/rust/servers/demo?tab=clans',
detail:
'Each clan on a server is a Team on the site: a page, a feed for its members and ' +
'core’s Team notifications. Who may see a clan’s roster is set alongside who may see ' +
diff --git a/src/data/games/uo.mjs b/src/data/games/uo.mjs
index 117af0b..18609fc 100644
--- a/src/data/games/uo.mjs
+++ b/src/data/games/uo.mjs
@@ -17,7 +17,8 @@
* ids and are resolved against the shard's own string table). Coverage, not a bijection.
*
* `demoPath` is a stable public route on the module's own pages, deep-linked into a demo
- * when one is mounted (D25). Paths are read from `module-uo`'s `client/src/entry.jsx`.
+ * when one is mounted (D25) and the mounted `demoGames` lists `uo` (D83) — which the public
+ * demo, a Rust deployment, does not. Paths are read from `module-uo`'s `client/src/entry.jsx`.
*/
/** @typedef {import('../capabilities.mjs').Capability} Capability */
diff --git a/src/data/notBuilt.mjs b/src/data/notBuilt.mjs
index 15c4f9d..1068155 100644
--- a/src/data/notBuilt.mjs
+++ b/src/data/notBuilt.mjs
@@ -117,23 +117,9 @@ export const notBuilt = [
resolvedBy: 'A game server that runs there.',
link: { href: `${GITEA}/docs/src/branch/main/installer/INSTALL.md`, label: 'The operator guide' },
},
- {
- id: 'public-demo',
- // Phase 5 added `app` and `beta`, and that is not tidying. D27 makes the demo the
- // deployment a beta tester connects to, so on those two pages this stopped being a
- // thing the site lacks and became the thing the beta is waiting for. An absence that
- // blocks a call to action has to be on the page carrying that call to action.
- scope: ['features', 'app', 'beta'],
- title: 'A public demo you can click through',
- body:
- 'Planned and out of scope today: a virtual machine running the whole stack including ' +
- 'a game server, with settings locked down and an hourly reset. Until it exists this ' +
- 'site does not link to one, and there is no screenshot here of something that is not ' +
- 'running somewhere.',
- resolvedBy:
- 'The machine being stood up. The site is already built to gain it by way of one line ' +
- 'in a configuration file, rather than a rebuild.',
- },
+ // `public-demo` stood here until 2026-10-28. Its `resolvedBy` was met when
+ // demo.runicgateway.com went up (docs/website/DEMO_MODE.md), and the site reaches it through
+ // the mounted `demoUrl`, as the entry promised (D82, D83).
/* ---------------------------------------------------------------------------------------
THE ANDROID CLIENT (phase 5)
diff --git a/src/lib/demoLogins.mjs b/src/lib/demoLogins.mjs
new file mode 100644
index 0000000..5146f47
--- /dev/null
+++ b/src/lib/demoLogins.mjs
@@ -0,0 +1,124 @@
+/**
+ * demoLogins.mjs — the public demo's sign-ins, read from the demo itself (D82).
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHY THE PASSWORDS ARE NOT IN THIS REPOSITORY
+ * ---------------------------------------------------------------------------------------
+ * The demo's accounts are recreated at every wipe from passwords generated on the demo's own
+ * host, so the only copy that is right by definition is the one the demo serves. It already
+ * publishes them: with `DEMO_MODE` on, `GET /api/v1/public/settings` answers
+ * `demo: { resetsAt, logins: [{ username, password, role }] }`, which is what its own
+ * sign-in page lists (`docs/website/DEMO_MODE.md` §4 and §6). `/beta` reads the same answer.
+ * A copy pasted into a mounted file would be one regenerated `.env` away from a page
+ * publishing passwords that no longer work.
+ *
+ * ---------------------------------------------------------------------------------------
+ * WHAT THIS DOES NOT CHANGE
+ * ---------------------------------------------------------------------------------------
+ * The fetch is made by this server, never by the browser, so D9 and §6's CSP stand: the
+ * visitor's page loads nothing from another origin. Only `/beta` calls it, and `/beta`
+ * already renders per request.
+ *
+ * It never throws and never holds a page up for long. The demo is down for a few minutes at
+ * every wipe, and a sign-up page must not fail with it: a failed or slow read answers `null`,
+ * the page points at the demo's own sign-in page instead, and the failure is cached briefly
+ * so a down demo is not asked again on every request.
+ *
+ * Everything read is checked before it is used. The demo is ours, but it is a public admin
+ * playground, and a string from it goes into this page's markup. Astro escapes it, and the
+ * length and shape limits below keep a strange answer from becoming a strange page.
+ */
+
+const OK_TTL_MS = 60_000;
+const FAIL_TTL_MS = 15_000;
+const TIMEOUT_MS = 1_500;
+
+const MAX_LOGINS = 8;
+const MAX_FIELD = 64;
+
+let cache = { key: '', until: 0, value: null };
+let pending = null;
+
+const isShortString = (value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_FIELD;
+
+/**
+ * The demo's answer reduced to what `/beta` shows, or `null` when it is not the shape
+ * `DEMO_MODE.md` §6 describes. Exported for the tests.
+ *
+ * @param {unknown} body The parsed JSON of `GET /api/v1/public/settings`.
+ * @returns {{ logins: { username: string, password: string, role: string }[], resetsAt: string | null } | null}
+ */
+export function readDemo(body) {
+ const demo = body && typeof body === 'object' ? body.demo : null;
+ if (!demo || typeof demo !== 'object' || !Array.isArray(demo.logins)) return null;
+
+ const logins = demo.logins
+ .filter((l) => l && isShortString(l.username) && isShortString(l.password) && isShortString(l.role))
+ .slice(0, MAX_LOGINS)
+ .map(({ username, password, role }) => ({ username, password, role }));
+ if (!logins.length) return null;
+
+ const at = typeof demo.resetsAt === 'string' ? new Date(demo.resetsAt) : null;
+ const resetsAt = at && !Number.isNaN(at.getTime()) ? at.toISOString() : null;
+
+ return { logins, resetsAt };
+}
+
+/**
+ * "22:00 UTC" — in UTC and not converted, matching the demo's own banner (§6).
+ * @param {string | null} iso
+ */
+export function resetTime(iso) {
+ return iso ? `${iso.slice(11, 16)} UTC` : null;
+}
+
+/**
+ * The sign-ins the demo at `demoUrl` publishes now, cached for a minute.
+ *
+ * @param {string} demoUrl The mounted `brand.json` `demoUrl`.
+ * @param {{ fetchImpl?: typeof fetch, now?: () => number }} [options] For the tests.
+ * @returns {Promise>}
+ */
+export async function demoLogins(demoUrl, { fetchImpl = fetch, now = Date.now } = {}) {
+ let base;
+ try {
+ base = new URL(demoUrl);
+ } catch {
+ return null;
+ }
+ if (base.protocol !== 'https:' && base.protocol !== 'http:') return null;
+
+ const key = base.origin + base.pathname.replace(/\/+$/, '');
+ if (cache.key === key && now() < cache.until) return cache.value;
+ if (pending?.key === key) return pending.promise;
+
+ const promise = (async () => {
+ let value = null;
+ try {
+ const response = await fetchImpl(`${key}/api/v1/public/settings`, {
+ headers: { accept: 'application/json' },
+ redirect: 'error',
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (response.ok) value = readDemo(await response.json());
+ if (!value) console.warn(`[beta] the demo at ${key} published no sign-ins (HTTP ${response.status}).`);
+ } catch (error) {
+ console.warn(`[beta] could not read the demo's sign-ins from ${key}: ${error.message}`);
+ }
+ cache = { key, until: now() + (value ? OK_TTL_MS : FAIL_TTL_MS), value };
+ return value;
+ })();
+
+ pending = { key, promise };
+ try {
+ return await promise;
+ } finally {
+ if (pending?.promise === promise) pending = null;
+ }
+}
+
+/** Forget the cache. For the tests. */
+export function resetDemoLoginsCache() {
+ cache = { key: '', until: 0, value: null };
+ pending = null;
+}
diff --git a/src/pages/beta.astro b/src/pages/beta.astro
index 5cc3312..2d1cfd8 100644
--- a/src/pages/beta.astro
+++ b/src/pages/beta.astro
@@ -4,6 +4,7 @@ import PageHeader from '../components/PageHeader.astro';
import NotBuilt from '../components/NotBuilt.astro';
import { liveBrand } from '../lib/brand.mjs';
+import { demoLogins, resetTime } from '../lib/demoLogins.mjs';
import { isFull, liveCount } from '../lib/betaStore.mjs';
import { issueFormToken, OUTCOME, submit, isSuccess } from '../lib/betaSignup.mjs';
import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/beta.mjs';
@@ -35,9 +36,9 @@ import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/
* The beta cannot start yet for two independent reasons — no Play track, and nowhere for a
* tester to point the app (see `beta.mjs` for both in full). The page collects addresses
* anyway, because the list is what makes the first batch possible on day one, and says
- * plainly that it is a list rather than a queue that is moving. The demo is rendered
- * through `NotBuilt` so the absence appears in the same shape it takes everywhere else on
- * the site rather than as an apology invented for this page.
+ * plainly that it is a list rather than a queue that is moving. Gate two closed when the
+ * public demo went up (2026-10-27): with `demoUrl` mounted it says so, and the page lists
+ * the demo's sign-ins as the demo itself publishes them (D82).
*
* ---------------------------------------------------------------------------------------
* WHAT THE SUCCESS SCREEN SHOWS, AND WHY IT CAN SHOW IT
@@ -53,6 +54,22 @@ export const prerender = false;
const brand = liveBrand();
+/**
+ * The public demo, which closes gate two (D27): the deployment a tester points the app at.
+ * Its sign-ins are read from the demo on this request (D82, `demoLogins.mjs`), so they are
+ * whatever the demo's own sign-in page lists. `null` with a `demoUrl` set means the demo did
+ * not answer, and the page sends people to that sign-in page instead.
+ */
+let demoHost = '';
+try {
+ demoHost = brand.demoUrl ? new URL(brand.demoUrl).host : '';
+} catch {
+ console.warn(`[beta] the mounted demoUrl is not a URL: ${JSON.stringify(brand.demoUrl)}`);
+}
+const demoBase = demoHost ? brand.demoUrl.replace(/\/+$/, '') : '';
+const demo = demoBase ? await demoLogins(demoBase) : null;
+const demoReset = resetTime(demo?.resetsAt ?? null);
+
/**
* A POST is a submission; anything else is somebody arriving. `Astro.request.formData()`
* parses both `application/x-www-form-urlencoded` and `multipart/form-data`, and this form
@@ -193,7 +210,7 @@ const formToken = issueFormToken();
test. This is the list of people who want a place on it.
- It has not opened yet, and the two reasons are below rather than behind a
+ It has not opened yet, and what it is waiting on is below rather than behind a
“coming soon”. Adding your address now means you are in the first batch
rather than hearing about it afterwards.
@@ -249,8 +266,11 @@ const formToken = issueFormToken();
What it is waiting on
- Two things, neither of which is a date. Both are visible from outside, so there is no
- reason to be vague about them.
+ {demoBase
+ ? 'Two things, neither of which is a date, and the second is in place. Both are ' +
+ 'visible from outside, so there is no reason to be vague about them.'
+ : 'Two things, neither of which is a date. Both are visible from outside, so there ' +
+ 'is no reason to be vague about them.'}
@@ -271,22 +291,85 @@ const formToken = issueFormToken();
-
-
Gate two
+ {demoBase ? 'Gate two · in place' : 'Gate two'}
Somewhere to point it
-
- The app is a client and ships pointed at nothing — its first screen asks for the
- address of a site running this platform. So a tester needs a deployment, and the
- public demo is the one being built for that. Until it is running, a place on the
- test would be a place to install an app with nothing behind it.
-
+ {
+ demoBase ? (
+
+ The app is a client and ships pointed at nothing — its first screen asks for the
+ address of a site running this platform. The public demo is that site: a real
+ deployment with a real game server behind it, open to anyone with the sign-ins{' '}
+ below.
+
+ ) : (
+
+ The app is a client and ships pointed at nothing — its first screen asks for the
+ address of a site running this platform. So a tester needs a deployment, and
+ without a public one to point at, a place on the test would be a place to
+ install an app with nothing behind it.
+
+ )
+ }
+ {
+ demoBase && (
+
+ The demo
+
+ Enter {demoHost} on the app's first screen, or{' '}
+ open it in a browser. Every account
+ below is shared with everyone else trying it, and the site is wiped back to the same
+ starting point every few hours, so nothing you change there lasts.
+
+
+ {demo ? (
+
+
+ Sign-ins, as the demo publishes them right now
+
+
+ | Account |
+ Password |
+ Role |
+
+
+
+ {demo.logins.map((login) => (
+
+ {login.username} |
+ {login.password} |
+ {login.role} |
+
+ ))}
+
+
+ {demoReset && (
+
+ )}
+
+ ) : (
+
+
The demo did not answer just now.
+
+ It is offline for a few minutes at every wipe. Its sign-ins are listed on{' '}
+ its own sign-in page
+ {' '}once it is back.
+
+
+ )}
+
+ )
+ }
+
What a tester needs
@@ -524,6 +607,46 @@ const formToken = issueFormToken();
font-size: 0.86rem;
}
+ /* The demo's sign-ins (D82). A plain table: three columns a person copies from, so the
+ passwords are code and nothing about them is decorated. */
+ .beta-demo {
+ margin-top: 1.25rem;
+ overflow-x: auto;
+ }
+
+ .beta-demo__logins {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .beta-demo__logins caption {
+ margin-bottom: 0.65rem;
+ color: var(--dim);
+ font-size: 0.86rem;
+ text-align: left;
+ }
+
+ .beta-demo__logins th,
+ .beta-demo__logins td {
+ padding: 0.55rem 0.75rem 0.55rem 0;
+ border-bottom: 1px solid var(--line-soft);
+ text-align: left;
+ }
+
+ .beta-demo__logins th {
+ color: var(--head);
+ font-weight: 600;
+ }
+
+ .beta-demo__logins td {
+ color: var(--text);
+ }
+
+ .beta-demo__foot {
+ margin: 0.85rem 0 0;
+ color: var(--muted);
+ }
+
/* The form and the counter. The counter is deliberately narrow and secondary — it is
context for the decision, not the reason to make it. */
.beta-formwrap {
diff --git a/src/pages/features.astro b/src/pages/features.astro
index 47c2a67..b86fe2c 100644
--- a/src/pages/features.astro
+++ b/src/pages/features.astro
@@ -133,7 +133,7 @@ const description =
{item.demoPath && (
See it running
diff --git a/src/pages/modules/rust.astro b/src/pages/modules/rust.astro
index bda83be..6cd1bd1 100644
--- a/src/pages/modules/rust.astro
+++ b/src/pages/modules/rust.astro
@@ -92,6 +92,7 @@ const description =
async () => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+});
+
+describe('readDemo', () => {
+ it('keeps the logins and normalises the reset time', () => {
+ const read = readDemo({ demo: { resetsAt: '2026-10-28T22:00:00.000Z', logins: LOGINS } });
+ assert.deepEqual(read, { logins: LOGINS, resetsAt: '2026-10-28T22:00:00.000Z' });
+ assert.equal(resetTime(read.resetsAt), '22:00 UTC');
+ });
+
+ it('answers null for a site that is not in demo mode', () => {
+ assert.equal(readDemo({ site_mode: 'live' }), null);
+ assert.equal(readDemo(null), null);
+ assert.equal(readDemo({ demo: { logins: [] } }), null);
+ });
+
+ it('drops malformed rows, extra fields and an unreadable time', () => {
+ const read = readDemo({
+ demo: {
+ resetsAt: 'soon',
+ logins: [
+ { username: 'demo-admin', password: 'pw-a', role: 'admin', token: 'x' },
+ { username: 'x'.repeat(65), password: 'pw', role: 'admin' },
+ { username: 'demo-player', password: 42, role: 'player' },
+ 'demo-moderator',
+ ],
+ },
+ });
+ assert.deepEqual(read, { logins: [LOGINS[0]], resetsAt: null });
+ assert.equal(resetTime(read.resetsAt), null);
+ });
+
+ it('shows at most eight', () => {
+ const many = Array.from({ length: 12 }, (_, i) => ({ username: `u${i}`, password: 'p', role: 'player' }));
+ assert.equal(readDemo({ demo: { logins: many } }).logins.length, 8);
+ });
+});
+
+describe('demoLogins', () => {
+ beforeEach(() => resetDemoLoginsCache());
+
+ it('reads the public settings route under the demo URL, without its trailing slash', async () => {
+ let asked = null;
+ const fetchImpl = async (url) => {
+ asked = url;
+ return answer({ demo: { logins: LOGINS } })();
+ };
+ const read = await demoLogins('https://demo.example.test/', { fetchImpl });
+ assert.equal(asked, 'https://demo.example.test/api/v1/public/settings');
+ assert.deepEqual(read.logins, LOGINS);
+ });
+
+ it('caches an answer for a minute', async () => {
+ let calls = 0;
+ let clock = 1_000;
+ const fetchImpl = async () => {
+ calls++;
+ return answer({ demo: { logins: LOGINS } })();
+ };
+ const options = { fetchImpl, now: () => clock };
+ await demoLogins('https://demo.example.test', options);
+ clock += 59_000;
+ await demoLogins('https://demo.example.test', options);
+ assert.equal(calls, 1);
+ clock += 2_000;
+ await demoLogins('https://demo.example.test', options);
+ assert.equal(calls, 2);
+ });
+
+ it('answers null when the demo is down, and asks again sooner', async () => {
+ let calls = 0;
+ let clock = 1_000;
+ const fetchImpl = async () => {
+ calls++;
+ throw new Error('connect ECONNREFUSED');
+ };
+ const options = { fetchImpl, now: () => clock };
+ assert.equal(await demoLogins('https://demo.example.test', options), null);
+ clock += 10_000;
+ assert.equal(await demoLogins('https://demo.example.test', options), null);
+ assert.equal(calls, 1);
+ clock += 6_000;
+ await demoLogins('https://demo.example.test', options);
+ assert.equal(calls, 2);
+ });
+
+ it('answers null for an error status and for a non-web URL', async () => {
+ assert.equal(await demoLogins('https://demo.example.test', { fetchImpl: answer({}, 502) }), null);
+ resetDemoLoginsCache();
+ let called = false;
+ const fetchImpl = async () => {
+ called = true;
+ return answer({ demo: { logins: LOGINS } })();
+ };
+ assert.equal(await demoLogins('file:///etc/passwd', { fetchImpl }), null);
+ assert.equal(await demoLogins('not a url', { fetchImpl }), null);
+ assert.equal(called, false);
+ });
+
+ it('shares one request between renders that arrive together', async () => {
+ let calls = 0;
+ const fetchImpl = async () => {
+ calls++;
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ return answer({ demo: { logins: LOGINS } })();
+ };
+ const [a, b] = await Promise.all([
+ demoLogins('https://demo.example.test', { fetchImpl }),
+ demoLogins('https://demo.example.test', { fetchImpl }),
+ ]);
+ assert.equal(calls, 1);
+ assert.deepEqual(a, b);
+ });
+});
+
+describe('applyBrand: which demo links point anywhere (D83)', () => {
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-demo-brand-'));
+ const dist = path.join(scratch, 'dist');
+ const mount = path.join(scratch, 'brand');
+ const page = path.join(dist, 'client', 'index.html');
+
+ // The four shapes a build writes: the homepage slot, a core deep link, and a link into
+ // each game's pages. Same attribute order as the templates; checkBrand.mjs holds them to it.
+ const STOCK =
+ 'See it running\n' +
+ 'w\n' +
+ 'r\n' +
+ 'u\n';
+
+ fs.mkdirSync(path.dirname(page), { recursive: true });
+ fs.mkdirSync(mount, { recursive: true });
+ fs.writeFileSync(page, STOCK);
+
+ const apply = (brand) => {
+ fs.writeFileSync(path.join(mount, 'brand.json'), JSON.stringify(brand));
+ execFileSync(process.execPath, [path.join(ROOT, 'scripts/applyBrand.mjs')], {
+ env: {
+ ...process.env,
+ BRAND_DIST: dist,
+ BRAND_DIR: mount,
+ BRAND_DEFAULT_DIR: path.join(ROOT, 'brand-default'),
+ },
+ stdio: 'pipe',
+ });
+ return fs.readFileSync(page, 'utf8');
+ };
+
+ const hrefOf = (html, marker) => html.split('\n').find((line) => line.includes(marker)).match(/href="([^"]*)"/)[1];
+
+ after(() => fs.rmSync(scratch, { recursive: true, force: true }));
+
+ it('fills the slot, core links and the listed game only', () => {
+ const html = apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] });
+ assert.equal(hrefOf(html, 'demo-cta'), 'https://demo.example.test');
+ assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki');
+ assert.equal(hrefOf(html, 'tab=map'), 'https://demo.example.test/rust/servers/demo?tab=map');
+ assert.equal(hrefOf(html, '/uo/market'), '');
+ assert.match(html, /data-demo-path="\/uo\/market" data-demo-game="uo"/);
+ });
+
+ it('hides a game link again when only demoGames changes', () => {
+ const html = apply({ demoUrl: 'https://demo.example.test', demoGames: [] });
+ assert.equal(hrefOf(html, 'tab=map'), '');
+ assert.equal(hrefOf(html, '/wiki'), 'https://demo.example.test/wiki');
+ });
+
+ it('treats a malformed demoGames as no games', () => {
+ apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust'] });
+ const html = apply({ demoUrl: 'https://demo.example.test', demoGames: 'rust' });
+ assert.equal(hrefOf(html, 'tab=map'), '');
+ });
+
+ it('returns to the stock markup when the demo is unmounted', () => {
+ apply({ demoUrl: 'https://demo.example.test', demoGames: ['rust', 'uo'] });
+ const html = apply({});
+ assert.equal(html, STOCK);
+ });
+});
--
2.49.1