Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the
export CLI of PLAN.md §8, and adds this repository's first test suite.
Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"):
- D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised
`/app/` "the 14 existing screenshots"; they are a July trusted-device smoke
test against an unseeded dev instance, captured before the theming work, and
five of the fourteen are two-factor prompts. Shipping them would break D4.
Phase 9 already builds the rig, so it gains an emulator pass.
- D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole
app on a validated deployment address, so a tester needs somewhere to point it.
The beta therefore waits on the demo VM, and the page says so.
- D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint
cannot report a validation error without JavaScript. §6's diagram is amended.
- D29 — the APK and the beta get equal billing, and the APK link is off:
`androidApk.serviceable` is false because the published v0.5.0 build does not
work. The panel stays and states that plainly rather than being removed.
Three mechanisms the plan did not anticipate:
- `liveBrand()` — a server-rendered page never passes through the boot rewrite,
so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in
takes effect on the next request rather than the next restart.
- `checkLinks.mjs` derives on-demand routes from `prerender = false` in the
source. A PLANNED_ROUTES entry would have been wrong: its reverse check fires
when a route has been built, and an on-demand route never produces a file, so
the entry could never rot out.
- `npm test` — the five existing checks all read built output, and none of this
logic appears there. A honeypot can stop working and leave the build identical.
Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606
reserved domains are not contact addresses; the D13 rule is otherwise unchanged.
Verified end to end against the built server: every outcome renders with no
JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without
a restart, and the export CLI round-trips.
Co-Authored-By: Claude <noreply@anthropic.com>
PLAN.md §13 phase 4: /features/, /architecture/, /modules/, /integrations/, and
/community/ — plus the two scope items the phase table never assigned to anyone.
Six decisions taken by the org lead before coding, recorded in PLAN.md §10 as
D20-D25:
- D20 /features/ is the homepage's list with a `detail` line, not a second list.
One data file, two renderings, so they cannot disagree about what exists.
- D21 /architecture/ draws reasons, not reference: three new inline SVGs, one per
boundary. No endpoint tables, no config keys — those are phase 8's and stay
canonical in docs/.
- D22 The deliberate absences of §2 become one tagged data file, rendered on the
three pages that promise them.
- D23 Phase 4 absorbs /community/ (specified in §10 and §14 N3, linked from the
header since phase 1, built by no phase) and checkLinks.mjs.
- D24 `needsModule`: writing the Teams detail exposed a false claim phase 3
shipped. Teams are module-sourced only — teams.module_id is NOT NULL, there is
no create route, sync is gated on providerModuleId() — so the Community group
no longer says a bare core does all of it.
- D25 The per-capability demo affordance brand.json had promised since phase 2 is
a deep link, filled at boot from data-demo-path.
checkLinks.mjs reads the built HTML rather than src/, because half these links
are assembled from data files and template literals. Its PLANNED_ROUTES list is
checked in both directions, so it cannot rot into a permanent exemption.
applyBrand.mjs gained a pass that recomputes deep links from their immutable
path, making it idempotent and reversible; checkBrand.mjs lifts that pattern out
and runs it against the stock markup so the two cannot drift. Both proved
against a real mount, in both directions.
Fixes a cascade bug the checks could not see: [data-demo-url=''] and a scoped
component class are both specificity 0,1,0, so .demo-link's `display` beat the
hide rule and twelve links to a nonexistent demo rendered, each resolving to the
current page. The rule is now !important.
The four diagrams' shared SVG vocabulary moved to src/styles/diagram.css.
Verified from a clean checkout: npm ci, all five checks, astro check (0 errors),
production build, and a live browser pass at desktop and 390px.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
PLAN.md §7: swapping a logo or recolouring the site is a file copy and a
container restart, never a rebuild. Phase 2 builds the mechanism and the
checks that keep it true.
GET /brand/* resolves every file against the mount first and the baked-in
defaults second, per file, at stable unhashed URLs with an ETag and a five
minute TTL. Nothing goes through Vite, which would fingerprint the names out
of the mount's reach. An X-Brand-Source header says which step answered.
Three decisions were taken with the org lead (recorded as D14-D16 in §7):
D14 — one raster in, every size out. brand-default holds a single logo.png;
the header mark at three pixel ratios, both install icons, the apple-touch
icon, the favicons and a real multi-resolution favicon.ico are derived on
request from whichever logo.png is in force, cached, and limited to an
allowlist of sizes. Shipping fifteen precomputed files would have meant an
operator producing fifteen to change a mark — and getting a new header with
the old favicon.
D15 — brand text is applied at boot. Pages are prerendered, so §7's promise
about the site name, tagline and links could not hold at render time.
npm start now runs scripts/applyBrand.mjs first, rewriting the built HTML
from what it last applied to what the mount says. It rewrites from a record
in dist/.brand-applied.json rather than from the defaults, because the naive
version works exactly once and then silently ignores every later edit. An
empty mount is a no-op; removing a mount restores the stock build byte for
byte. Verified both ways, plus a second rename.
D16 — the header shows the real emblem, replacing phase 1's placeholder
glyph, so the site, the product and the Android launcher icon are one mark.
It is raster art, so theme.css cannot recolour it; replacing logo.png is how
the mark changes.
Two defects found and fixed while proving it:
The mounted theme.css did not win. Astro emits its own stylesheet after the
head markup, so linking the operator's last was not enough and every override
was silently a no-op. tokens.css now lives in @layer tokens and the mounted
file is unlayered, which takes order out of the mechanism entirely.
The documentation was a different site. Starlight builds its own head, so the
docs linked a Starlight default /favicon.svg that does not exist here, carried
no manifest or OG card, and never loaded the brand stylesheet — a mounted
theme recoloured the marketing pages and left the docs stock. A Head override
fixes it; half a rebrand looks like a product bug rather than a missed step.
brand-default/wordmark.svg and og-image.png are generated by
scripts/buildBrandAssets.mjs from the emblem and Cinzel's outlines and are
committed, so CI needs neither the artwork nor a font. Type is converted to
paths, because an SVG in an <img> can see neither the page's @font-face rules
nor fontconfig — the same isolation that broke currentColor in phase 1. Its
glyphs are drawn at the origin and translated: opentype.js emits NaN
coordinates at a non-zero origin for some glyphs, and a path parser stops at
the first malformed command, so the first lockup read "Runic Gate" and looked
like a typo rather than a bug.
scripts/checkBrand.mjs is the mechanism for the two failures that are
otherwise silent: it puts every literal /brand/... URL in the source through
the route's own classifier, so a size that is not on the allowlist fails the
build instead of 404ing in a browser, and it rejects a brand string short
enough that a blind replacement at boot could corrupt a page. Negative-tested
three ways before being trusted. It runs in CI ahead of the type check.
Co-Authored-By: Claude <noreply@anthropic.com>
checkFacts needs to read link, servuo-plugins, website and installer, and
the automatic per-run token is scoped to this repo alone. Rather than mint
a new secret, the workflow uses REGISTRY_TOKEN, which already exists at the
org level with the right permissions.
The secret is named for the registry and the script reads GITEA_TOKEN; the
mapping stays in the workflow so the script keeps asking for what it
actually wants -- a Gitea token -- rather than this org's secret name.
Co-Authored-By: Claude <noreply@anthropic.com>
`data/` without a leading slash matches a directory of that name at any
depth, so it silently swallowed src/data/platform.json -- the single file
every page and checkFacts.mjs reads.
The working tree still had it, so `npm run verify` passed locally with all
14 facts green. CI cloned fresh and `astro check` failed on the missing
module. Anchored both patterns to the repository root.
Verified the way it should have been the first time: exported HEAD to a
clean directory, npm ci, and ran the checks there rather than in the tree
that was hiding the problem.
Co-Authored-By: Claude <noreply@anthropic.com>
Astro 7 with the Node adapter, Starlight mounted at /docs, the token file, both
self-hosted typefaces, the layout shell, and the two build-time checks from §12.
The palette's gold and cyan are sampled from runic-emblem.png rather than
guessed, per §11: 494,059 opaque pixels binned by hue, each value annotated with
its measured contrast against the ground, and restricted rather than brightened
where a ratio fails.
- checkTokens.mjs fails the build on any colour literal outside tokens.css,
which is what keeps §7's "recolouring is a file copy" promise true.
- checkFacts.mjs re-reads all 14 externally-sourced facts from their authorities
over the Gitea API and fails on disagreement. It also enforces D13: no email
address in the source outside brand-default/brand.json.
- Both were negative-tested; neither has ever been allowed to pass by default.
§6 asks for output:'server' with per-page prerender=true. Astro 7 expresses the
same runtime shape as output:'static' with an adapter, opting individual routes
out — so the default is static rather than accidentally server-rendered.
Co-Authored-By: Claude <noreply@anthropic.com>
The domain still has no mailbox. Rather than delay the beta on Cloudflare
Email Routing, the org lead elected to publish the existing address.
- D13 recorded: whitlocktech@gmail.com is the published contact, held only
in brand.json so a later privacy@/security@ is a file copy, not a rebuild.
- Phase 0 PR 0.10 withdrawn -- SECURITY.md already carries that address.
- checkFacts.mjs gains the rule that keeps the promise true: no email
address in the source outside brand-default/brand.json.
- Phase 0 marked complete (docs#172, docs#173, .profile#4, Module-uo#19).
Co-Authored-By: Claude <noreply@anthropic.com>
Revision 1 ended in five open questions. All are now answered, plus the
domain and Gitea-registration facts, so the plan states decisions instead
of asking.
New section 5 holds the twelve decisions of record. Sections 6-9 design
against them: the Astro + Node adapter runtime, branding resolved out of a
bind mount against baked defaults, the Android closed-beta signup, and the
legal pages the beta cannot ship without.
Re-verified every version against the Gitea API and corrected three things
revision 1 got wrong: the bundle manifests live at the root of the bundles
branch rather than under bundles/, the website publishes no releases at
all, and the Android app targets no server of ours - the user enters the
address, which is load-bearing for the privacy policy.
Phase 0 grows from the four operator-facing documentation fixes to all
ten conflicts, plus retiring the personal Gmail that SECURITY.md
publishes as the public vulnerability contact.
Two design notes worth flagging. Play does not notify testers on the
email-list path either, so a site that sends no mail looked blocked - but
the closed-testing opt-in URL only works for addresses already on the
list, so it is safe to publish on the confirmation screen, with Discord
carrying the announcement. And the CSV export is a CLI against the bind
mount rather than an admin page, so the site keeps no authenticated
surface at all.
The demo instance is recorded as planned and out of scope: a Proxmox VM
running the full stack including ServUO, restricted and reset hourly. The
site reserves its slot now so it costs one line in a bind-mounted file
later.
Co-Authored-By: Claude <noreply@anthropic.com>
Reconnaissance across the nine RunicGateway checkouts, and the plan for the
public site + documentation experience built on top of them.
The site is a presentation layer over existing project knowledge: normative
contracts stay canonical in docs/ and are linked, never copied. What the site
authors is the journey — install, configure, administer, extend — which no
existing document owns end to end, because the repos are organised by
component and an operator is not.
Two things worth reading before the rest:
- Section 3 lists eleven places the repositories contradict each other,
verified against origin/main rather than resolved quietly. The sharpest is
that link/INTEGRATION.md documents the protocol-4 event kinds while its
normative section 2 still declares X-UOLink-Version: 3 — one document
disagreeing with itself. installer/INSTALL.md still hands operators
"Protocol version 3", which is the value they paste into Admin -> Shard,
and its own troubleshooting table says a stale number looks exactly like
the shard going offline.
- Section 4 makes fixing those a Phase 0 that lands before the site quotes
anything: four PRs across docs and .profile, none of which touch code or a
contract. They correct documentation to match what main already does.
Section 1 also records a process failure and the rule taken from it. An
earlier draft claimed the platform was on protocol 3 with 4 "not cut over".
Every checkout was on a feature branch whose local main ref had never been
fetched, so git show main:<path> answered from a months-old blob. Every fact
in section 2 is now read from origin/main or the Gitea API, and section 7
turns that into a build-time check instead of a promise.
Section 9 leaves five questions open rather than assuming answers.
Co-Authored-By: Claude <noreply@anthropic.com>