docs(website): client extension slots, and the client half is one slice (phase 3, slice 2) #137

Merged
whitlocktech merged 2 commits from docs/module-client-slots into main 2026-08-11 16:54:09 -05:00
2 changed files with 180 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`: Core exports a single integer-major semver string from `server/src/modules/version.js`:
```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 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 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. 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 **1.1.0 — Phase 3 slice 1.** `ctx` gained `activity.log`, `users.getById`, `site.baseUrl`, and
`middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`. `middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`.
Additions only. Each exists because module-uo's extraction needed it and none could be vendored — an 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.registerRoutes(id, { public: [...], admin: [...], player: [...] })
registry.registerNav(id, { area, items }) registry.registerNav(id, { area, items })
registry.registerFeatureProvider(id, namespace, hook) 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 **`registerRoutes`** — arrays of `{ path, element, gate? }`. Paths are relative to the module's
@@ -762,6 +767,92 @@ decided here.
approach; slice 0 proved the configuration, in a browser, under the enforced `script-src 'self'`, 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. 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.
**Core decorates a slot with `<Slot wrap>`, never by asking whether it is filled.** `wrap` is called
with the extension's element and rendered *inside* the boundary, so core's own markup around an
extension — a separator, a heading, a rule — shares the extension's fate:
```jsx
<Slot name="site.footer.status" linkStyle={LINK_STYLE} wrap={(link) => <>&nbsp;·&nbsp;{link}</>} />
```
There is deliberately no `hasExtension` for a layout to branch on. Branching is right about the
unfilled case and wrong about the failed one: the slot *is* filled, so the separator renders, and
the component then throws into the boundary and leaves the separator behind on its own. Found in a
browser with this exact footer separator, which is the only place it could have been found.
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.
**While core fills a slot, no module can** — first fill wins, and core registers first. In 1.2.0 that
means both slots are occupied and a module's fill is rejected, loudly, naming `core`. This is
scaffolding, not the steady state: core's two fills and the two files behind them are deleted by the
client half of Phase 3, in the same change that registers the module's. Neither slot is one core
intends to fill permanently, and a slot core *does* intend to fill is a slot that should not exist.
`declareSlot` and `extensionFor` 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 so is reading back who filled what.
--- ---
## Part 4 — The loader's obligations ## Part 4 — The loader's obligations

View File

@@ -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. | | 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). | | 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 | | 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 | **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/*` | | 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`, `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 | | 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` | | 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 ##### Why the server half cannot be sliced — found 2026-08-11, before writing any of it
@@ -826,6 +826,91 @@ 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 *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. 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 `<Slot>` — 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.
#### Slice 2 — the slots (website#138, 2026-08-11)
`modules/registry.js` grows `declareSlot` / `registerExtension` / `extensionFor`; `modules/Slot.jsx`
is the read side and core's only error boundary. `SiteFooter`'s link becomes `ShardStatusLink.jsx`
and `UserDetail`'s six UO sections become `UserShardSections.jsx`, both registered by `main.jsx`
under owner id `core`. 616 server + **169 client** tests (+9), manifest still 158, swagger
byte-identical.
**The §7.7 browser smoke earned its place again, and this time by finding something no test would
have.** A throwaway module filled both slots: the footer rendered the module's own label and target
in core's `linkStyle`, the admin page received `userId`, and a deliberate render failure was
contained to its own spot with the slot named in the console — but the footer was left showing
`email · · Admin`. The separator was rendered *beside* the slot, guarded on `hasExtension`, which is
right when nothing is installed and wrong when something is installed and fails. Core now decorates
through `<Slot wrap>`, inside the boundary, and `hasExtension` is gone rather than kept as a trap for
the next caller. Every unit test passed both before and after.
Two more things the smoke settled, neither of them a defect:
- **Core's own fill occupies the slot, so a module cannot fill it** — first fill wins and core
registers first, so the smoke module's call was rejected naming `core`. That is correct and
temporary: the client half deletes core's two fills in the change that registers the module's. It
is worth stating in the contract, because a module written against 1.2.0 today cannot use either
slot.
- **Core's UO sections on the user-detail page now fail to load, and that is slice 1's doing, not
this slice's** — core stopped serving `/admin/users/:id/shard/*` when the server half left, and
with no module installed there is nothing at the other end. Identical before and after this change;
the client half is what puts a live module behind it.
**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) **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 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. word grep would have made every one of them a CI failure while proving nothing about the boundary.