feat(guilds): a guild detail page, and the slot core puts the feed in
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / frozen-manifest (pull_request) Failing after 33s
PR Checks / server-tests (pull_request) Successful in 8m46s

The module's half of the org lead's correction: Teams is the contract, guilds
are the presentation, and the presentation is this module's.

Adds `/uo/guilds/:id` — the detail view the board never had — with the roster
from this module's OWN board, which is the same data it answers core's Team
provider from. Reading core's projection of our own answer back would be a round
trip through a staler copy of it.

The page declares `uo.guild.detail` and core fills it with the Team activity
feed. That is the one part of this page core cannot hand over: only core can
resolve whether the viewer is inside the Team, and the public/members split on
that feed is a security boundary. The guild is named in OUR terms — core maps
its own Team from the module id and the external id — so this module never holds
core's row id or slug.

`TeamOverviewStrip` is deleted with the core Team page it filled.
`team.member.row` is not declared here either: the useful thing to put in a
roster row is a link to the character behind it, and nothing core could supply
identifies one.

`GET /public/shard/guilds/:id` backs the page, gated and projected through the
same `guilds` feature as the board — so an operator who raises that audience
raises this too, and the locked acct/webId fields never survive below admin. A
roster is where those appear in bulk, which makes this the endpoint where
getting the projection wrong would matter most.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:58:30 -05:00
parent d4aa5ade12
commit dda0e32dd3
11 changed files with 257 additions and 80 deletions

View File

@@ -44,6 +44,7 @@ function fakeRg() {
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
const extensions = new Map()
const declaredSlots = new Set()
return {
version: '1.3.0',
react,
@@ -54,7 +55,7 @@ function fakeRg() {
// object, so the check compares against whatever is here.
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
ui: Object.fromEntries(
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
.map((n) => [n, stub(n)]),
),
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
@@ -72,10 +73,19 @@ function fakeRg() {
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
extensions.set(slot, { id, Component })
},
// The INVERTED direction (core API 1.6.0): this module declares a place on
// its OWN page and core fills it. Core enforces the namespace, so the fake
// does too — a chunk that declared an unnamespaced slot would pass here and
// throw in a browser.
declareModuleSlot(id, name) {
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
declaredSlots.add(name)
},
routesFor: (area) => routes[area],
navFor: (area) => nav[area],
},
_read: () => ({ routes, nav, providers, extensions }),
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
}
}
@@ -98,7 +108,7 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
it('registers routes in all three areas, namespaced under the module id', () => {
const { routes } = registered
assert.equal(routes.public.length, 12)
assert.equal(routes.public.length, 13)
assert.equal(routes.admin.length, 7)
assert.equal(routes.player.length, 2)
for (const area of ['public', 'admin', 'player']) {
@@ -166,15 +176,11 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
})
it('fills the four extension slots, each with a component', () => {
it('fills the three CORE extension slots, each with a component', () => {
const { extensions } = registered
// `team.member.row` is core-declared and deliberately absent: the props core
// can supply do not identify a character, because the member key and the site
// account id are withheld from every public roster (TEAMS.md §3.2). An empty
// cell beats a guess.
assert.deepEqual(
[...extensions.keys()].sort(),
['admin.users.detail', 'player.invite.accepted', 'site.footer.status', 'team.overview'],
['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
)
for (const [slot, { id, Component }] of extensions) {
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
@@ -202,3 +208,17 @@ it('registers under exactly one module id, matching the manifest', () => {
])
assert.deepEqual([...owners], [manifest.id])
})
it('declares its own guild-detail slot, for core to fill', () => {
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
// core page: core owns the activity feed and this module owns the word "guild",
// so this module declares the place and core puts the feed in it.
assert.deepEqual([...registered.declaredSlots], ['uo.guild.detail'])
})
it('the declared slot is rendered by the page that owns it', () => {
// A slot nothing renders is a slot core fills into the void. Asserted against
// the source rather than the chunk, since the chunk is minified.
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
assert.match(page, /name="uo\.guild\.detail"/)
})