From d667565ae73052b2e90d05177234867ef098f593 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 11 Aug 2026 16:45:22 -0500 Subject: [PATCH] refactor(modules): move core's UO page content behind the two slots 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 --- client/src/components/ShardStatusLink.jsx | 24 +++ client/src/components/SiteFooter.jsx | 21 ++- client/src/main.jsx | 32 +++- client/src/routes/admin/views/UserDetail.jsx | 147 ++-------------- .../routes/admin/views/UserShardSections.jsx | 163 ++++++++++++++++++ 5 files changed, 247 insertions(+), 140 deletions(-) create mode 100644 client/src/components/ShardStatusLink.jsx create mode 100644 client/src/routes/admin/views/UserShardSections.jsx 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 ? ( -

No characters online right now.

- ) : ( -
    - {data.map((c) => ( -
  • - - - {c.name || '(unnamed)'} - - - {c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'} - -
  • - ))} -
- )} -
- ) -} - -// 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 ( -
- Standing -
- {govs.map((g) => ( - - Governor of {g.city} - - ))} - {guilds.map((g) => ( - - Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} - - ))} -
-
- ) -} - -// 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 ( -
  • -
    -
    - {h.name || 'Unnamed house'} - {h.isIdoc && IDOC} -
    -
    - {location} - {coords} - {owner} - {shares} -
    -
    -
    - {(h.decay || h.stage) ?
    {h.decay || h.stage}
    : null} - {h.price != null ?
    {Number(h.price).toLocaleString()} gp
    : null} - {h.lastRefreshed ?
    refreshed {ago(h.lastRefreshed)}
    : null} -
    -
  • - ) -} - -// 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 ? ( +

    No characters online right now.

    + ) : ( +
      + {data.map((c) => ( +
    • + + + {c.name || '(unnamed)'} + + + {c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'} + +
    • + ))} +
    + )} +
    + ) +} + +// 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 ( +
    + Standing +
    + {govs.map((g) => ( + + Governor of {g.city} + + ))} + {guilds.map((g) => ( + + Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} + + ))} +
    +
    + ) +} + +// 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 ( +
  • +
    +
    + {h.name || 'Unnamed house'} + {h.isIdoc && IDOC} +
    +
    + {location} + {coords} + {owner} + {shares} +
    +
    +
    + {(h.decay || h.stage) ?
    {h.decay || h.stage}
    : null} + {h.price != null ?
    {Number(h.price).toLocaleString()} gp
    : null} + {h.lastRefreshed ?
    refreshed {ago(h.lastRefreshed)}
    : null} +
    +
  • + ) +} + +// 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) => ( + + ))} +
    + )} +
    + ) +} +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 ( + <> + + Linked accounts & characters + `/admin/characters/${serial}`} /> + + + + + + ) +}