diff --git a/client/src/components/ShardStatusLink.jsx b/client/src/components/ShardStatusLink.jsx
new file mode 100644
index 0000000..ffaea4c
--- /dev/null
+++ b/client/src/components/ShardStatusLink.jsx
@@ -0,0 +1,24 @@
+// ── Core's fill for the `site.footer.status` extension slot ────────────────
+//
+// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
+// MODULE_API.md §3.7.
+//
+// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
+// it is a file now for one reason: `/site/shard` is a UO page, so the link goes
+// when the client half goes, and core should be deleting a registration rather
+// than editing its footer under extraction pressure.
+//
+// Note what core kept and what it handed over. Core owns the position in the row
+// and the separator around it, and passes `linkStyle` so the row stays visually
+// one row. The label, the destination, and the decision to render at all are
+// this file's — which is exactly the division a module inherits.
+
+import { Link } from 'react-router-dom'
+
+export default function ShardStatusLink({ linkStyle }) {
+ return (
+
+ Shard Status
+
+ )
+}
diff --git a/client/src/components/SiteFooter.jsx b/client/src/components/SiteFooter.jsx
index 5b94977..66f65b0 100644
--- a/client/src/components/SiteFooter.jsx
+++ b/client/src/components/SiteFooter.jsx
@@ -1,5 +1,14 @@
import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx'
+import Slot from '../modules/Slot.jsx'
+
+const FOOTER_SLOT = 'site.footer.status'
+
+// Handed to the extension rather than left for it to guess. A module rendering
+// its own link in this row should look like the row, and the alternative is
+// every module restating core's colours and then drifting from them the next
+// time this footer is themed.
+const LINK_STYLE = { color: 'var(--accent)', textDecoration: 'none' }
export default function SiteFooter() {
const { contactEmail, siteTitle } = useSite()
@@ -36,10 +45,14 @@ export default function SiteFooter() {
{contactEmail}
- ·
-
- Shard Status
-
+ {/* A module's spot in the footer, and core supplies only the
+ position and the styling: the label, the target and whether
+ anything renders at all are the module's (MODULE_API.md §3.7).
+ The separator goes through `wrap` rather than sitting beside the
+ slot, so it shares the extension's fate — no module installed and
+ a module whose link throws both render nothing here, rather than
+ the second leaving a stray middot behind. */}
+ <> · {link}>} />
·
Admin
diff --git a/client/src/main.jsx b/client/src/main.jsx
index de0fa13..f1c81fa 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
-import { registerFeatureProvider } from './modules/registry.js'
+import { declareSlot, registerExtension, registerFeatureProvider } from './modules/registry.js'
import { useShardFlags } from './lib/useShardFeatures.js'
+import ShardStatusLink from './components/ShardStatusLink.jsx'
+import UserShardSections from './routes/admin/views/UserShardSections.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -28,6 +30,34 @@ publishSharedDependencies()
// for them by the name they will always have had.
registerFeatureProvider('core', 'uo', useShardFlags)
+// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
+//
+// Declared HERE, in core's own bundle, which is what makes the ordering a fact
+// rather than a hope: module chunks are deferred scripts the shell injects after
+// this one (§3.1), so a module can never reach registerExtension before the slot
+// it names exists. "Unknown slot" therefore always means a typo or a version
+// skew, never a load-order accident — which is why that case throws.
+//
+// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is
+// the spot in the footer's info row, not a declaration that core knows what a
+// game server's status is; the label, the target and whether anything renders at
+// all belong to whoever fills it. A slot typed by its content would put game
+// semantics back into core, which is the thing Phase 3 takes out.
+declareSlot('site.footer.status')
+// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one
+// resource, one extension point, two halves. The module with routes under
+// /api/v1/admin/users/:id is the module with something to show on that page.
+declareSlot('admin.users.detail')
+
+// And core fills both itself, under owner id `core`, with the components that
+// were inline in SiteFooter.jsx and UserDetail.jsx until this slice. The page
+// renders exactly what it rendered before, and the mechanism is exercised by
+// core's own content from the day it lands rather than first proved by the
+// change that depends on it. Slice 3 deletes these three lines and the two files
+// they name, and the module registers the same two slots on its way in.
+registerExtension('core', 'site.footer.status', ShardStatusLink)
+registerExtension('core', 'admin.users.detail', UserShardSections)
+
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx
index cd552cf..93f9cca 100644
--- a/client/src/routes/admin/views/UserDetail.jsx
+++ b/client/src/routes/admin/views/UserDetail.jsx
@@ -1,17 +1,16 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
-import { dateTime, ago } from '../../../lib/format.js'
+import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
-import CharacterStats from '../../../components/CharacterStats.jsx'
-import GameAccounts from '../../../components/GameAccounts.jsx'
-import VendorSales from '../../../components/VendorSales.jsx'
+import Slot from '../../../modules/Slot.jsx'
-// Admin read-only view of one user's shard (uo-link) footprint: linked game
-// accounts + character rosters, currently-online characters, houses (incl.
-// IDOC) and recent vendor sales — everything scoped to that user's accounts.
-// Reached from the Users table's "View" action; Edit stays a separate modal.
+// Admin view of one user: who they are, their security posture (trusted devices
+// and MFA), and then whatever the installed module contributes about them —
+// today core's own UO footprint, via the `admin.users.detail` extension slot
+// (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
+// a separate modal.
const ROLE_BADGE = {
admin: 'badge-admin',
@@ -28,114 +27,6 @@ function SectionTitle({ children }) {
)
}
-// Currently-online characters on the user's accounts, with where they are. The
-// per-character Online/Offline badge lives in the roster; this adds location.
-function OnlineNow({ scope }) {
- const { data } = useAsync(() => scope.online(), [scope])
- if (!data) return null
- return (
-
- Online now
- {data.length === 0 ? (
-
- )
-}
-
-// Houses owned by the user's accounts, IDOC first (flagged).
-function Houses({ scope }) {
- const { data } = useAsync(() => scope.houses(), [scope])
- if (!data) return null
- return (
-
- Houses
- {data.length === 0 ? (
-
No houses recorded for this user’s accounts.
- ) : (
-
- {data.map((h) => (
-
- ))}
-
- )}
-
- )
-}
-
// Admin security controls for one user: their trusted devices (view + revoke) and
// an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) {
@@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) {
)
}
-function ShardSections({ scope }) {
- return (
- <>
-
- Linked accounts & characters
- `/admin/characters/${serial}`} />
-
-
-
-
- >
- )
-}
-
export default function UserDetail() {
const { id } = useParams()
- // Memoize so the child components' effects (keyed on `scope`) don't refetch
- // on every render.
- const scope = useMemo(() => api.admin.userShard(id), [id])
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return
@@ -296,7 +170,10 @@ export default function UserDetail() {
-
+ {/* Whatever the installed module has to say about this user, or nothing
+ at all. Core's own UO sections fill it today (UserShardSections.jsx,
+ registered in main.jsx) — MODULE_API.md §3.7. */}
+
)
}
diff --git a/client/src/routes/admin/views/UserShardSections.jsx b/client/src/routes/admin/views/UserShardSections.jsx
new file mode 100644
index 0000000..aa0890b
--- /dev/null
+++ b/client/src/routes/admin/views/UserShardSections.jsx
@@ -0,0 +1,163 @@
+// ── Core's fill for the `admin.users.detail` extension slot ────────────────
+//
+// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
+// is UO, and every one of them leaves core with the client half in slice 3 —
+// this file exists so that when they do, core deletes a registration and a file
+// instead of unpicking a page.
+//
+// Core registers it through the same seam a module uses
+// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
+// server's `registries.registerCore()` and the same trick `useShardFlags`
+// already uses for the feature seam. The mechanism is therefore exercised by
+// core's own content from the day it lands, rather than first proved by the
+// change that depends on it.
+//
+// The slot hands over `userId` and nothing else — deliberately, not `scope`.
+// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
+// that passed it would be handing a module something core is about to delete.
+// An extension builds its own client for the routes it registered at the other
+// end (MODULE_API.md §3.5), and this file does exactly what the module will.
+
+import { useMemo } from 'react'
+import { useAsync } from '../../../lib/useAsync.js'
+import { ago } from '../../../lib/format.js'
+import { api } from '../../../api/client.js'
+import CharacterStats from '../../../components/CharacterStats.jsx'
+import GameAccounts from '../../../components/GameAccounts.jsx'
+import VendorSales from '../../../components/VendorSales.jsx'
+
+// Its own copy, not an export from UserDetail.jsx: six lines of presentational
+// furniture that is not in the §3.4 kit, so a module filling this slot would
+// vendor the same thing. Core's copy stays behind with core's own security
+// panel, which is the other caller.
+function SectionTitle({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// Currently-online characters on the user's accounts, with where they are. The
+// per-character Online/Offline badge lives in the roster; this adds location.
+function OnlineNow({ scope }) {
+ const { data } = useAsync(() => scope.online(), [scope])
+ if (!data) return null
+ return (
+
+ Online now
+ {data.length === 0 ? (
+