refactor(teams)!: Teams is a contract, not a surface — invert the slots
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 35s

Org lead's correction, and it changes what this phase ships.

TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.

Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.

Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.

**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.

Three things about the inverted direction are load-bearing:

  - the name is namespaced under the declaring module and that is enforced, not
    conventional: it is the only thing keeping two modules off one name;
  - core's fills are applied at MOUNT rather than eagerly. Core's bundle
    evaluates before every module chunk, so when core registers a fill the slot
    does not exist yet — filling eagerly would silently do nothing;
  - a fill for a slot nobody declared is a no-op, never an error. The declaring
    module is simply not installed, which is the ordinary case. That is the
    opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
    there, core declares first, so an unknown name is always a typo.

`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.

`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:58:07 -05:00
parent 203ce9c654
commit 5d9d10b245
25 changed files with 585 additions and 854 deletions

View File

@@ -3,8 +3,8 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import { declareSlot, registerFeatureProvider } from './modules/registry.js'
import { useCoreFlags } from './modules/coreFeatures.js'
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
@@ -19,12 +19,6 @@ publishSharedDependencies()
// and namespace `uo`, so that the seam was exercised by real content from the
// day it was built. That prediction paid out exactly as written: the extraction
// deleted the registration and the hook it named, and SiteHeader was not touched.
//
// Teams put a core row back on the seam. `feature: 'teams'` on the three Team nav
// rows resolves against owner id `core` (featureGate.js: a row with no `moduleId`
// belongs to core), and this is the provider that answers it — hiding the rows on
// a deployment that has no Teams at all, and failing open everywhere else.
registerFeatureProvider('core', 'core', useCoreFlags)
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
//
@@ -55,24 +49,32 @@ declareSlot('admin.users.detail')
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
// which is what core's own code did whenever the flag was off.
declareSlot('player.invite.accepted')
// The two Team slots (TEAMS.md §3.4, MODULE_API 1.6.0). Both named for a place:
// `team.overview` is the spot under the counts on a Team page, not "where the
// game puts guild stats", and `team.member.row` is the trailing cell of a roster
// row. Core renders the whole Team experience with both unfilled — the pages are
// core's, and a module adds to them rather than supplying them.
//
// `team.overview` is where a live "online now" strip belongs: core's online count
// is the durable floor refreshed at the reconcile interval (§3.3), and a module
// that already holds a live presence feed can render the current number here
// without core acquiring an SSE stack to do it.
declareSlot('team.overview')
declareSlot('team.member.row')
// Core filled the first two itself until slice 3, with the components that were
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
// all three, and core's own fills had to go for it to be able to — the first
// fill wins, and core registered first (§3.7).
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
//
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
// tables, the sync, the access rules and the activity feed; it does not own the
// word for one — a UO shard says guild, and the module that comes after it will
// say clan. So core publishes no Team page and no Team nav row, and the module
// that owns the vocabulary owns the page.
//
// The activity feed is the one piece of that page core cannot hand over: only
// core can resolve whether this viewer is inside the Team, and the public/members
// split is a security boundary. So the module declares the place and core fills
// it. Registered here, applied at mount — `applyCoreFills` runs after every
// module chunk has evaluated, which is the only moment a module-declared slot
// exists to be filled.
//
// Naming a slot no installed module declares is not an error. On a deployment
// with no game module this fill simply never lands, which is the mirror of an
// unfilled slot rendering nothing.
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
@@ -99,6 +101,10 @@ declareSlot('team.member.row')
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
// Every module chunk has evaluated by now, so any slot a module declared is
// present and core's pending fills can land. Must happen before the first
// render: `extensionFor` is read during render and there is no subscription.
applyCoreFills()
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>