fix(admin): style the Teams admin screen with the site's own classes
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 2m34s

The Teams admin screen was written against four CSS classes that do not
exist anywhere in the project — `.table`, `.kv`, `.list` and `.notice` —
and against `.btn-ghost` / `.btn` used without the `.btn` box they depend
on. The result rendered as unstyled UA tables and bare browser buttons
sitting flush against unpadded panels, and looked nothing like the rest
of the admin panel.

Nothing here changes behaviour, data or routes; it is presentation only.

- Tables become `adm-table` / `adm-th` / `adm-td` inside `panel-flat`,
  the markup the other fourteen admin views use, and scroll rather than
  clip when a row is wider than the shell (a status badge and the action
  buttons are both nowrap by design, so a narrow viewport can always
  overflow one).
- Buttons take the full `btn btn-primary btn-sq` / `btn btn-ghost btn-sq`
  triplet. `.btn` carries the padding, border and radius; the variants
  carry only colour, so a bare `.btn-ghost` had none of the box and a
  bare `.btn` fell back to the UA's light button face.
- `.panel` supplies no padding, so every panel now sets it explicitly at
  22px, as ModulesAdmin and EmptyState already do.
- Headings become `h2.display`, and the in-page `<h1>Teams</h1>` goes
  away in favour of AdminLayout's topbar title — which needed
  `/admin/teams` adding to TITLES, the reason the bar read "ADMIN".
- Status pills use the existing `badge-pub` / `badge-moderator` /
  `badge-ban` / `badge-draft` modifiers. `.badge` alone declares no
  border, so the old inline `borderColor` was inert.
- The bridge and voice panels drop their private palette
  (`#e08b77` / `#8fbf7a` / `#e0b877`) for the site's
  `#d98b84` / `#7fd0a4` / `#e0b070`, and a literal `rgba(255,255,255,.12)`
  rule and a `borderRadius: 4` for `var(--line-soft)` and
  `var(--radius-input)`.

Walked live against the dev DB as an admin: sync panel, review queue, all
Teams, the forum ledger, the bridge draft form and its acknowledgement
dialog. Client 288/288, server 1162/1162, client build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
This commit is contained in:
2026-08-19 14:03:01 -05:00
parent 963d734dcc
commit 68f038f456
4 changed files with 345 additions and 218 deletions

View File

