feat(home): phase 3 — the homepage
All checks were successful
PR checks / checks (pull_request) Successful in 49s

Replaces phase 1's scaffold with the real homepage: hero, the data path as
inline SVG, the self-hosted argument, all five capability groups, and the
get-started CTA. Three decisions the org lead took first are recorded in
PLAN.md as D17-D19.

The data path is drawn generically and captioned specifically (D17): the nodes
say "your game server" and "sidecar", the sub-labels and caption name ServUO and
uo-link. The SVG is aria-hidden because the four numbered steps beside it carry
the same path in prose — one telling, not two.

The capability list is data with a check behind it (D18). Every Game-intelligence
item names the module-uo capability slug it comes from, and the build fails if
the page and platform.json disagree either way. That needed a fifteenth fact in
checkFacts.mjs: §12 named the capability list as an externally-sourced fact and
nothing re-read it, so the chain rested on someone remembering. It also found
that the site was omitting two of the module's eight capabilities — guilds and
city governors are now listed, in the page and in §10.

The hero leads with the emblem (D19), derived from whichever logo.png is in
force so one file still changes the hero, header, tab icon and app icon
together.

Also here, both found by standing the build up rather than by review:

  - checkBrand.mjs now enforces the demo slot's markup contract. applyBrand.mjs
    reveals the demo link by replacing an exact pair of empty attributes; an
    attribute inserted between them produces a build where the mount sets a demo
    URL, the boot log says nothing and the link never appears. Both halves are
    checked and the literal is derived from the expression applyBrand.mjs uses,
    so they cannot drift.

  - The header nav overflowed at 390px — four links plus the lockup measured
    433px against a 390px viewport, so every phone got a horizontally scrolling
    page. Phase 1 left this to phase 3 expecting a disclosure control; it got a
    wrap instead, because with four links there is nothing to disclose and a
    hamburger costs state, script and duplicate markup.

