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

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

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

The rig is committed rather than remembered (D45):

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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