feat(teams): project rosters by audience rung, and add to the Team page

The module's half of TEAMS.md phase 3.

`projectRoster` is the optional fourth provider method and the only one core
calls on a request path. Core holds the roster and owns its public shape; the
question that is this module's is who is allowed to look, because the audience
rungs and their configuration live here.

The answer is all-or-nothing, which is the honest translation rather than a
shortcut: a rung is a property of the FEATURE, and there is no configuration in
which some members of a guild are public and others are not.

The refusal semantics INVERT here, and the tests say so. For the other three
methods a refusal means "change nothing" and an empty array would be
destructive. Core fails CLOSED on this one, so the dangerous answer is the
opposite — returning every key because the config could not be read would
publish a roster an operator gated to staff. Every path that cannot reach a
confident answer refuses, including the catch.

The anonymous case is answered directly rather than by handing `viewerLevel` a
synthetic request. Given one with no `req.user` it falls through to
`auth.getUserFromRequest`, which expects real cookies and throws on a fake — and
that throw would have become a refusal, so every anonymous visitor would have
been served an empty roster on a shard whose guilds are public. Caught by the
tests, not by reading.

`team.overview` gets a live population reading beside core's stored one. Core's
number comes from the last roster sync and is coarse by construction; this is
the `presence.online` feed this module already holds. It is explicitly not a
per-Team presence figure — the shard publishes a global aggregate and no
per-guild breakdown exists on the wire, so claiming one would be inventing a
number — and it renders nothing at all when it has nothing true to say.

`team.member.row` is left unfilled. The useful thing to put there is a link to
the character behind a row, and the props core can supply do not identify one:
the member key and the site account id are withheld from every public roster.
An empty cell beats a guess.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:16:22 -05:00
parent 0d618599cf
commit d4aa5ade12
5 changed files with 210 additions and 3 deletions

View File

@@ -0,0 +1,55 @@
import { useMemo } from 'react'
import { useShardFeed } from '../lib/useShardFeed.js'
// What this module contributes to a core Team page (`team.overview`,
// TEAMS.md §3.3, §3.4).
//
// **Core already renders an online count, and this does not replace it.** Core's
// number is written by the Team sync from the roster the module answered, so it
// is durable and refreshed at the reconcile interval — coarse by construction.
// This one is live: the same `presence.online` feed the site header's widget
// already consumes, which this module holds and core does not. Core's is the
// floor; this is the current reading, and it says which it is rather than
// silently disagreeing with the number three lines above it.
//
// It is emphatically NOT a per-Team presence feed. The shard publishes a global
// online aggregate and no per-guild breakdown exists on the wire, so claiming one
// here would be inventing a number. What it can honestly say is how many players
// are on the shard right now, next to a roster whose own online marks are as old
// as the last sync — which is the context a reader of that roster is missing.
//
// Renders nothing at all until the feed produces something. An empty slot is the
// correct output when there is nothing true to add (§3.7): core's page is
// complete without it, and a panel reading "unavailable" would be this module
// making core's page worse than it is with no module installed.
const PRESENCE_KINDS = new Set(['presence.online'])
export default function TeamOverviewStrip() {
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 2 })
const snapshot = events[0]
const total = useMemo(() => {
const n = Number(snapshot?.count)
return Number.isFinite(n) ? n : null
}, [snapshot])
// No feed yet, a disabled integration, or a shard that is down. All three are
// "nothing to add", and none of them is worth a box saying so.
if (total == null) return null
return (
<p
className="sans dim"
style={{ margin: '0 0 8px', fontSize: '0.86rem' }}
>
{total === 0
? 'Nobody is on the shard right now.'
: `${total} ${total === 1 ? 'player is' : 'players are'} on the shard right now.`}
{' '}
<span style={{ opacity: 0.7 }}>
The per-member marks above are as recent as the last roster sync.
</span>
</p>
)
}

View File

@@ -52,6 +52,7 @@ import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
import ShardStatusLink from './components/ShardStatusLink.jsx'
import UserShardSections from './routes/admin/UserShardSections.jsx'
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
import TeamOverviewStrip from './components/TeamOverviewStrip.jsx'
const ID = 'uo'
@@ -180,6 +181,18 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
// The fourth, and the first that was never core's: `team.overview` is new in
// 1.6.0 and core renders the whole Team page without it (TEAMS.md §3.4). This
// adds a live reading beside core's stored one, and renders nothing when it has
// nothing true to say.
//
// `team.member.row` is declared by core and deliberately LEFT UNFILLED. Its
// useful contents would be a link to the character behind a roster row, and the
// props core can supply do not identify one: the member key and the site account
// id are withheld from every public roster (§3.2), so this module would be
// guessing from a display name. Filling it with a guess is worse than an empty
// cell.
registry.registerExtension(ID, 'team.overview', TeamOverviewStrip)
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check here. It is logged because a