docs(website): client extension slots, and the client half is one slice

Two findings from surveying the client half, and one org-lead decision that
turns them into the same piece of work.

The client half does not divide into public and admin after all. Routes slice by
area, but useShardFeed and shardEvents are imported by eight public pages AND
three admin views, and a shared leaf moves with its last consumer -- the same
rule the server half taught. Of the three ways out, two trade a real cost for a
boundary that lasts one review, so the client half is one slice. The nav rows
keep their feature provider as a consequence (rows without it fail open, so
disabled surfaces would advertise themselves again), and VendorSales turns out
to have no public consumer at all.

Its three consumers include core's own UserDetail, which renders six UO sections
with nowhere to put them: the server got declareSlot/registerExtension in phase
2 PR 4 and the client never got the twin. SiteFooter's link to a URL the
extraction deletes is the same gap one component over.

So slice 2 is core-only and adds rather than moves: client extension slots
(API 3.7), the site.footer.status and admin.users.detail slots, core filling
both itself. MODULE_API_VERSION goes to 1.2.0.

A slot is named for a PLACE, never for a meaning -- core supplies the position
and the styling, the module owns the label, the target, the data and whether it
renders at all. Typing a slot by its content would put game semantics back into
core, which is what this phase removes.

This is also the one slice where core merges before module-uo, because a chunk
cannot call registerExtension before the function exists. Harmless here: the
slice only adds, and core fills both slots with its own components under owner
id `core`, so the page is unchanged and the mechanism is proved before anything
moves.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 16:31:59 -05:00
parent f3a6231084
commit 262c86ad4f
2 changed files with 133 additions and 4 deletions

View File

@@ -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 `<Slot name="…" {...props} />`, 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