From 262c86ad4f3ee3d12eda6c4f42ac517f93834ade Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 16:31:59 -0500 Subject: [PATCH] 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 --- website/MODULE_API.md | 75 +++++++++++++++++++++++++++++++++++++++- website/MODULE_SYSTEM.md | 62 +++++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 4 deletions(-) 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.