feat(teams): the public Team pages, the two slots and the nav flag

TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full
roster and the player portal's "My Teams" — plus the two extension slots a
module adds to them, and the nav rows that lead there.

These are CORE routes, not module ones. A Team is a core platform entity that a
module merely populates, so the whole experience renders on bare core; a module
adds to these pages rather than supplying them.

`team.member.row` is declared with `{ displayName, isLeader, linked }` and not
§3.4's `{ memberKey, userId, displayName }`. The two documents contradict each
other and §3.2 is the one that is a security rule: a slot component runs in the
browser, so those props can only reach it by publishing a game-internal
identifier and a site account id in every public roster response, for every
visitor, module installed or not. Recorded as an amendment.

The presentation logic is split into lib/teams.js with its own tests, following
lib/teamAdmin.js, because these pages have to state differences that read as
bugs unless they are worded deliberately:

  - "37 members · 21 linked" — the gap is information (a character with no site
    account behind it), and the header says what each number IS rather than
    showing both and hoping;
  - an empty roster has three unrelated causes — nobody in the Team, a rung
    that shows nobody, and a module that could not be asked — and reporting the
    last as the first is a statement about the game that happens to be false;
  - a stale projection says how old it is rather than presenting itself as
    current.

`teams` is the first CORE nav row to carry a `feature` since the shard rows left
with the module cutover, and it brings core's own feature provider back with it.
It gates on whether this deployment has Teams AT ALL, not on who is looking —
Team pages are public and the server gates them. It fails open, so an unknown
answer shows the link: a Teams link leading somewhere empty is a far cheaper
mistake than a Team page nobody can find.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:15:54 -05:00
parent 03631d7d40
commit 8f4aff6946
12 changed files with 861 additions and 3 deletions

View File

@@ -0,0 +1,120 @@
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import Slot from '../../modules/Slot.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { emptyRosterReason, freshnessNote, rosterSummary } from '../../lib/teams.js'
// The full roster (TEAMS.md §3.2). `shell="wide"` per §3.1 — this is the one
// Team page with a table wide enough to want the room.
//
// **The link state is a first-class column, not an absence.** A roster where 37
// members show but only 21 carry a profile link looks broken until the page says
// what the difference is; §3.2 exists because that gap is information (a
// character with no site account behind it) and has to read as such.
export default function TeamRoster() {
const { slug } = useParams()
const team = useAsync(() => api.team(slug), [slug])
const roster = useAsync(() => api.teamRoster(slug), [slug])
const members = roster.data?.members || []
const linked = members.filter((m) => m.linked).length
const note = roster.data ? freshnessNote(roster.data) : null
const emptyReason = roster.data ? emptyRosterReason(roster.data) : null
return (
<PublicLayout section="website" shell="wide">
<PageHeader
eyebrow={team.data ? team.data.name : 'Team'}
title="Roster"
/>
<p style={{ marginBottom: 18 }}>
<Link to={`/teams/${slug}`}> Back to the Team</Link>
</p>
{(team.loading || roster.loading) && <Loading />}
{roster.error && <ErrorState message="Could not load this roster right now." />}
{!roster.loading && !roster.error && roster.data && (
<>
<p style={{ color: 'var(--muted)', marginBottom: 6 }}>
{rosterSummary({ members: members.length, linked })}
</p>
{note && (
<p style={{ color: note.tone === 'warn' ? 'var(--mode-maint)' : 'var(--muted)', fontSize: '0.85rem', marginBottom: 18 }}>
{note.text}
</p>
)}
{emptyReason && <p style={{ color: 'var(--muted)' }}>{emptyReason}</p>}
{members.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--muted)', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Name</th>
<th style={{ padding: '8px 10px' }}>Rank</th>
<th style={{ padding: '8px 10px' }}>Status</th>
<th style={{ padding: '8px 10px' }} />
</tr>
</thead>
<tbody>
{/* Keyed by position, unavoidably: the row's stable identifier
is its member key and that is exactly what is not published
(§3.2). Two characters may share a display name. */}
{members.map((member, i) => (
// eslint-disable-next-line react/no-array-index-key
<tr key={`${member.displayName}-${i}`} style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '10px', color: member.linked ? 'var(--head)' : 'var(--muted)' }}>
{member.displayName}
{member.isLeader && (
<span style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.78rem' }}>Leader</span>
)}
{/* Muted text alone would read as a rendering glitch; the
chip is what makes the gap in the header line legible. */}
{!member.linked && (
<span style={{ color: 'var(--muted)', marginLeft: 8, fontSize: '0.75rem', border: '1px solid var(--line)', borderRadius: 999, padding: '1px 8px' }}>
not linked
</span>
)}
</td>
<td style={{ padding: '10px', color: 'var(--muted)' }}>{member.rankLabel || '—'}</td>
<td style={{ padding: '10px', color: 'var(--muted)' }}>
{member.online ? 'Online' : 'Offline'}
</td>
{/* The module's trailing cell. Nothing on bare core.
§3.4 declares this slot's props as `{ memberKey, userId,
displayName }`, and two of those cannot be supplied:
§3.2 withholds the member key and the user id from every
public roster response, so they are not in the payload
this component was rendered from. Publishing them to
reach the slot would put a game-internal identifier and
a site account id on a public page for every visitor,
module installed or not — the doc's two sections
contradict each other and §3.2 is the one that is a
security rule. The slot gets what core can honestly
give it. */}
<td style={{ padding: '10px', textAlign: 'right' }}>
<Slot
name="team.member.row"
displayName={member.displayName}
isLeader={member.isLeader}
linked={member.linked}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</PublicLayout>
)
}