feat(modules): client extension slots (phase 3, slice 2) #138

Merged
whitlocktech merged 2 commits from feature/module-client-slots into edge 2026-08-11 21:53:44 +00:00
Member

Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1 — core only, and the one slice that adds rather than moves. No UO code leaves core here. Docs: docs#137.

Why this slice exists

Two problems in the client half turned out to be the same missing mechanism:

  • UserDetail.jsx renders six UO sections with nowhere to put them. The server half got declareSlot/registerExtension in Phase 2 PR 4 and the six /admin/users/:id/shard/* routes moved behind it. The client never got the twin — registry takes routes, nav and feature providers, and nothing else.
  • SiteFooter links to /site/shard, a URL the extraction deletes, and the footer is not nav so no registry answers for it.

So core declares a slot, at most one module fills it, and core renders <Slot> — nothing when unfilled. MODULE_API_VERSION 1.1.0 → 1.2.0; contract is MODULE_API.md §3.7.

The two decisions

A slot is named for a PLACE, never for a meaning. The footer slot began as "make shard status a hook any module can use", 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 belong to the module. A slot typed by its content puts game semantics back into core, which is the thing this phase removes — and the next module wanting that spot for something else would need a second mechanism.

This slice inverts the phase's merge order, once. Everywhere else module-uo merges before website. Here core must go first, because a chunk cannot call registry.registerExtension before the function exists. Harmless precisely because this slice only adds: core declares both slots and fills them with its own components under owner id core — the same trick useShardFlags and the server's registries.registerCore() already use — so the rendered pages are unchanged and the mechanism is proved by core's own content before a line of it moves.

Where it is not fail-open

An unknown slot, a non-component and a second fill all throw, matching checkExtensionShape server-side. A dropped nav row costs a link the viewer can reach another way; a silently dropped extension is invisible to everyone including its author. And a throw is always a programming error and never a race — core declares in its own bundle, every module chunk is a deferred script injected after it.

Reading stays fail-safe (undeclared and unfilled both read null), and a filling component renders inside an error boundary — core's only one. That asymmetry 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 that page is that it stays usable.

What the browser smoke found

§7.7, with a throwaway hand-written chunk filling both slots. The footer rendered the module's own label and target in core's linkStyle, the admin page received userId, a deliberate render failure was contained to its own spot with the slot named in the console — and the footer was left showing email · · Admin.

The separator was rendered beside the slot, guarded on hasExtension. That is right when nothing is installed and wrong when something is installed and fails: the slot is filled, so the separator renders, and the component then throws into the boundary leaving the separator behind. Core now decorates through <Slot wrap>, inside the boundary, so the decoration shares the extension's fate — and hasExtension is deleted rather than left as a trap for the next caller. Every unit test passed both before and after.

Two more things the smoke settled, neither a defect:

  • Core's own fill occupies the slot, so a module cannot fill it — first fill wins, core registers first, and the smoke module's call was rejected naming core. Correct and temporary (the client half deletes core's fills in the change that registers the module's), now stated in the contract.
  • Core's UO sections on the user-detail page fail to load — that is slice 1 removing the routes, not this slice. Identical before and after.

Zero CSP reports throughout.

Verification

  • 616 server + 169 client tests (+9), all passing
  • routes.manifest.json 158 public + 2 internal, unchanged; routes.guards.json unchanged; swagger-output.json regenerates byte-identical
  • Client build clean
  • Browser smoke as above: module fill, prop delivery, failure containment, core's fills restored, and no module installed

AI disclosure

Written with Claude Code.

Co-Authored-By: Claude noreply@anthropic.com

Phase 3, slice 2 of `docs/website/MODULE_SYSTEM.md` §2.7.1 — **core only, and the one slice that adds rather than moves.** No UO code leaves core here. Docs: docs#137. ## Why this slice exists Two problems in the client half turned out to be the same missing mechanism: - **`UserDetail.jsx` renders six UO sections with nowhere to put them.** The server half got `declareSlot`/`registerExtension` in Phase 2 PR 4 and the six `/admin/users/:id/shard/*` routes moved behind it. The client never got the twin — `registry` takes routes, nav and feature providers, and nothing else. - **`SiteFooter` links to `/site/shard`**, a URL the extraction deletes, and the footer is not nav so no registry answers for it. So core declares a slot, at most one module fills it, and core renders `<Slot>` — nothing when unfilled. `MODULE_API_VERSION` 1.1.0 → **1.2.0**; contract is MODULE_API.md §3.7. ## The two decisions **A slot is named for a PLACE, never for a meaning.** The footer slot began as "make shard status a hook any module can use", 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 belong to the module. A slot typed by its content puts game semantics back into core, which is the thing this phase removes — and the next module wanting that spot for something else would need a second mechanism. **This slice inverts the phase's merge order, once.** Everywhere else `module-uo` merges before `website`. Here core must go first, because a chunk cannot call `registry.registerExtension` before the function exists. Harmless precisely because this slice only adds: core declares both slots and fills them with its own components under owner id `core` — the same trick `useShardFlags` and the server's `registries.registerCore()` already use — so the rendered pages are unchanged and the mechanism is proved by core's own content before a line of it moves. ## Where it is not fail-open An unknown slot, a non-component and a second fill all **throw**, matching `checkExtensionShape` server-side. A dropped nav row costs a link the viewer can reach another way; a silently dropped extension is invisible to everyone including its author. And a throw is always a programming error and never a race — core declares in its own bundle, every module chunk is a deferred script injected after it. Reading stays fail-safe (undeclared and unfilled both read null), and a filling component renders inside an **error boundary** — core's only one. That asymmetry 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 that page is that it stays usable. ## What the browser smoke found §7.7, with a throwaway hand-written chunk filling both slots. The footer rendered the module's own label and target in core's `linkStyle`, the admin page received `userId`, a deliberate render failure was contained to its own spot with the slot named in the console — **and the footer was left showing `email · · Admin`.** The separator was rendered beside the slot, guarded on `hasExtension`. That is right when nothing is installed and wrong when something is installed and fails: the slot *is* filled, so the separator renders, and the component then throws into the boundary leaving the separator behind. Core now decorates through `<Slot wrap>`, inside the boundary, so the decoration shares the extension's fate — and `hasExtension` is deleted rather than left as a trap for the next caller. **Every unit test passed both before and after.** Two more things the smoke settled, neither a defect: - **Core's own fill occupies the slot, so a module cannot fill it** — first fill wins, core registers first, and the smoke module's call was rejected naming `core`. Correct and temporary (the client half deletes core's fills in the change that registers the module's), now stated in the contract. - **Core's UO sections on the user-detail page fail to load** — that is slice 1 removing the routes, not this slice. Identical before and after. Zero CSP reports throughout. ## Verification - **616 server + 169 client tests** (+9), all passing - `routes.manifest.json` **158 public + 2 internal, unchanged**; `routes.guards.json` unchanged; `swagger-output.json` regenerates **byte-identical** - Client build clean - Browser smoke as above: module fill, prop delivery, failure containment, core's fills restored, and no module installed ## AI disclosure Written with Claude Code. Co-Authored-By: Claude <noreply@anthropic.com>
wtclaude added 2 commits 2026-08-11 21:46:59 +00:00
The client twin of the server's declareSlot/registerExtension, and the same rule
in both halves: core declares a slot, only core declares one, and at most one
module fills it. Core renders <Slot name> and gets nothing back when the slot is
unfilled, so an instance with no module installed renders exactly what it
rendered before -- the same untouched-path guarantee withModuleNav makes.

A slot is named for a PLACE, never for a meaning. Core supplies the position and
the styling; the label, the target, the data and whether anything renders at all
are the module's. The moment core types a slot by its content it has re-acquired
the game semantics phase 3 exists to remove.

This is the one place the client registry is not fail-open. An unknown slot, a
non-component and a second fill all throw, matching checkExtensionShape
server-side, because a dropped nav row costs a link the viewer can reach another
way while a silently dropped extension is invisible to everyone including its
author. A throw is always a programming error and never a race: core declares in
its own bundle and every module chunk is a deferred script injected after it.

Reading stays fail-safe -- undeclared and unfilled both read null -- and a
filling component renders inside an error boundary. That asymmetry 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 that page is that it stays usable.

Core decorates a slot through <Slot wrap>, not by asking whether it is filled.
The obvious alternative is right about the unfilled case and wrong about the
failed one -- the extension is filled, so the separator renders, and then the
component throws into the boundary and leaves the separator behind on its own.
wrap puts core's decoration inside the boundary where it shares the extension's
fate. Found in a browser, with the footer's separator, which is the only place
either could have been found.

MODULE_API_VERSION 1.1.0 -> 1.2.0, both halves: the two state ONE version.
Contract: docs/website/MODULE_API.md 3.7.

Co-Authored-By: Claude <noreply@anthropic.com>
refactor(modules): move core's UO page content behind the two slots
All checks were successful
PR Checks / client-build (pull_request) Successful in 26s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / bot-install (pull_request) Successful in 8m46s
d667565ae7
Core declares site.footer.status and admin.users.detail in main.jsx and fills
both itself, under owner id `core` -- the client twin of registries.registerCore()
and the same trick useShardFlags already uses. The rendered page is unchanged;
what changes is that the content now arrives the way a module's will.

The footer's Shard Status link becomes ShardStatusLink.jsx, and UserDetail's six
UO sections become UserShardSections.jsx. Both are files rather than inline
markup so that the client half of phase 3 deletes a registration and a file
instead of editing a core page under extraction pressure -- which is also what
proves the mechanism before anything depends on it.

The user-detail slot is handed userId and not scope. api.admin.userShard is a UO
binding that leaves core with the client half, so a slot passing it would hand a
module something core is about to delete; an extension builds its own client for
the routes it registered at the other end. Core's own fill now does exactly what
the module will.

Verified in a browser against a real chunk (MODULE_API.md 7.7): a throwaway
module fills both slots and renders its own label and target in the footer with
core's linkStyle, and receives userId on the admin page; a deliberate render
failure is contained to that one spot with the slot named in the console; core's
own fills leave the pages byte-identical to before; and with no module installed
both slots render nothing. Zero CSP reports throughout.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 91964c8898 into edge 2026-08-11 21:53:44 +00:00
whitlocktech deleted branch feature/module-client-slots 2026-08-11 21:53:45 +00:00
Sign in to join this conversation.
No description provided.