Verified on a clean checkout of this commit: all four checks, astro check, a
production build, a live /brand/* smoke, and a demo URL mounted and reverted.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-20 00:03:04 -05:00
parent bb06f1de44
commit 556dee7355
12 changed files with 1333 additions and 78 deletions

View File

@@ -216,6 +216,80 @@ if (brand) {
}
}
/* =======================================================================================
4. The demo slot's markup contract (§15 / D12)
=======================================================================================
`applyBrand.mjs` reveals the demo link by string-replacing an exact pair of empty
attributes in the built HTML. That is a contract between a script and a template that
share no code, and it fails in the quietest possible way: an attribute inserted between
the two, or `href` written after `data-demo-url`, produces a build where the demo URL is
set in the mount, the boot log says nothing, and the link is simply never there.
Both halves are checked, and neither is retyped from memory — the literal is derived from
the same expression `applyBrand.mjs` uses, so the two cannot drift apart. */
const applyForCheck = existsSync(path.join(ROOT, 'scripts/applyBrand.mjs'))
? readFileSync(path.join(ROOT, 'scripts/applyBrand.mjs'), 'utf8')
: '';
const attrTemplate = applyForCheck.match(
/`href="\$\{escapeHtml\(value\)\}" data-demo-url="\$\{escapeHtml\(value\)\}"`/
);
if (!attrTemplate) {
fail(
'applyBrand.mjs no longer builds the demo attributes as `href="..." data-demo-url="..."`.\n' +
' Update the expected pair below to match, and re-check every template that writes it.'
);
} else {
// What the script will look for when the applied value is the stock empty string.
const EMPTY_PAIR = 'href="" data-demo-url=""';
let slots = 0;
const strays = [];
for await (const file of walk(path.join(ROOT, 'src'))) {
if (path.extname(file) !== '.astro') continue;
// Comments discuss the contract at length, including in the template that implements
// it. Scanning them would make the check fail on its own documentation.
// Blanked rather than removed: keeping every newline and every offset means the line
// numbers reported below are the ones in the file, not the ones in a shortened copy.
const blank = (match) => match.replace(/[^\n]/g, ' ');
const source = readFileSync(file, 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/<!--[\s\S]*?-->/g, blank);
const relative = path.relative(ROOT, file);
slots += source.split(EMPTY_PAIR).length - 1;
for (const match of source.matchAll(/data-demo-url/g)) {
const start = match.index - EMPTY_PAIR.indexOf('data-demo-url');
if (source.slice(start, start + EMPTY_PAIR.length) !== EMPTY_PAIR) {
strays.push(`${relative}:${source.slice(0, match.index).split('\n').length}`);
}
}
}
if (!slots) {
fail(
`no demo slot found in src/**/*.astro — expected the literal \`${EMPTY_PAIR}\`.\n` +
' §15 reserves this slot so that gaining a demo instance is one line in the mounted\n' +
' brand.json. Removing it makes that a rebuild.'
);
}
for (const site of strays) {
fail(
`${site} writes data-demo-url outside the exact pair \`${EMPTY_PAIR}\`.\n` +
' applyBrand.mjs replaces that literal at boot; anything else is invisible to it and\n' +
' the slot will never appear.'
);
}
}
/* ======================================================================================= */
if (failures.length) {
@@ -227,5 +301,5 @@ if (failures.length) {
console.log(
`checkBrand: brand-default is complete, ${referenced.size} /brand/ URL(s) resolve, ` +
`and every rewritable string is safe to replace.`
`every rewritable string is safe to replace, and the demo slot matches its contract.`
);

View File

@@ -117,7 +117,29 @@ async function checkModuleApi() {
}
// ---------------------------------------------------------------------------
// 4. The current bundle
// 4. The capabilities the installed module actually declares
//
// §12 names "module-uo's capability list" as one of the facts platform.json holds, and it
// was the one fact nothing re-read. That mattered from phase 3 onwards, because the
// homepage renders the list rather than merely storing it: `src/data/capabilities.mjs`
// asserts at build time that every declared slug is claimed by a named capability on the
// page and vice versa. Without this check that assertion was anchored to a local copy
// nobody was verifying, so the whole chain rested on someone remembering.
//
// Sorted before comparing: the manifest's order is the module's business, and a reordered
// array is not a changed capability set. A slug appearing or disappearing is.
// ---------------------------------------------------------------------------
async function checkModuleCapabilities() {
const authority = 'Module-uo main:module.json';
const manifest = JSON.parse(await raw('Module-uo', 'module.json', 'main'));
const declared = [...(manifest.capabilities || [])].sort();
const expected = [...platform.moduleUoCapabilities].sort();
record('moduleUoCapabilities', expected.join(' '), declared.join(' '), authority);
}
// ---------------------------------------------------------------------------
// 5. The current bundle
//
// The manifests live at the ROOT of the `bundles` branch — `current.json`,
// `bundle-<tag>.json` — not under `bundles/`. Fetching the directory 404s.
@@ -141,7 +163,7 @@ async function checkBundle() {
}
// ---------------------------------------------------------------------------
// 5. Release versions, per repo
// 6. Release versions, per repo
// ---------------------------------------------------------------------------
async function checkReleases() {
for (const [repo, expected] of Object.entries(platform.releases)) {
@@ -152,7 +174,7 @@ async function checkReleases() {
}
// ---------------------------------------------------------------------------
// 6. `website` still publishes nothing
// 7. `website` still publishes nothing
//
// It ships as container images and is never tagged, so the site refers to the platform by
// bundle tag and Module API version instead. The day that changes, this repo should notice
@@ -165,7 +187,7 @@ async function checkWebsiteHasNoReleases() {
}
// ---------------------------------------------------------------------------
// 7. D13 — the contact address lives in exactly one file
// 8. D13 — the contact address lives in exactly one file
// ---------------------------------------------------------------------------
const CONTACT_CHECK = 'contact address (D13)';
@@ -244,6 +266,7 @@ async function main() {
checkProtocol,
checkOverlayProtocol,
checkModuleApi,
checkModuleCapabilities,
checkBundle,
checkReleases,
checkWebsiteHasNoReleases,