diff --git a/website/MODULE_API.md b/website/MODULE_API.md
index 6d305bd..259364f 100644
--- a/website/MODULE_API.md
+++ b/website/MODULE_API.md
@@ -26,13 +26,17 @@ 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.1.0'
+const MODULE_API_VERSION = '1.2.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.2.0 — Phase 3, the client half.** `registry` gained `registerExtension` and core gained client
+extension slots (§3.7). An addition only, and the first change to `window.__rg` since 1.0.0 — the
+server half is untouched, and both files bump because the two halves state ONE version.
+
**1.1.0 — Phase 3 slice 1.** `ctx` gained `activity.log`, `users.getById`, `site.baseUrl`, and
`middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`.
Additions only. Each exists because module-uo's extraction needed it and none could be vendored — an
@@ -540,6 +544,7 @@ on a mismatch, logging once — the client-side twin of §1.1, and the reason `v
registry.registerRoutes(id, { public: [...], admin: [...], player: [...] })
registry.registerNav(id, { area, items })
registry.registerFeatureProvider(id, namespace, hook)
+registry.registerExtension(id, slot, Component) // 1.2.0 — §3.7
```
**`registerRoutes`** — arrays of `{ path, element, gate? }`. Paths are relative to the module's
@@ -762,6 +767,74 @@ decided here.
approach; slice 0 proved the configuration, in a browser, under the enforced `script-src 'self'`,
by checking each imported binding is **identity-equal** to the one core published.
+### 3.7 Extension slots — module content inside a core page
+
+Added in **1.2.0** (settled 2026-08-11). The client twin of §2.4's `registerExtension`, and the same
+rule in both halves: **core declares a slot, only core may declare one, and at most one module may
+fill it.**
+
+```js
+registry.registerExtension(id, slot, Component)
+```
+
+Core renders a slot with ``, which renders the filling component with
+those props — or **nothing at all** when the slot is unfilled. An instance with no module installed
+therefore renders exactly what it renders today, which is the same "untouched path" guarantee
+`withModuleNav` makes for nav.
+
+The slots in 1.2.0:
+
+| Slot | Rendered in | Props core passes |
+| --- | --- | --- |
+| `site.footer.status` | `components/SiteFooter.jsx`, in the info row | `linkStyle` — the row's own link styling |
+| `admin.users.detail` | `routes/admin/views/UserDetail.jsx`, below the security panel | `userId` |
+
+**A slot is named for a PLACE, never for a meaning.** `site.footer.status` is "the status-ish spot in
+the footer", not a declaration that core knows what a game server's status is: core supplies the
+position and the styling, and the module owns the label, the target, the data and whether it renders
+anything at all. This is the whole point of the mechanism in a game-agnostic core — the moment core
+types a slot by its content, it has re-acquired the semantics Phase 3 exists to remove, and the next
+module wanting that spot for something else needs a second mechanism.
+
+**`admin.users.detail` is deliberately the same name as the server slot** (§2.4). One resource, one
+extension point, two halves — a module that adds routes under `/api/v1/admin/users/:id` is the module
+that has something to show on that page, and giving the two halves one name means an operator reading
+either side sees the same word.
+
+`userId` is the only prop that slot gets, and not `scope`: core's `api.admin.userShard(id)` is a UO
+binding that leaves core in the client half of Phase 3, so a slot that handed it over would hand over
+something core is about to delete. The extension builds its own client for the routes it registered
+at the other end, which is §3.5's rule applied to a slot.
+
+**Errors are contained.** Core renders a filled slot inside an error boundary: a component that
+throws costs its own section and a console error, never core's page. That asymmetry is deliberate and
+is where the client differs from the server — a module route that throws costs the module's own page,
+but an extension throws *inside* core's, and the whole reason core keeps ownership of the page is
+that it stays usable.
+
+**Malformed registrations throw, and that matches the server.** An unknown slot name, a non-function
+component, or a second module filling a filled slot all throw at the call, exactly as
+`checkExtensionShape` does server-side. This is the one place the client registry is not fail-open:
+a dropped nav row costs a link the user can reach another way, while a silently dropped extension is
+invisible to everyone including its author, and the ordering guarantee below means a throw here is
+always a programming error and never a race.
+
+**Declaration always precedes filling, structurally.** Core declares its slots in `main.jsx`, which
+runs in core's own bundle; module chunks are deferred scripts injected after it (§3.1). So a module
+can never fill a slot that has not been declared yet, and "unknown slot" always means a typo or a
+version skew rather than a load-order accident.
+
+**Core fills its own slots through the same seam** — `registerExtension('core', …)` in `main.jsx`,
+the client twin of the server's `registries.registerCore()` and the same trick `useShardFlags`
+already uses for the feature seam. It means the mechanism is exercised by core's own content from the
+moment it lands, and the extraction becomes a deletion rather than a rewrite made under extraction
+pressure.
+
+`declareSlot` and `hasExtension` are module exports of `modules/registry.js` and deliberately **not**
+members of the `registry` object handed to modules, for the same reason `featureProviders()` is not:
+declaring is core's, and a module asks about a slot it knows the name of rather than enumerating what
+everyone else filled.
+
---
## Part 4 — The loader's obligations
diff --git a/website/MODULE_SYSTEM.md b/website/MODULE_SYSTEM.md
index 4d5563e..85b4e29 100644
--- a/website/MODULE_SYSTEM.md
+++ b/website/MODULE_SYSTEM.md
@@ -779,9 +779,9 @@ Each slice is one `module-uo` PR (adds), one `website` PR (deletes), and one `do
| --- | --- | --- |
| 0 | **The bundle skeleton** | `module.json`, both `package.json`s, `server/index.js` registering nothing, the Vite library build + the four shared-dep shims, CI armed, the §5.1 zero-internal-imports check. `website` untouched. |
| 1 | **The whole server half** | 40 files / ~9,674 lines, 25 of core's 82 test files, 27 of its 68 tables — every UO model, util, router and controller, `config/shardStreams.js`, `scripts/importSpawnAtlas.js` and the art JSON. **One merge, five commits** (below). |
-| 2 | **Public pages** | `Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`, `PlayersOnline`, `VendorSales`, `data/cityCrests.js`, `lib/shardEvents.js`, `lib/useShardFeed.js`, and their public nav rows — under `/uo/*` per §2.8 |
-| 3 | **Admin + player pages** | `ShardAdmin`, `ShardOps`, `ShardVisibility`, `SpawnAtlas`, `HousesAdmin`, `AdminCharacter(s)`, `PlayerCharacter(s)`, `GameAccounts`, `CharacterSheet`, `CharacterStats`, `ShardAccountActions`, `CreateGameAccountForm`, `useShardFeatures` and the `useShardFlags` feature provider — under `/admin/uo/*` and `/player/uo/*` |
-| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `SiteFooter`, `heroLayout`'s defaults, `api/client.js`'s `shard`/`atlas` namespaces, and the comments in `navOverrides.js` — plus the §5.2 CI grep that keeps them out |
+| 2 | **Client extension slots** | Core only, and the one slice that adds rather than moves: the client twin of `declareSlot`/`registerExtension` (API §3.7), the `site.footer.status` and `admin.users.detail` slots, and core filling both itself. `module-uo` untouched. |
+| 3 | **The whole client half** | 51 files / ~3,700 lines — all twelve public pages (`Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`) under `/uo/*`, every admin and player view under `/admin/uo/*` and `/player/uo/*`, `PlayersOnline`, `VendorSales`, `CharacterStats`, `GameAccounts`, the `data/` and `lib/` UO leaves, the public nav rows, the feature provider, and both slot fills. |
+| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `SiteFooter`'s prose, `heroLayout`'s defaults, `api/client.js`'s `shard`/`atlas` namespaces, and the comments in `navOverrides.js` — plus the §5.2 CI grep that keeps them out |
| 5 | **Close the phase** | `module-uo`'s frozen route manifest and release workflow; `docs/modules/uo/` and `docs/modules/rust-dryrun.md` |
##### Why the server half cannot be sliced — found 2026-08-11, before writing any of it
@@ -826,6 +826,62 @@ branches, and commits give most of the same reading order for none of it.
*area*, with no prefix atomicity and no shared models — which is the same asymmetry that let the two
halves be separated in the first place.
+##### Why the client half is one slice after all — settled 2026-08-11
+
+The paragraph above is right about the *mechanism* and wrong about the outcome. Routes do slice by
+area, but the files behind them do not divide along that line, and the reason is the same rule the
+server half taught: **a shared leaf moves with its last consumer.**
+
+`lib/useShardFeed.js` and `lib/shardEvents.js` are imported by eight of the public pages *and* by
+three admin views. Splitting public from admin means the module needs them one slice before core is
+finished with them, and there are only three ways out — the module vendors a copy for one slice,
+public keeps only the four pages that never touch the live feed, or the two slices become one. The
+first two both trade a real cost for a boundary that lasts one review, so the client half is **one
+slice**: all twelve public pages, every admin and player view, and the leaves under them, in one
+`module-uo` PR and one `website` PR.
+
+Two things that were separately tabled fold into it as a consequence, and both are improvements:
+
+- **The nav rows and their feature provider stay one unit.** The nine UO rows in the public header
+ carry `feature` gates resolved by `useShardFlags`, which core registers under namespace `uo`.
+ Since resolution is by the *registering* module (§1.5), rows that move without their provider
+ resolve against a namespace nothing answers for — and everything fails open, so nine rows an
+ operator may have disabled or gated to staff would advertise themselves again for the length of a
+ slice. `useShardFeatures.js` already says as much in its own closing comment.
+- **`VendorSales` was tabled with the public pages and has no public consumer at all.** Its three are
+ `AdminCharacters`, `PlayerCharacters` and core's own `UserDetail` — which is the next finding.
+
+##### Core's user-detail page needs a client extension slot — and so does the footer
+
+`UserDetail.jsx` renders a core header, core's `SecurityAdmin`, and then six UO sections. The server
+half already had somewhere to put that: Phase 2 PR 4 declared the `admin.users.detail` slot and moved
+the six `/admin/users/:id/shard/*` routes behind it. **The client never got the twin** — `registry`
+takes routes, nav and feature providers, and nothing else — so the client half had nowhere to put the
+same page's other half.
+
+The same gap shows up one component over. `SiteFooter` links to `/site/shard`, a URL the extraction
+deletes, and the footer is not nav so no registry answers for it.
+
+Both are the same missing mechanism, and it is **slice 2**, core-only: core declares a slot, at most
+one module fills it, and core renders `` — nothing when unfilled. The contract is
+[API §3.7](MODULE_API.md#37-extension-slots--module-content-inside-a-core-page); `MODULE_API_VERSION`
+goes to **1.2.0**.
+
+Two decisions inside it, both settled with the org lead 2026-08-11:
+
+- **The footer slot is named for a place, not for a meaning.** The idea started as "make shard status
+ a hook any module can use", which is right, and the only refinement is that core must not learn
+ what a game server's status *is*. `site.footer.status` is a position and a bit of styling; the
+ label, the target, the data and whether anything renders at all are the module's. A slot typed by
+ its content would put game semantics back in core, which is the thing this phase removes.
+- **This slice inverts the phase's merge order, once.** Everywhere else `module-uo` merges before
+ `website` so `edge` is never missing a feature. Here core must go first, because a module chunk
+ cannot call `registry.registerExtension` before the function exists. That is harmless precisely
+ because this slice only *adds*: core declares both slots and fills them with its own existing
+ components under owner id `core` — the same trick `useShardFlags` and the server's
+ `registries.registerCore()` already use — so the rendered page is unchanged and the mechanism is
+ proved by core's own content before a line of it moves.
+
**Criterion 1 is a grep over code, not over prose** — see [API §5.2](MODULE_API.md#52-zero-uo-identifiers-in-core-ci-website-repo)
for what that means precisely. Core's marketing copy says "shard" in a dozen places, and a literal
word grep would have made every one of them a CI failure while proving nothing about the boundary.