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 = () =>