From 5d9d10b2450b8ded1552df76e0fc3af7d3a952a8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:58:07 -0500 Subject: [PATCH] =?UTF-8?q?refactor(teams)!:=20Teams=20is=20a=20contract,?= =?UTF-8?q?=20not=20a=20surface=20=E2=80=94=20invert=20the=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.jsx | 16 -- client/src/api/client.js | 26 ++- client/src/components/SiteHeader.jsx | 5 - client/src/lib/teamActivity.js | 100 +++++++++++ client/src/lib/teams.js | 151 ---------------- client/src/main.jsx | 46 ++--- client/src/modules/TeamActivityFeed.jsx | 96 +++++++++++ client/src/modules/coreFeatures.js | 56 ------ client/src/modules/registry.js | 66 +++++++ client/src/modules/shared.js | 8 + .../src/routes/player/PlayerPortalLayout.jsx | 8 - client/src/routes/player/PlayerTeams.jsx | 67 -------- client/src/routes/public/Team.jsx | 126 -------------- client/src/routes/public/TeamRoster.jsx | 120 ------------- client/src/routes/public/Teams.jsx | 107 ------------ client/test/moduleRegistry.test.js | 3 + client/test/moduleSlots.test.js | 76 ++++++++ client/test/teamActivity.test.js | 78 +++++++++ client/test/teams.test.js | 162 ------------------ server/routes.guards.json | 8 + server/routes.manifest.json | 4 + server/src/model/teams/teams.model.js | 19 ++ .../src/router/v1/public/teams.controller.js | 21 ++- server/src/router/v1/public/teams.router.js | 16 ++ server/swagger/swagger-output.json | 54 ++++++ 25 files changed, 585 insertions(+), 854 deletions(-) create mode 100644 client/src/lib/teamActivity.js delete mode 100644 client/src/lib/teams.js create mode 100644 client/src/modules/TeamActivityFeed.jsx delete mode 100644 client/src/modules/coreFeatures.js delete mode 100644 client/src/routes/player/PlayerTeams.jsx delete mode 100644 client/src/routes/public/Team.jsx delete mode 100644 client/src/routes/public/TeamRoster.jsx delete mode 100644 client/src/routes/public/Teams.jsx create mode 100644 client/test/teamActivity.test.js delete mode 100644 client/test/teams.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 6353cc5..1750361 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -18,9 +18,6 @@ import Newsletter from './routes/public/Newsletter.jsx' import NewsletterIssue from './routes/public/NewsletterIssue.jsx' import About from './routes/public/About.jsx' import Status from './routes/public/Status.jsx' -import Teams from './routes/public/Teams.jsx' -import Team from './routes/public/Team.jsx' -import TeamRoster from './routes/public/TeamRoster.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -60,7 +57,6 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx' import PlayerAccount from './routes/player/PlayerAccount.jsx' import PlayerAppeals from './routes/player/PlayerAppeals.jsx' -import PlayerTeams from './routes/player/PlayerTeams.jsx' export default function App() { return ( @@ -97,13 +93,6 @@ export default function App() { } /> } /> } /> - {/* Teams (TEAMS.md §3.1). Core routes, not module ones: a Team is a - core platform entity that a module merely populates, so these - render on bare core too. `/teams` is declared before `/:slug` - below for the same reason every named route is. */} - } /> - } /> - } /> {/* Installed modules' public pages, namespaced `//…` — the registry prefixes the segment, so a module cannot spell its way out of it (docs/website/MODULE_API.md §3.3). Declared before the @@ -227,11 +216,6 @@ export default function App() { } /> } /> } /> - {/* Core's own page under the /player prefix, unlike the module - pages below it. Open to any authenticated account, not role - 'player': staff are a superset of players and a moderator is in - guilds too — RequirePlayer above already draws that line. */} - } /> {/* Installed modules' player-portal pages, at /player//…. This group's own routes are absolute (its layout route has no path), so the prefix is written here rather than inherited — the one diff --git a/client/src/api/client.js b/client/src/api/client.js index bd19f4d..d35a67e 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -134,28 +134,24 @@ export const api = { }, wikiCategories: () => req('/public/wiki/categories'), - // ----- Teams (TEAMS.md §2.11, §3.1) ----- + // ----- Teams (TEAMS.md §2.11, §4.3) ----- // - // Public reads, but three of them behave differently for a signed-in caller and - // the session rides along on the cookie the shared `req` already sends: the - // roster may widen with the module's audience projection, and the activity feed - // adds members-only items. None of them REQUIRES a session. - teams: (opts = {}) => { - const qs = new URLSearchParams() - if (opts.limit != null) qs.set('limit', String(opts.limit)) - if (opts.offset != null) qs.set('offset', String(opts.offset)) - return req(`/public/teams${withQs(qs.toString())}`) - }, - team: (slug) => req(`/public/teams/${encodeURIComponent(slug)}`), - teamRoster: (slug) => req(`/public/teams/${encodeURIComponent(slug)}/members`), + // Only the two calls CORE's own client makes. Core renders no Team pages — the + // vocabulary belongs to whichever module owns the surface — so the index, the + // roster and the player list are not here; a module that renders those calls + // the same public API from its own client. + // + // The lookup exists because a module names a Team in its own terms and core + // keys the feed by slug. Resolving that is core's job precisely so a module + // never has to hold core's identifiers. + teamByExternalId: (moduleId, externalId) => + req(`/public/teams/by-external/${encodeURIComponent(moduleId)}/${encodeURIComponent(externalId)}`), teamActivity: (slug, opts = {}) => { const qs = new URLSearchParams() if (opts.limit != null) qs.set('limit', String(opts.limit)) if (opts.offset != null) qs.set('offset', String(opts.offset)) return req(`/public/teams/${encodeURIComponent(slug)}/activity${withQs(qs.toString())}`) }, - myTeams: () => req('/player/teams'), - myTeamAccess: (slug) => req(`/player/teams/${encodeURIComponent(slug)}/access`), wikiTags: () => req('/public/wiki/tags'), wikiPage: (slug) => req(`/public/wiki/${slug}`), // CMS pages (block-based). Published-only for the public; a draft-preview link diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 064536d..84706f0 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -28,11 +28,6 @@ import { useFeatureGate } from '../modules/features.jsx' export const NAV = [ { label: 'Home', to: '/', end: true }, { label: 'News', to: '/site/news' }, - // The first CORE row to carry a `feature` since the shard rows left in slice 3 - // (TEAMS.md §3.5). It is answered by core's own provider (main.jsx) and gates - // on whether this deployment has Teams at all, not on who is looking — Team - // pages are public. Fails open, so an unknown answer shows the link. - { label: 'Teams', to: '/teams', feature: 'teams' }, { label: 'Screenshots', to: '/site/screenshots' }, { label: 'Five on Friday', to: '/site/five-on-friday' }, { label: 'Newsletter', to: '/site/newsletter' }, diff --git a/client/src/lib/teamActivity.js b/client/src/lib/teamActivity.js new file mode 100644 index 0000000..2d5c3b6 --- /dev/null +++ b/client/src/lib/teamActivity.js @@ -0,0 +1,100 @@ +// What core's Team activity feed SAYS, separated from how it renders +// (docs/website/TEAMS.md §4.3). +// +// Core renders this feed into a slot a MODULE declares on its own page, because +// Teams is a contract primitive and not a surface: core owns the feed, its +// visibility rules and its wording; the module owns the page and the vocabulary +// around it. So this file is deliberately narrow — the roster and index +// presentation that once lived here went with the core Team pages, to whichever +// module renders them. +// +// Plain JS with tests, following lib/teamAdmin.js. Worth splitting for the same +// reason it was there: a feed that is filtered, or a projection that is stale, +// has to say so in words, and getting that wording right is logic rather than +// markup. + +const MINUTE = 60_000 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR + +/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */ +export function relativeTime(when, now = Date.now()) { + if (!when) return null + const ms = now - new Date(when).getTime() + if (!Number.isFinite(ms)) return null + if (ms < MINUTE) return 'just now' + if (ms < HOUR) { + const n = Math.floor(ms / MINUTE) + return `${n} ${n === 1 ? 'minute' : 'minutes'} ago` + } + if (ms < DAY) { + const n = Math.floor(ms / HOUR) + return `${n} ${n === 1 ? 'hour' : 'hours'} ago` + } + const n = Math.floor(ms / DAY) + return `${n} ${n === 1 ? 'day' : 'days'} ago` +} + +/** + * How a public surface describes the projection's freshness (§2.4). + * + * Distinct from `teamAdmin.freshnessOf`, which is worded for an operator + * debugging a sync. A visitor needs one sentence about whether what they are + * looking at is current, and specifically must never be shown an unconfirmed + * empty projection as though it were a confirmed empty shard. + */ +export function freshnessNote(sync = {}, now = Date.now()) { + // Nothing supplies Teams here, so there is nothing to be stale ABOUT. A + // deployment with no game module is not a broken one. + if (!sync.configured) return null + if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' } + const ago = relativeTime(sync.lastSyncAt, now) + if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` } + return { tone: 'idle', text: `Last confirmed ${ago}.` } +} + +/** + * Group feed items into days, newest first, preserving order within a day (§4.3). + * + * Keyed by local calendar date rather than by a UTC slice: "yesterday" is a + * property of where the reader is sitting, and a shard's evening raid landing at + * 00:30 UTC belongs on the day the players experienced it. + */ +export function groupByDay(items = [], locale = undefined) { + const days = [] + const byKey = new Map() + for (const item of items) { + const date = new Date(item.occurredAt) + if (Number.isNaN(date.getTime())) continue + const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` + if (!byKey.has(key)) { + const day = { + key, + label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }), + items: [], + } + byKey.set(key, day) + days.push(day) + } + byKey.get(key).items.push(item) + } + return days +} + +/** + * What to say under a feed that has been filtered. + * + * Only when there is something to say: a caller who saw everything is told + * nothing, and an anonymous caller is invited to sign in rather than simply + * informed that entries exist which they cannot have. + * + * The wording avoids core's own noun. The reader is looking at a page the module + * titled — a guild, a clan — and "this Team" would be core's vocabulary leaking + * onto a surface that deliberately does not use it. + */ +export function activityScopeNote(feed = {}, signedIn = false) { + if (feed.scope !== 'public') return null + return signedIn + ? 'Some entries are visible to members only.' + : 'Sign in as a member to see the members-only entries.' +} diff --git a/client/src/lib/teams.js b/client/src/lib/teams.js deleted file mode 100644 index e73a208..0000000 --- a/client/src/lib/teams.js +++ /dev/null @@ -1,151 +0,0 @@ -// What the public Team pages SAY, separated from how they render -// (docs/website/TEAMS.md §3.2, §3.3, §4.3). -// -// Plain JS with tests, following lib/teamAdmin.js. It is worth splitting here for -// the same reason it was there: these pages have to state differences that look -// like bugs unless they are worded deliberately. A roster header reading -// "37 members · 21 linked" is information; the same numbers with no explanation -// is a support ticket. And an empty roster has three unrelated causes — a Team -// with nobody in it, an audience rung that shows nobody, and a module that could -// not be asked — which is logic, not markup. - -/** How the roster describes a row's relationship to a site account (§3.2). */ -export const LINK_STATE = { linked: 'linked', unlinked: 'unlinked' } - -export function linkStateOf(member) { - return member && member.linked ? LINK_STATE.linked : LINK_STATE.unlinked -} - -/** - * The roster header line. - * - * The gap between the two numbers is the surfaced divergence Part 2 asks for: it - * must read as information rather than as a discrepancy, which is why the line - * says what each number IS instead of showing them side by side and hoping. - * - * `guests` is phase 4's forum grants and is omitted while there are none, so the - * line does not carry a permanent zero for a feature that has not shipped. - */ -export function rosterSummary({ members = 0, linked = 0, guests = 0 } = {}) { - const parts = [`${members} ${members === 1 ? 'member' : 'members'}`, `${linked} linked`] - if (guests > 0) parts.push(`${guests} forum ${guests === 1 ? 'guest' : 'guests'}`) - return parts.join(' · ') -} - -const MINUTE = 60_000 -const HOUR = 60 * MINUTE -const DAY = 24 * HOUR - -/** "just now" / "14 minutes ago" / "3 hours ago" / "2 days ago". */ -export function relativeTime(when, now = Date.now()) { - if (!when) return null - const ms = now - new Date(when).getTime() - if (!Number.isFinite(ms)) return null - if (ms < MINUTE) return 'just now' - if (ms < HOUR) { - const n = Math.floor(ms / MINUTE) - return `${n} ${n === 1 ? 'minute' : 'minutes'} ago` - } - if (ms < DAY) { - const n = Math.floor(ms / HOUR) - return `${n} ${n === 1 ? 'hour' : 'hours'} ago` - } - const n = Math.floor(ms / DAY) - return `${n} ${n === 1 ? 'day' : 'days'} ago` -} - -/** - * How a public page describes the projection's freshness (§2.4). - * - * Distinct from `teamAdmin.freshnessOf`, which is worded for an operator - * debugging a sync. A visitor needs one sentence about whether what they are - * looking at is current, and specifically must never be shown an unconfirmed - * empty projection as though it were a confirmed empty shard. - */ -export function freshnessNote(sync = {}, now = Date.now()) { - // Nothing supplies Teams here, so there is nothing to be stale ABOUT. A - // deployment with no game module is not a broken one. - if (!sync.configured) return null - if (!sync.lastSyncAt) return { tone: 'warn', text: 'Not yet confirmed against the game.' } - const ago = relativeTime(sync.lastSyncAt, now) - if (sync.stale) return { tone: 'warn', text: `Last confirmed ${ago} — the game may have moved on.` } - return { tone: 'idle', text: `Last confirmed ${ago}.` } -} - -/** - * Why a roster is empty, in the viewer's terms. - * - * Returns null when it is not empty. The three causes are genuinely different and - * reporting the wrong one is the failure this function exists to prevent: telling - * someone a guild has no members when in fact the module could not be asked is a - * statement about the game that happens to be false. - */ -export function emptyRosterReason(roster = {}) { - const members = roster.members || [] - if (members.length) return null - if (roster.projectionUnavailable) { - return 'The roster cannot be shown right now — the game module could not be reached.' - } - if (roster.configured && !roster.lastSyncAt) { - return 'This roster has not been confirmed against the game yet.' - } - return 'Nobody is in this Team.' -} - -/** - * Group feed items into days, newest first, preserving order within a day (§4.3). - * - * Keyed by local calendar date rather than by a UTC slice: "yesterday" is a - * property of where the reader is sitting, and a shard's evening raid landing at - * 00:30 UTC belongs on the day the players experienced it. - */ -export function groupByDay(items = [], locale = undefined) { - const days = [] - const byKey = new Map() - for (const item of items) { - const date = new Date(item.occurredAt) - if (Number.isNaN(date.getTime())) continue - const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` - if (!byKey.has(key)) { - const day = { - key, - label: date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' }), - items: [], - } - byKey.set(key, day) - days.push(day) - } - byKey.get(key).items.push(item) - } - return days -} - -/** - * What to say under a feed that has been filtered. - * - * Only when there is something to say: a caller who saw everything is told - * nothing, and an anonymous caller is invited to sign in rather than simply - * informed that items exist which they cannot have. - */ -export function activityScopeNote(feed = {}, signedIn = false) { - if (feed.scope !== 'public') return null - return signedIn - ? 'Some entries are visible to members of this Team only.' - : 'Sign in as a member of this Team to see its members-only entries.' -} - -/** Sort for the index: most members first, then alphabetically. */ -export function sortTeams(teams = []) { - return [...teams].sort( - (a, b) => (b.memberCount || 0) - (a.memberCount || 0) || String(a.name).localeCompare(String(b.name)), - ) -} - -/** The index's search, over the two things a visitor knows a Team by. */ -export function filterTeams(teams = [], query = '') { - const q = query.trim().toLowerCase() - if (!q) return teams - return teams.filter( - (t) => String(t.name || '').toLowerCase().includes(q) || String(t.abbr || '').toLowerCase().includes(q), - ) -} diff --git a/client/src/main.jsx b/client/src/main.jsx index 8ab9ff7..258fbbd 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -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( diff --git a/client/src/modules/TeamActivityFeed.jsx b/client/src/modules/TeamActivityFeed.jsx new file mode 100644 index 0000000..873d098 --- /dev/null +++ b/client/src/modules/TeamActivityFeed.jsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react' +import { api } from '../api/client.js' +import { useAuth } from '../contexts/AuthContext.jsx' +import { activityScopeNote, freshnessNote, groupByDay } from '../lib/teamActivity.js' + +// Core's Team activity feed, rendered into a slot a MODULE declares +// (TEAMS.md Part 4, §3.4 as amended). +// +// **This is the inverted slot direction, and this component is why it exists.** +// The feed is core's: core owns `team_activity`, writes the membership and rename +// items into it, enforces the public/members split, and is the only thing that +// can resolve whether this viewer is inside the Team. None of that is a module's +// to reimplement. But the PAGE is the module's, because Teams is a contract +// primitive and core does not own the word for one — a UO shard says guild, the +// next game will say something else. So the module declares the place and core +// puts the feed in it. +// +// The module passes the Team in ITS OWN vocabulary — `externalId` plus its module +// id — and core resolves the slug. A module never learns core's Team id and never +// needs to: it names the thing the way it already names it. +// +// Everything here degrades to rendering nothing. A slot that throws is contained +// by core's own boundary (Slot.jsx), but a slot that renders an error box would +// still be core putting a defect on a page it does not own — so a failed fetch is +// silence, not a message. + +export default function TeamActivityFeed({ externalId, moduleId, limit = 25 }) { + const { user } = useAuth() + const [state, setState] = useState({ loading: true, feed: null, team: null }) + + useEffect(() => { + let active = true + if (!externalId || !moduleId) { + setState({ loading: false, feed: null, team: null }) + return undefined + } + // Two calls because the module names the Team its way and the feed is keyed + // by core's slug. The lookup is core's job precisely so the module does not + // have to hold core's identifiers. + api.teamByExternalId(moduleId, externalId) + .then(async (team) => { + const feed = await api.teamActivity(team.slug, { limit }) + if (active) setState({ loading: false, feed, team }) + }) + .catch(() => { if (active) setState({ loading: false, feed: null, team: null }) }) + return () => { active = false } + }, [externalId, moduleId, limit]) + + const { loading, feed, team } = state + if (loading || !feed) return null + + const days = groupByDay(feed.items || []) + const note = team ? freshnessNote(team) : null + const scopeNote = activityScopeNote(feed, Boolean(user)) + + // Nothing has happened and nothing to explain: render nothing rather than an + // empty heading on someone else's page. + if (days.length === 0 && !scopeNote) return null + + return ( +
+

+ Recent activity +

+ {note && ( +

{note.text}

+ )} + + {days.length === 0 && ( +

Nothing has happened here yet.

+ )} + + {days.map((day) => ( +
+

+ {day.label} +

+
    + {day.items.map((item) => ( +
  • + {item.summary} +
  • + ))} +
+
+ ))} + + {scopeNote && ( +

{scopeNote}

+ )} +
+ ) +} diff --git a/client/src/modules/coreFeatures.js b/client/src/modules/coreFeatures.js deleted file mode 100644 index c797157..0000000 --- a/client/src/modules/coreFeatures.js +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect, useState } from 'react' - -// Core's own feature provider (TEAMS.md §3.5, MODULE_API.md §3.3). -// -// Core registered one here until the module cutover, under owner id `core` and -// namespace `uo`, and it left with the shard rows. This brings the seam back with -// content that is genuinely core's: `teams` gates the Teams nav rows, and Teams -// are a core platform entity that a module merely populates. -// -// **What the flag actually answers is "does this deployment have Teams at all".** -// Not "may this viewer see them" — Team pages are public (§0.7) and the server -// gates them. On bare core, with no module supplying a Team provider and no rows -// left behind by one, `/teams` is a permanently empty page and a link to it is -// worse than no link. That is the whole job. -// -// It fails OPEN, like every other answer in this seam: while the request is in -// flight, and on any error, the hook returns `null`, which `buildFeatureGate` -// reads as "we do not know yet" and SHOWS the row. The page itself is the gate. -// The one thing a UI mistake must never do here is hide a surface from someone -// entitled to it — and a Teams link that leads somewhere empty is a far cheaper -// mistake than a Team page nobody can find. - -// `limit=1` because only `enabled` is wanted. The endpoint answers it whatever -// the page size, and asking for the default fifty would pull a roster's worth of -// counts into a nav decision. -const TEAMS_URL = '/api/v1/public/teams?limit=1' - -/** - * The hook core registers. Returns a Set-like of visible flags, or `null` while - * the answer is unknown. - * - * Fetched once per mount rather than subscribed: whether a deployment has Teams - * changes when a module is installed, which is a restart, not a session event. - */ -export function useCoreFlags() { - const [flags, setFlags] = useState(null) - - useEffect(() => { - let active = true - fetch(TEAMS_URL, { credentials: 'same-origin' }) - .then((res) => (res.ok ? res.json() : null)) - .then((body) => { - if (!active) return - // A body that does not carry `enabled` is an older server or a shape - // change, and both are "unknown" rather than "no". - if (!body || typeof body.enabled !== 'boolean') return - setFlags(new Set(body.enabled ? ['teams'] : [])) - }) - .catch(() => {}) // stays null: unknown shows the row - return () => { active = false } - }, []) - - return flags -} - -export default useCoreFlags diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js index d6783f5..718bb3f 100644 --- a/client/src/modules/registry.js +++ b/client/src/modules/registry.js @@ -135,6 +135,69 @@ export function declareSlot(name) { slots.set(name, { Component: null, filledBy: null }) } +/** + * The INVERTED direction: a MODULE declares a slot and CORE fills it. + * + * Added for Teams (TEAMS.md Part 3). The original direction assumes core owns + * the page and a module contributes to it, which is right for the footer and the + * admin user detail. Teams is the other shape: **Teams is a contract primitive, + * not a surface.** Core owns the tables, the sync, the access rules and the + * activity feed; it does not own the vocabulary — a UO shard calls them guilds + * and the next game will call them something else — so the PAGE is the module's + * and the content core contributes to it is core's. + * + * Without this, core would have to publish a `/teams` page under a word it + * invented, next to the module's own Guilds page saying the same thing twice. + * + * A module namespaces its slot under its own id (`uo.guild.detail`), which is + * what stops two modules colliding and what makes the owner readable at the fill + * site. The namespace is enforced rather than conventional. + * + * **Ordering is why this is a separate call and not just `declareSlot` exposed + * to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts + * are deferred and injected after core's), so at the moment core would like to + * fill one of these, it does not exist yet. Core therefore registers its fills + * through `fillModuleSlot` below, which is applied after every module chunk has + * evaluated — see main.jsx. + */ +export function declareModuleSlot(id, name) { + if (!name.startsWith(`${id}.`)) { + throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`) + } + if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`) + slots.set(name, { Component: null, filledBy: null, declaredBy: id }) +} + +// Core's pending fills for module-declared slots, applied once every module +// chunk has evaluated. Kept as a list rather than applied eagerly because the +// slot does not exist when core asks — see the ordering note above. +const coreFills = [] + +/** + * Core: "fill this module-declared slot when it turns up." + * + * Deliberately not an error when the slot never appears. A module that is not + * installed declares nothing, and core offering content for a page that does not + * exist is the ordinary case on any deployment — not a misconfiguration. That is + * the mirror of an unfilled slot rendering nothing. + */ +export function fillModuleSlot(name, Component) { + if (typeof Component !== 'function') throw new Error(`fillModuleSlot: ${name} is not a component`) + coreFills.push([name, Component]) +} + +/** Apply core's fills. Called once from main.jsx, after module chunks have run. */ +export function applyCoreFills() { + for (const [name, Component] of coreFills) { + const entry = slots.get(name) + if (!entry) continue // the declaring module is not installed + if (entry.filledBy) continue // a module already claimed it; first fill wins + entry.Component = Component + entry.filledBy = 'core' + } + coreFills.length = 0 +} + /** * Fill a declared slot with a component. * @@ -207,6 +270,7 @@ export function _reset() { nav[area].length = 0 } providers.clear() + coreFills.length = 0 // Declarations go too, unlike the server's, where a slot is declared once at // require time by the router that owns it. Core declares its slots in // main.jsx — the one file no test loads — so on this side there is nothing @@ -224,6 +288,8 @@ export const registry = { registerNav, registerFeatureProvider, registerExtension, + // The inverted direction (TEAMS.md Part 3): the module declares, core fills. + declareModuleSlot, routesFor, navFor, featureProviderFor, diff --git a/client/src/modules/shared.js b/client/src/modules/shared.js index 5deb940..6a72734 100644 --- a/client/src/modules/shared.js +++ b/client/src/modules/shared.js @@ -34,6 +34,7 @@ import { MODULE_API_VERSION } from './version.js' import PublicLayout from '../components/PublicLayout.jsx' import PageHeader from '../components/PageHeader.jsx' import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx' +import Slot from './Slot.jsx' import { useAsync } from '../lib/useAsync.js' import { useAuth } from '../contexts/AuthContext.jsx' import { useSite } from '../contexts/SiteContext.jsx' @@ -66,6 +67,13 @@ const ui = { useAsync, useAuth, useSite, + // The eighth member, for the INVERTED slot direction (TEAMS.md Part 3). A + // module that declares a slot on its own page needs the same component core + // renders its own with — the error boundary in particular, since the thing + // being contained here is CORE's content failing inside the MODULE's page. + // Shared rather than reimplemented for the reason the whole kit exists: two + // boundaries with different behaviour would be two bugs. + Slot, } // The request PRIMITIVE, not the `api` object (§3.5): a module builds its own diff --git a/client/src/routes/player/PlayerPortalLayout.jsx b/client/src/routes/player/PlayerPortalLayout.jsx index cd1c537..12e9153 100644 --- a/client/src/routes/player/PlayerPortalLayout.jsx +++ b/client/src/routes/player/PlayerPortalLayout.jsx @@ -35,7 +35,6 @@ function Icon({ children, size = 16 }) { } const IconGear = () => const IconShield = () => -const IconTeams = () => // Exported because Admin -> Navigation edits this list. It stays declared here; // the editor may only relabel, reorder and hide what it finds (§7). No CORE row @@ -47,12 +46,6 @@ const IconTeams = () =>