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.