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

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>
This commit is contained in:
2026-08-11 16:45:22 -05:00
parent 1d1350558b
commit d667565ae7
5 changed files with 247 additions and 140 deletions

View File

@@ -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 (
<Link to="/site/shard" style={linkStyle}>
Shard Status
</Link>
)
}

View File

@@ -1,5 +1,14 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useSite } from '../contexts/SiteContext.jsx' 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() { export default function SiteFooter() {
const { contactEmail, siteTitle } = useSite() const { contactEmail, siteTitle } = useSite()
@@ -36,10 +45,14 @@ export default function SiteFooter() {
<a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}> <a href={`mailto:${contactEmail}`} style={{ color: 'var(--accent)', textDecoration: 'none' }}>
{contactEmail} {contactEmail}
</a> </a>
&nbsp;·&nbsp; {/* A module's spot in the footer, and core supplies only the
<Link to="/site/shard" style={{ color: 'var(--accent)', textDecoration: 'none' }}> position and the styling: the label, the target and whether
Shard Status anything renders at all are the module's (MODULE_API.md §3.7).
</Link> 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. */}
<Slot name={FOOTER_SLOT} linkStyle={LINK_STYLE} wrap={(link) => <>&nbsp;·&nbsp;{link}</>} />
&nbsp;·&nbsp; &nbsp;·&nbsp;
<Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}> <Link to="/admin/login" style={{ color: '#5d6b7d', textDecoration: 'none' }}>
Admin Admin

View File

@@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js' 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 { useShardFlags } from './lib/useShardFeatures.js'
import ShardStatusLink from './components/ShardStatusLink.jsx'
import UserShardSections from './routes/admin/views/UserShardSections.jsx'
import './styles/theme.css' import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates. // 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. // for them by the name they will always have had.
registerFeatureProvider('core', 'uo', useShardFlags) 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 // Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes. // of core's boot the module system changes.
// //

View File

@@ -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 { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx' import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js' 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 { api } from '../../../api/client.js'
import CharacterStats from '../../../components/CharacterStats.jsx' import Slot from '../../../modules/Slot.jsx'
import GameAccounts from '../../../components/GameAccounts.jsx'
import VendorSales from '../../../components/VendorSales.jsx'
// Admin read-only view of one user's shard (uo-link) footprint: linked game // Admin view of one user: who they are, their security posture (trusted devices
// accounts + character rosters, currently-online characters, houses (incl. // and MFA), and then whatever the installed module contributes about them —
// IDOC) and recent vendor sales — everything scoped to that user's accounts. // today core's own UO footprint, via the `admin.users.detail` extension slot
// Reached from the Users table's "View" action; Edit stays a separate modal. // (MODULE_API.md §3.7). Reached from the Users table's "View" action; Edit stays
// a separate modal.
const ROLE_BADGE = { const ROLE_BADGE = {
admin: 'badge-admin', 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 (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
// Admin security controls for one user: their trusted devices (view + revoke) and // 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. // an MFA reset for a locked-out user. Every action is audit-logged server-side.
function SecurityAdmin({ userId }) { function SecurityAdmin({ userId }) {
@@ -244,25 +135,8 @@ function SecurityAdmin({ userId }) {
) )
} }
function ShardSections({ scope }) {
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}
export default function UserDetail() { export default function UserDetail() {
const { id } = useParams() 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]) const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
if (loading) return <Loading /> if (loading) return <Loading />
@@ -296,7 +170,10 @@ export default function UserDetail() {
</div> </div>
<SecurityAdmin userId={id} /> <SecurityAdmin userId={id} />
<ShardSections scope={scope} /> {/* 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. */}
<Slot name="admin.users.detail" userId={id} />
</section> </section>
) )
} }

View File

@@ -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 (
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
{children}
</div>
)
}
// 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 (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Online now</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((c) => (
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
</span>
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
</span>
</li>
))}
</ul>
)}
</section>
)
}
// Shard "standing": city governorships held and guilds led by this user's
// accounts (both reliable current-state lookups). Renders nothing when empty.
function Standing({ scope }) {
const { data } = useAsync(() => scope.standing(), [scope])
if (!data) return null
const govs = data.governorOf || []
const guilds = data.guildsLed || []
if (govs.length === 0 && guilds.length === 0) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Standing</SectionTitle>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{govs.map((g) => (
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
Governor of {g.city}
</span>
))}
{guilds.map((g) => (
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
</span>
))}
</div>
</section>
)
}
// One house row — the many optional detail fields are gathered here so the
// Houses list stays a simple map.
function HouseRow({ house: h }) {
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
return (
<li
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
>
<div style={{ minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
{h.name || 'Unnamed house'}
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
</div>
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
{location}
{coords}
{owner}
{shares}
</div>
</div>
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
</div>
</li>
)
}
// Houses owned by the user's accounts, IDOC first (flagged).
function Houses({ scope }) {
const { data } = useAsync(() => scope.houses(), [scope])
if (!data) return null
return (
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
<SectionTitle>Houses</SectionTitle>
{data.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this users accounts.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{data.map((h) => (
<HouseRow key={h.serial} house={h} />
))}
</ul>
)}
</section>
)
}
export default function UserShardSections({ userId }) {
// Memoized so the child components' effects (keyed on `scope`) don't refetch
// on every render — the same reason UserDetail memoized it before this moved.
const scope = useMemo(() => api.admin.userShard(userId), [userId])
return (
<>
<CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} />
<OnlineNow scope={scope} />
<Houses scope={scope} />
<VendorSales fetchSales={scope.sales} />
</>
)
}