feat(marketing): phase 4 — the marketing pages
All checks were successful
PR checks / checks (pull_request) Successful in 9m9s

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>
This commit is contained in:
2026-08-24 01:53:11 -05:00
parent d9d7a8d47f
commit 2d19ee4220
21 changed files with 3332 additions and 119 deletions

118
PLAN.md
View File

@@ -234,6 +234,16 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
| **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. |
| **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 **twenty-five**:
| # | Where | What it settled |
|---|---|---|
| D14D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem |
| D17D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero |
| D20D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links |
---
## 6. Runtime shape
@@ -532,6 +542,11 @@ Guilds and city governors were added to Game intelligence in phase 3: `module-uo
capabilities and the site was omitting two of the eight. That correction is now mechanical rather
than editorial — see D18.
**Community is core machinery, but two of its six need a module to fill them.** Teams and Team
forums are marked as such (D24). Core owns every part of the Team machinery and cannot create a
Team: they arrive from the installed module, so on a deployment with no module the feature is
present and permanently empty. The group's summary says so; `/features/` says why.
### How phase 3 built the homepage
Three decisions taken before the page was written (org lead, 2026-08-20).
@@ -570,6 +585,87 @@ nothing has to be rewritten later. Links *into the documentation* are the except
`/docs/`, because phases 7 and 8 own those slugs and a guessed one would be a stale URL nothing
checks.
### How phase 4 built the marketing pages
Six decisions taken before coding (org lead, 2026-08-20), plus two scope items the phase table had
never assigned to anyone.
**D20 — `/features/` is the homepage's list with a `detail` line, not a second list.** Every
capability in `src/data/capabilities.mjs` gained a sentence or two of argument; `/` renders the
label, `/features/` renders the label and the detail. Rejected: slicing the page by reader
(players / staff / operators / builders), which reads better but makes the same capability appear
twice and breaks the one-to-one mapping the coverage check depends on; and deep-diving only the
differentiators, which would have left the page looking smaller than the homepage promised.
`assertDetailCoverage()` fails the build on a capability with no detail — the homepage would still
look right, and `/features/` would render a heading with nothing under it.
**D21 — `/architecture/` draws reasons, not reference.** Three new inline SVGs, each drawing one
boundary: two hosts and two installs, the public/staff allowlist, and the core/module seam. It
carries no endpoint tables, no configuration keys, no schema and no event catalog — phase 8 owns
those, they are canonical in `docs/`, and a second copy here is a copy that goes stale (§1).
Rejected: reusing the homepage's data-path diagram larger (a visitor arriving from `/` meets the
same picture twice), and adding a component/version table (starts becoming the Reference section).
The vocabulary the four diagrams now share moved to `src/styles/diagram.css`.
**D22 — the deliberate absences are one data file.** `src/data/notBuilt.mjs`, each entry tagged with
the pages that render it, because the homepage already promises a reader they will find the list on
both `/features/` and `/integrations/` and two hand-written copies is how the inconvenient half
stops appearing on one of them. Every entry carries a `resolvedBy`: an absence with an exit
condition is a position, an absence without one is a hole. That generalises what D8 already required
of the Integration Kit's draft badge.
**D23 — phase 4 absorbs `/community/` and `checkLinks.mjs`.** Neither had a phase. §10 specifies the
page and §14 N3 specifies its contents, and the header and footer have linked it since phase 1 — a
page the site pointed at that no phase built. `checkLinks.mjs` is specified in §12 and phase 4 is
what makes it load-bearing: it roughly quadrupled the internal link count and added the first
outbound links into the repositories.
**D24 — `needsModule`, because "core" and "module-supplied" were not enough.** Writing the
`/features/` detail for Teams exposed a claim phase 3 had shipped: the Community group said
"everything here works on a deployment with no game module installed at all", and that is false.
`teams.module_id` is `NOT NULL` on `website` `main`, there is no create route anywhere under
`/api/v1/admin/teams`, and sync is gated on `teamProvider.providerModuleId()`. Core owns the whole
Team machinery — tables, roster resolver, forums, notification streams, Discord bridge, voice,
activity feed, `/admin/teams` — and deliberately cannot *originate* a Team, because core does not
own the word for one. On a bare core the feature is present, correct and permanently empty. Teams
and Team forums are marked; the group summary was requalified; the homepage changed by one sentence
and kept D18's five named groups. Rejected: a sixth group for Teams (says it loudest, costs the
five-group grid phase 3 tuned), and fixing only the wording (leaves the distinction one sentence
deep and unguarded).
**D25 — the demo affordance on `/features/` is a per-capability deep link.** `brand.json` had
promised one since phase 2 without defining it. Capabilities with a stable public route carry a link
appended to the mounted `demoUrl`; the rest carry nothing, and that asymmetry is honest — a
character sheet is reachable only by the account it belongs to, and a Team forum lives behind an id
no static page can know. Paths are read from the real route tables on `main`, never guessed, which
also means they are the *module's* routes: a deployment running a different module deep-links
somewhere else.
That needed the branding pipeline extended, because the phase-3 slot could not express it. The slot
is a literal swap of a whole URL, so it can only ever put the demo's root in an `href`, and
reversing it would not even find a deep link — whose `href` is the root plus a path, matching no
literal the script knows. `applyBrand.mjs` gained a second pass that **recomputes** all three
attributes from the immutable `data-demo-path`, making it idempotent and exactly reversible, and
`checkBrand.mjs` gained a guard that lifts the pattern out of `applyBrand.mjs` and runs it against
the stock markup, so the two cannot drift. Both directions were proved against a real mount.
**One thing the checks caught about each other.** A scoped `:has([data-demo-url=''])` rule, added to
hide the wrapper around a hidden demo link, made `checkBrand.mjs` fail: it cannot tell a CSS selector
from an attribute, and it should not have to. The right fix was to delete the wrapper and let the
link be the flex item, so the existing hide rule takes the margin with it — a case where the check
being blunt pointed at simpler markup rather than at a needed exemption.
**And one thing no check caught.** `[data-demo-url=''] { display: none }` is specificity 0,1,0, and
so is the scoped class Astro puts on the same element — so a component that sets `display` wins on
source order, because component styles are emitted after `global.css`. `/features/`'s `.demo-link`
set `display: inline-flex` for its arrow, and twelve links to a demo that does not exist rendered on
the page, each pointing at `href=""` — which a browser resolves to the page it is already on.
`checkBrand.mjs` was green throughout: the attributes were perfect and the defect was three files
away, in the cascade. It was found by looking at the rendered page at 390px, which is not a
mechanism, and it is the argument for keeping the live browser pass in every phase. The rule is now
`!important` and says why in the stylesheet: while there is no demo these elements do not render,
and no component may overrule that by accident.
### Documentation
```
@@ -692,7 +788,23 @@ a mechanism rather than diligence:
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
mechanism, diligence is not.
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
`RunicGateway` repo points at a branch path, not a commit permalink.
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from
data files and template literals, and a source scan sees an expression rather than a URL. It runs
after the build for that reason, in `verify` and in CI. It fetches nothing — the outbound rule is
about the shape of a URL, and a check that fails when someone else's host is slow is a check
people learn to ignore.
It carries one exemption list, `PLANNED_ROUTES`, because §10's convention is that the header,
footer and homepage link the *final* routes rather than growing links phase by phase. That is safe
only because the list is checked in both directions: a link to a route that is neither built nor
listed fails, **and an entry whose route has since been built also fails**, so the list cannot rot
into a permanent exemption once the page arrives.
- **`scripts/checkBrand.mjs`** also guards the demo slot and, since phase 4, the per-capability deep
links (D25) — lifting the pattern out of `applyBrand.mjs` and running it against the stock markup,
so a template and a script that share no code cannot drift apart. Both are invisible in a stock
build, which is exactly why they need a check rather than a look.
- **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7).
- `astro check` plus a production build, in CI on every PR.
@@ -706,14 +818,14 @@ a mechanism rather than diligence:
| **1** | Foundation: Astro + Node adapter scaffold, the token file, typography, layout shell, header/footer, docs theming and sidebar, `platform.json` + `checkFacts.mjs` + `checkTokens.mjs` |
| **2** | Branding pipeline (§7): `/brand/*` resolution, `brand-default` contents, the emblem's web derivatives and lockup, `brand.json` wiring |
| **3** | Homepage: hero, the data-path diagram as inline SVG, grouped capability sections, CTA, the reserved demo slot |
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/` |
| **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20D25 |
| **5** | The app and the beta: `/app/`, `/beta/`, the signup endpoint, the SQLite store, rate limiting, the export CLI (§8) |
| **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) |
| **7** | Docs — the journey: Getting started (7) + Administration (12). **The installation path is the priority of the whole project** |
| **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) |
| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components |
| **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers |
| **11** | Validation: `astro check`, production build, all four check scripts, mobile layout verified in a real browser, a signup walked end to end |
| **11** | Validation: `astro check`, production build, **all five check scripts** (tokens, brand, links, facts, types), mobile layout verified in a real browser, a signup walked end to end |
| **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) |
Phases 5 and 6 are deliberately adjacent and early: the beta cannot start without `/privacy`, and