docs(modules): close Phase 5 — the acceptance run, and the page shell it found

Slice 3 of Phase 5 (MODULE_SYSTEM.md 2.11.1), and the phase's last slice.

docs/modules/kit-acceptance.md is decision 5's deliverable: a cold agent given the
Integration Kit and the documents it links to — never core's source, never
module-uo — built a working module for a second game, which was then installed
into a real core and taken through MODULE_API.md 7.7's browser smoke. Verdict
recorded whichever way it went, and it went **yes, with caveats**: one pass, no
core source, and three of the four normative documents never opened.

The finding that justifies the two-stage shape is the one the agent structurally
could not reach, because it had no core to render against. A module page built
exactly as the kit teaches renders OUTSIDE the site: PublicLayout supplies the
chrome and not the body, and the `shell-... page-body` wrapper every core public
page writes for itself is two class names that appear in no contract. That is
3.4's own stated failure — "a module page that does not look like the site it is
installed in" — reached by following 3.4.

Fixed in core rather than documented at the reader, so the class names stay
core's private business and the theming workstream keeps its freedom to rename
them: PublicLayout takes an opt-in `shell` width, MODULE_API_VERSION 1.5.0
(website#148, merges first).

- MODULE_API.md 1.1: 1.5.0's entry, and a new bump-table row — adding an
  OPTIONAL prop or argument is minor. "A member's signature changes" is major
  because a call already written changes meaning, and an optional prop changes
  none; the table now says what it means rather than leaving it to be argued.
- MODULE_API.md 3.4: the shell prop, why a module names a width and never a
  class, and the eight-vs-seven miscount the run also turned up — the kit had
  faithfully carried it out of the contract into the template, which is the
  never-re-specify rule working exactly as designed on a wrong input.
- rust-dryrun.md: coreApi ^1.3.0 -> ^1.5.0, as a dated correction per decision 33.
  It is the only complete module.json in the kit's reading path and nothing
  checks a JSON block inside a Markdown file, which is the reusable half.
- MODULE_SYSTEM.md 2.11.1: slice 3 recorded, plus the third finding worth
  generalising — a check whose failure message asserts a diagnosis has to be
  right about it. `check:swagger` failed on a pristine template on Windows
  (CRLF) while blaming the routes, green on the Linux runner forever.
- Decision 34: core owns the page body as well as the chrome.

The banner does not come off. Decision 32 makes that a person's to remove, this
run exercised the website-module half only (the module has no sidecar, so
chapters 3 and 4 were never tested), and an agent does not skim or give up.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-12 14:28:26 -05:00
parent c6f4c2478f
commit 3116e7bbf6
4 changed files with 248 additions and 12 deletions

View File

@@ -26,13 +26,27 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.4.0'
const MODULE_API_VERSION = '1.5.0'
```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop —
`'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always
written by hand (§3.4). Found by the acceptance run in
[`../modules/kit-acceptance.md`](../modules/kit-acceptance.md): a module built by following the kit
alone rendered *outside* the site's page column, because the wrapper's two class names belong to
`theme.css` and appear in no contract. Omitting `shell` is 1.4.0's behaviour exactly, so core's own
pages are untouched.
**Minor, and the table below is why that needs saying.** "A member's signature changes" is a major
bump, and a prop is a signature — but the rule is about a call that *already exists* changing
meaning, and an optional prop changes none. Read the table as being about what breaks, not about what
is typed: adding an optional argument is an addition, and `module-uo`'s `coreApi: "^1.3.0"` still
resolves.
**1.4.0 — Phase 5, the sidecar rule.** §2.7 gained one prohibition: a module does not open a
connection to a game server from the website process. It talks to a **sidecar**, which owns the
durable copy of the game's state. No member was added, removed or changed — the surface is identical
@@ -68,6 +82,7 @@ requires a line of module code, and a mismatch fails that module loudly into `st
| Change | Bump |
| --- | --- |
| A member is added to `ctx`, or a new `register*` call appears | minor |
| An **optional** prop or argument is added to an existing member | minor |
| A member is removed or its signature changes | major |
| Behaviour of an existing member changes without a signature change | major |
| A core-internal refactor behind an unchanged member | none |
@@ -779,21 +794,52 @@ The kit is **curated and closed**, not a re-export of `components/`:
| Export | From | Why it is in the kit |
| --- | --- | --- |
| `PublicLayout` | `components/PublicLayout.jsx` | the public chrome; a module page without it is a bare page |
| `PublicLayout` | `components/PublicLayout.jsx` | the public chrome **and**, via `shell`, the page body; a module page without it is a bare page |
| `PageHeader` | `components/PageHeader.jsx` | title/subtitle furniture |
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; changing a kit component's props is a
**major** one. That is a real constraint on core and it is the price of the boundary being worth
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing
props is a **major** one, because that breaks a call already written. Adding an **optional** prop is
minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth
anything.
The kit is those **seven** members. An earlier draft of this table listed an eighth, `AdminPage`, and
core has no such component — admin views are plain markup inside `AdminLayout`. It was struck in
Phase 2 PR 7 rather than satisfied by inventing a core component with no consumer until Phase 3;
adding it later costs a minor bump, which is the case this versioning exists for.
The kit is those **eight exports** — five rows, because `PageState` contributes three. An earlier
draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are
plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a
core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case
this versioning exists for.
#### `PublicLayout` gives you the chrome; `shell` gives you the body
`PublicLayout` renders the header, the footer and the `.page` flex column. It does **not**, by
default, render the body wrapper that every core public page writes for itself:
```jsx
<PublicLayout shell="narrow"> // 'narrow' (760px) · 'mid' (880px) · 'wide' (1280px)
<PageHeader title="Our servers" />
</PublicLayout>
```
Without a `shell`, content renders full-bleed from x=0 with no vertical padding, and the footer rides
up underneath it instead of sitting at the bottom of the viewport. That last part is the one worth
knowing, because it looks like a CSS bug in the module rather than a missing wrapper: the footer is
pushed down by `flex: 1` on the body element, so a page with no body element has nothing pushing it.
**Name a width, never a class.** The classes those widths map to are `theme.css`'s and are *not*
contract — the theming workstream owns that file and must stay free to rename them. A module that
hardcoded `className="shell-narrow page-body"` would look right today and break silently on a rename,
with nothing failing anywhere. An unrecognised width falls back to `narrow` rather than to no
wrapper at all, because a page at the wrong width still looks like the site and a page with no
wrapper does not.
This exists because the Integration Kit's acceptance run
([`../modules/kit-acceptance.md`](../modules/kit-acceptance.md)) proved a module author cannot
discover the wrapper: the kit hands them `PublicLayout` and describes it as the public chrome, which
is true and was not enough. Core's own pages keep writing their wrapper by hand and are unaffected.
### 3.5 `api` — the request primitive