@@ -20,6 +20,9 @@ import {
// gate is PRESENTED: the sentence an operator agrees to, and the fact that
// agreeing is a deliberate act rather than a checkbox they tab past.
const PANEL = { padding: 22, marginBottom: 22, maxWidth: 760 }
const HEADING = { margin: '0 0 6px', fontSize: '1.2rem', color: 'var(--head)' }
const ACK_TEXT = [
'Forum posts and announcements are visible only to a Teams members. This site cannot see who can'
+ ' read a channel on another platform, so it cannot check that for you.',
@@ -54,9 +57,9 @@ export default function TeamIntegrations() {
if (!config) {
return (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Notification bridge</h2>
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
</section>
)
}
@@ -115,67 +118,75 @@ export default function TeamIntegrations() {
}
return (
<section style={{ marginTop: 34, maxWidth: 760 }}>
<h2 className="display" style={{ fontSize: '1.05rem', marginBottom: 4 }}>Notification bridge</h2>
<section className="panel" style={PANEL}>
<h2 className="display" style={HEADING}>Notification bridge</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 14px' }}>
Send Team notifications to a {config.platform} channel. Set a default that every Team uses, and
override it for individual Teams. A message is sent once and not retried the bridge is a
courtesy, and nothing on the site depends on it arriving.
</p>
{error && <p className="sans" style={{ color: '#e08b77', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#8fbf7a', fontSize: '0.82rem' }}>{notice}</p>}
{error && <p className="sans" style={{ color: '#d98b84', fontSize: '0.82rem' }}>{error}</p>}
{notice && <p className="sans" style={{ color: '#7fd0a4', fontSize: '0.82rem' }}>{notice}</p>}
{config.rows.length === 0 && !draft && (
<p className="sans dim" style={{ fontSize: '0.8rem' }}>Nothing configured no Team events leave the site.</p>
)}
{config.rows.length > 0 && (
<table className="table">
<thead>
<tr><th>Applies to</th><th>Events</th><th>Channel</th><th>State</th><th /></tr>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={rowKey(row)}>
<td>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="sans" style={{ fontSize: '0.76rem' }}>
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block' }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
<div className="panel-flat" style={{ overflowX: 'auto' }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Applies to</th>
<th className="adm-th">Events</th>
<th className="adm-th">Channel</th>
<th className="adm-th">State</th>
<th className="adm-th" />
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{config.rows.map((row) => (
<tr key={rowKey(row)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>
{appliesToLabel(row)}
{isDefaultRow(row) && <span className="dim"> (default)</span>}
</td>
<td className="adm-td">
{row.events.length === 0
? <span className="dim">none</span>
: row.events.map(eventLabel).join(', ')}
</td>
<td className="adm-td dim">{row.channel_ref || <span className="dim">unset</span>}</td>
<td className="adm-td">
{row.enabled ? 'Enabled' : 'Disabled'}
{row.members_ack && (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem', marginTop: 3 }}>
members-only destination confirmed
{row.members_ack_username ? ` by ${row.members_ack_username}` : ''}
</span>
)}
</td>
<td className="adm-td" style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDraft(draftFrom(row))}>Edit</button>
<button type="button" className="btn btn-ghost btn-sq" style={{ marginLeft: 8 }} disabled={busy} onClick={() => remove(row)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!draft && (
<div style={{ marginTop: 12 }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14 }}>
{!hasDefault && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(null))}>
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(null))}>
Set a default for all Teams
</button>
)}
{available.length > 0 && (
<button type="button" className="btn-ghost" onClick={() => setDraft(blankDraft(available[0].id))}>
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft(blankDraft(available[0].id))}>
Add a per-Team override
</button>
)}
@@ -183,7 +194,7 @@ export default function TeamIntegrations() {
)}
{draft && (
<div style={{ marginTop: 18, borderTop: '1px solid rgba(255,255,255,0.12)', paddingTop: 16 }}>
<div style={{ marginTop: 18, borderTop: '1px solid var(--line-soft)', paddingTop: 16 }}>
<label style={{ display: 'block', marginBottom: 12 }}>
<span className="field-label">Applies to</span>
<select
@@ -246,34 +257,34 @@ export default function TeamIntegrations() {
{draft.membersAck && (
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 10 }}>
You have confirmed this channel is restricted to the Teams members.{' '}
<button type="button" className="btn-ghost" onClick={() => setDraft({ ...draft, membersAck: false })}>
<button type="button" className="btn btn-ghost btn-sq" onClick={() => setDraft({ ...draft, membersAck: false })}>
Withdraw
</button>
</p>
)}
<div style={{ marginTop: 16 }}>
<button type="button" className="btn" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
<div style={{ display: 'flex', gap: 10, marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={save}>Save</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => { setDraft(null); setError('') }}>Cancel</button>
</div>
</div>
)}
{dialog && (
<div style={{ marginTop: 18, border: '1px solid #e0b877', padding: 14, borderRadius: 4 }}>
<div style={{ marginTop: 18, border: '1px solid #e0b070', padding: 16, borderRadius: 'var(--radius-input)' }}>
<h3 className="display" style={{ fontSize: '0.95rem', marginTop: 0 }}>Confirm the destinations audience</h3>
{ACK_TEXT.map((line) => (
<p key={line} className="sans" style={{ fontSize: '0.8rem' }}>{line}</p>
))}
<button
type="button"
className="btn"
className="btn btn-primary btn-sq"
disabled={busy}
onClick={() => persist({ ...dialog, membersAck: true })}
>
I confirm the channel is members-only
</button>
<button type="button" className="btn-ghost" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
<button type="button" className="btn btn-ghost btn-sq" disabled={busy} onClick={() => setDialog(null)}>Cancel</button>
</div>
)}
</section>