Chapter 23 · Operator · Team administration

Workspace. Who’s in the room.

The operator-side surface for team administration — members, pending invites, and the audit log. Six components compose the workspace-admin workflow: role badges, member rows, invite rows, audit-log entries, a team page header, and a drop-in members page wrapper.

23.1 Role badge

A small uppercase pill labelling one of the four workspace roles. The role taxonomy is a single source-of-truth record (ROLE_TAXONOMY) keyed by stable id — admin, editor, billing, viewer — each paired with a visual tint that drives a .role-badge--{tint} modifier. The four tints map to semantic meanings: Admin uses --error-* for “destructive privileges”, Editor uses --accent-* for the most-common “working privileges”, Billing uses --warning-* to flag the cost-side scope, and Viewer uses the sunken neutral pair for read-only. The primitive is composed inside <MemberRow> / <InviteRow> and the audit-log meta, but it stands alone — drop one anywhere a role needs surfacing.

Four roles — Admin, Editor, Billing, Viewer

.role-badge

All four badges in a horizontal row inside a .role-badge-row wrapper, in the canonical taxonomy order. The mono var(--f-mono) face + uppercase + tracked letter-spacing matches the kit’s other pill primitives (lifecycle, deal-stage, ticket-status). Below the row, the explanatory paragraph spells out the tint-to-meaning mapping consumers will read when wiring a custom role surface against ROLE_TAXONOMY.

Admin Editor Billing Viewer
<div class="role-badge-row">
  <span class="role-badge role-badge--error">Admin</span>
  <span class="role-badge role-badge--accent">Editor</span>
  <span class="role-badge role-badge--warning">Billing</span>
  <span class="role-badge role-badge--neutral">Viewer</span>
</div>
.role-badge {
  font: 500 11px/1 var(--f-mono);
  padding: 2px 8px;
  border-radius: var(--r-pill);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  display: inline-flex;
  align-items: center;
}

.role-badge--error    { background: var(--error-soft);   color: var(--error-text); }

.role-badge--accent   { background: var(--accent-soft);  color: var(--accent-text); }

.role-badge--warning  { background: var(--warning-soft); color: var(--warning-text); }

.role-badge--neutral  { background: var(--bg-sunk);      color: var(--fg-dim); }

.role-badge-row {
  display: inline-flex;
  gap: var(--s-2);
  align-items: center;
}
import { RoleBadge } from "@magicblocksai/ui";

<div className="role-badge-row">
  <RoleBadge role="admin" />
  <RoleBadge role="editor" />
  <RoleBadge role="billing" />
  <RoleBadge role="viewer" />
</div>

23.2 Member row

A single team-member row inside a <ul class="member-list"> — avatar + name + email stack + <RoleBadge> + last-active timestamp + optional per-row actions trigger. The five-column grid (auto 1fr auto auto auto) auto-sizes the avatar and meta columns while the name + email stack absorbs the slack. Used inside <WorkspaceMembersPage> (section 25.6) or composed standalone whenever a member list needs surfacing. Below 480px the grid reflows to a two-row stack: avatar spans both rows on the left, name + email occupy row 1, and the role badge + last-active meta drop to row 2 (badge left, meta right). The actions cell is hidden on phone — surfaces are expected to expose actions inside the row’s tap menu at that breakpoint.

Four rows — admin, editor, billing, viewer

.member-row

Four member rows stacked inside a .member-list wrapper, one per workspace role. The first three carry an actions trigger (the row-menu kebab); the fourth (a viewer-only placeholder member) omits the actions slot to show how the column auto-collapses when not populated. The wrapper provides the hairline border + rounded corners; the per-row bottom hairlines align to the radius via overflow: hidden on the wrapper.

<ul class="member-list">
  <li class="member-row">
    <span class="member-row-avatar">
      <span class="av" role="img" aria-label="Jay Stockwell">
        <span aria-hidden="true">JS</span>
      </span>
    </span>
    <div class="member-row-text">
      <span class="member-row-name">Jay Stockwell</span>
      <span class="member-row-email">[email protected]</span>
    </div>
    <span class="member-row-role">
      <span class="role-badge role-badge--error">Admin</span>
    </span>
    <span class="member-row-meta">Active now</span>
    <span class="member-row-actions">
      <button type="button" class="icon-btn"
              aria-label="Member actions for Jay Stockwell">…</button>
    </span>
  </li>
  <li class="member-row">…</li>
  <li class="member-row">
    <!-- Sasha Park — no actions slot -->
    <span class="member-row-avatar">…</span>
    <div class="member-row-text">…</div>
    <span class="member-row-role">…</span>
    <span class="member-row-meta">3 weeks ago</span>
  </li>
</ul>
.member-row {
  display: grid;
  grid-template-columns: auto 1fr auto auto auto;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--hair);
  transition: background var(--dur-2) var(--ease);
}

.member-row:last-child {
  border-bottom: 0;
}

.member-row.is-interactive {
  cursor: pointer;
}

.member-row.is-interactive:hover {
  background: var(--bg-paper);
}

.member-row-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.member-row-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.member-row-name {
  font: 500 14px/1.3 var(--f-body);
  color: var(--fg);
}

.member-row-email {
  font: 400 12px/1.3 var(--f-body);
  color: var(--fg-dim);
}

.member-row-role {
  display: inline-flex;
  align-items: center;
}

.member-row-meta {
  font: 400 12px/1.4 var(--f-body);
  color: var(--fg-dim);
  white-space: nowrap;
}

.member-row-actions {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

@media (max-width: 480px) {
  .member-row {
    grid-template-columns: auto 1fr auto;
    grid-template-rows: auto auto;
    row-gap: var(--s-1);
  }
  .member-row-avatar {
    grid-column: 1 / 2;
    grid-row: 1 / 3;
  }
  .member-row-text {
    grid-column: 2 / 4;
    grid-row: 1 / 2;
  }
  .member-row-role {
    grid-column: 2 / 3;
    grid-row: 2 / 3;
    justify-self: start;
  }
  .member-row-meta {
    grid-column: 3 / 4;
    grid-row: 2 / 3;
    justify-self: end;
  }
  .member-row-actions {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .member-row {
    transition: none;
  }
}
import { Avatar, MemberRow } from "@magicblocksai/ui";

<ul className="member-list">
  <MemberRow
    avatar={<Avatar name="Jay Stockwell" />}
    name="Jay Stockwell"
    email="[email protected]"
    role="admin"
    lastActive="Active now"
    actions={
      <button type="button" className="icon-btn"
              aria-label="Member actions for Jay Stockwell">…</button>
    }
  />
  <MemberRow
    avatar={<Avatar name="Alex Kim" />}
    name="Alex Kim"
    email="[email protected]"
    role="editor"
    lastActive="2 hours ago"
    actions={
      <button type="button" className="icon-btn"
              aria-label="Member actions for Alex Kim">…</button>
    }
  />
  <MemberRow
    avatar={<Avatar name="Robin Lee" />}
    name="Robin Lee"
    email="[email protected]"
    role="billing"
    lastActive="Yesterday"
    actions={
      <button type="button" className="icon-btn"
              aria-label="Member actions for Robin Lee">…</button>
    }
  />
  <MemberRow
    avatar={<Avatar name="Sasha Park" />}
    name="Sasha Park"
    email="[email protected]"
    role="viewer"
    lastActive="3 weeks ago"
  />
</ul>

23.3 Invite row

A pending-invite row inside a <ul class="invite-list"> — invitee email + role badge + invited-by / sent-at meta + optional Resend / Revoke action buttons. The three-column grid (1fr auto auto) lets the email + meta stack absorb the slack while the badge and actions auto-size on the right. The actions slot is the only piece of per-row state the consumer wires: pass onResend to surface the secondary “Resend” button, pass onRevoke to surface the danger-outline “Revoke” button, or pass either alone (a revoke-only row is canonical for older invites where the resend window has closed). Below 480px the grid reflows to a two-row stack: the email + meta stack runs the full width with the role badge pinned to the right edge of row 1; the action buttons drop to row 2 and right-align so the touch targets stay accessible.

Three rows — editor, billing, viewer

.invite-row

Three invite rows stacked inside a .invite-list wrapper, covering the three most common invite-row shapes. The first two carry both Resend + Revoke buttons; the third (a viewer invite that’s been pending for two weeks) drops the Resend button to show how the actions slot collapses when only one handler is wired. The wrapper provides the hairline border + rounded corners; the per-row bottom hairlines align to the radius via overflow: hidden on the wrapper.

<ul class="invite-list">
  <li class="invite-row">
    <div class="invite-row-text">
      <span class="invite-row-email">[email protected]</span>
      <span class="invite-row-meta">invited by Jay · 2 days ago</span>
    </div>
    <span class="invite-row-role">
      <span class="role-badge role-badge--accent">Editor</span>
    </span>
    <span class="invite-row-actions">
      <button type="button" class="btn btn-secondary btn-sm">Resend</button>
      <button type="button" class="btn btn-danger-outline btn-sm">Revoke</button>
    </span>
  </li>
  <li class="invite-row">…</li>
  <li class="invite-row">
    <!-- [email protected] — revoke only -->
    <div class="invite-row-text">…</div>
    <span class="invite-row-role">…</span>
    <span class="invite-row-actions">
      <button type="button" class="btn btn-danger-outline btn-sm">Revoke</button>
    </span>
  </li>
</ul>
.invite-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--hair);
  align-items: center;
}

.invite-row:last-child {
  border-bottom: 0;
}

.invite-row-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.invite-row-email {
  font: 500 14px/1.3 var(--f-body);
  color: var(--fg);
}

.invite-row-meta {
  font: 400 12px/1.4 var(--f-body);
  color: var(--fg-dim);
}

.invite-row-role {
  display: inline-flex;
  align-items: center;
}

.invite-row-actions {
  display: flex;
  gap: var(--s-2);
}

@media (max-width: 480px) {
  .invite-row {
    grid-template-columns: 1fr auto;
    grid-template-rows: auto auto;
  }
  .invite-row-role {
    grid-column: 2 / 3;
    grid-row: 1;
    justify-self: end;
  }
  .invite-row-actions {
    grid-column: 1 / 3;
    grid-row: 2;
    justify-content: flex-end;
  }
}
import { InviteRow } from "@magicblocksai/ui";

<ul className="invite-list">
  <InviteRow
    email="[email protected]"
    role="editor"
    invitedBy="Jay"
    sentAt="2 days ago"
    onResend={() => resend("[email protected]")}
    onRevoke={() => revoke("[email protected]")}
  />
  <InviteRow
    email="[email protected]"
    role="billing"
    invitedBy="Alex"
    sentAt="5 days ago"
    onResend={() => resend("[email protected]")}
    onRevoke={() => revoke("[email protected]")}
  />
  <InviteRow
    email="[email protected]"
    role="viewer"
    invitedBy="Robin"
    sentAt="2 weeks ago"
    onRevoke={() => revoke("[email protected]")}
  />
</ul>

23.4 Audit log entry

A single audit-log entry surfacing one team-administration event — who did what, to what, when. The head row carries an avatar + actor name + verb + target + timestamp in a one-line layout, with the timestamp pinned mono on the right edge. When the underlying event carries a state change (a role swap, a plan adjustment, a billing-rate update), an optional diff prop unlocks a chevron toggle that reveals a two-column Before / After payload below the head row. The before column tints --error-*; the after column tints --success-* — the same semantic pair the kit uses for the destructive / additive read across other diff-shaped surfaces. Entries without a diff render the head row alone. Used inside <WorkspaceMembersPage> (section 25.6) or composed standalone wherever an audit log needs surfacing — settings change-history, project archive trails, billing event logs. Below 480px the diff panel reflows to a single column and the timestamp drops to its own line.

Five entries — invite, remove, role change (open), billing update, archive

.audit-log-entry

Five stacked audit entries inside an .audit-log-stack wrapper, covering the canonical verb shapes the operator-side log surfaces. Entries 1, 2, and 5 are no-diff events (invite, remove, archive) — the chevron toggle isn’t rendered at all because there’s nothing to expand. Entry 3 (role change) opens with its diff panel expanded (defaultExpanded) to show the Before / After payload — Editor → Admin — without consumer action. Entry 4 (billing update) ships the same shape but starts collapsed: the chevron is visible on the right edge of the head row, the diff panel waits for the click.

Jay Stockwell invited [email protected]
Alex Kim removed [email protected]
Robin Lee changed role for [email protected]
Before Editor
After Admin
Jay Stockwell updated billing for Pro plan
Robin Lee archived Project Alpha
<div class="audit-log-stack">
  <div class="audit-log-entry">
    <div class="audit-log-entry-head">
      <span class="audit-log-entry-avatar">
        <span class="av av-sm" role="img" aria-label="Jay Stockwell">
          <span aria-hidden="true">JS</span>
        </span>
      </span>
      <span class="audit-log-entry-text">
        <strong>Jay Stockwell</strong> invited
        <strong>[email protected]</strong>
      </span>
      <span class="audit-log-entry-timestamp">2 hours ago</span>
    </div>
  </div>
  <div class="audit-log-entry is-open">
    <div class="audit-log-entry-head">
      <span class="audit-log-entry-avatar">…</span>
      <span class="audit-log-entry-text">
        <strong>Robin Lee</strong> changed role for
        <strong>[email protected]</strong>
      </span>
      <span class="audit-log-entry-timestamp">Yesterday at 4:12pm</span>
      <button type="button" class="audit-log-entry-toggle"
              aria-expanded="true" aria-label="Hide diff">
        <svg viewBox="0 0 12 12" aria-hidden="true">…</svg>
      </button>
    </div>
    <div class="audit-log-entry-diff">
      <div class="audit-log-entry-diff-block">
        <span class="audit-log-entry-diff-label">Before</span>
        <span class="audit-log-entry-diff-value is-before">Editor</span>
      </div>
      <div class="audit-log-entry-diff-block">
        <span class="audit-log-entry-diff-label">After</span>
        <span class="audit-log-entry-diff-value is-after">Admin</span>
      </div>
    </div>
  </div>
  <div class="audit-log-entry">
    <!-- Jay Stockwell · updated billing for · Pro plan — collapsed -->
    <div class="audit-log-entry-head">…</div>
  </div>
</div>
.audit-log-stack {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.audit-log-entry {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--hair);
}

.audit-log-entry:last-child {
  border-bottom: 0;
}

.audit-log-entry-head {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--s-3);
}

.audit-log-entry-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.audit-log-entry-text {
  flex: 1;
  min-width: 0;
  font: 400 14px/1.4 var(--f-body);
  color: var(--fg);
}

.audit-log-entry-timestamp {
  font: 500 12px/1 var(--f-mono);
  color: var(--fg-dim);
  white-space: nowrap;
  margin-left: auto;
}

.audit-log-entry-toggle {
  width: 28px;
  height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 0;
  background: transparent;
  color: var(--fg-dim);
  border-radius: var(--r-sm);
  cursor: pointer;
  flex-shrink: 0;
  transition: transform var(--dur-2) var(--ease), background var(--dur-2) var(--ease);
}

.audit-log-entry-toggle:hover {
  background: var(--bg-sunk);
  color: var(--fg);
}

.audit-log-entry-toggle svg {
  width: 12px;
  height: 12px;
  transition: transform var(--dur-2) var(--ease);
}

.audit-log-entry-toggle[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

.audit-log-entry-diff {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-3);
  padding: var(--s-3) 0 0 calc(36px + var(--s-3));
}

.audit-log-entry-diff-block {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.audit-log-entry-diff-label {
  font: 500 10px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--fg-faint);
}

.audit-log-entry-diff-value {
  font: 500 13px/1.3 var(--f-mono);
  font-variant-numeric: tabular-nums;
  padding: 4px 8px;
  border-radius: var(--r-sm);
  background: var(--bg-sunk);
}

.audit-log-entry-diff-value.is-before {
  color: var(--error-text);
}

.audit-log-entry-diff-value.is-after {
  color: var(--success-text);
}

@media (max-width: 480px) {
  .audit-log-entry-diff {
    grid-template-columns: 1fr;
    padding-left: 0;
  }
  .audit-log-entry-timestamp {
    display: block;
    margin-left: 0;
  }
}

/* …additional rules trimmed for brevity — see _shared.css */
import { Avatar, AuditLogEntry } from "@magicblocksai/ui";

<div className="audit-log-stack">
  <AuditLogEntry
    actor={{ name: "Jay Stockwell", avatar: <Avatar size="sm" name="Jay Stockwell" /> }}
    verb="invited"
    target={<strong>[email protected]</strong>}
    timestamp="2 hours ago"
  />
  <AuditLogEntry
    actor={{ name: "Alex Kim", avatar: <Avatar size="sm" name="Alex Kim" /> }}
    verb="removed"
    target={<strong>[email protected]</strong>}
    timestamp="Yesterday"
  />
  <AuditLogEntry
    actor={{ name: "Robin Lee", avatar: <Avatar size="sm" name="Robin Lee" /> }}
    verb="changed role for"
    target={<strong>[email protected]</strong>}
    timestamp="Yesterday at 4:12pm"
    diff={{ before: "Editor", after: "Admin" }}
    defaultExpanded
  />
  <AuditLogEntry
    actor={{ name: "Jay Stockwell", avatar: <Avatar size="sm" name="Jay Stockwell" /> }}
    verb="updated billing for"
    target={<strong>Pro plan</strong>}
    timestamp="Mar 14"
    diff={{ before: "$29/seat", after: "$32/seat" }}
  />
  <AuditLogEntry
    actor={{ name: "Robin Lee", avatar: <Avatar size="sm" name="Robin Lee" /> }}
    verb="archived"
    target={<strong>Project Alpha</strong>}
    timestamp="Feb 28"
  />
</div>

23.5 Team header block

A per-page header for team / workspace surfaces — eyebrow + title + optional member-count pill on the head row, with an Invite CTA pinned to the right edge and an optional filter rail wrapping below. The head row uses flex justify-between so the text stack and actions auto-size against each other; the member-count pill picks up the same mono --bg-sunk / --fg-soft chrome the kit’s other inline-count surfaces wear (chat unread counts, table footer totals) so the read across surfaces is consistent. Composed inside <WorkspaceMembersPage> (section 25.6) or stands alone wherever a team surface needs a header. Below 480px the head reflows to a column and the Invite CTA stretches to full width; the filter rail wraps as needed at any breakpoint.

Workspace · Members · 12 · Invite + 3 filter chips

.team-header-block

The canonical full-slot composition — eyebrow “Workspace”, title “Members”, count pill “12”, Invite CTA on the right, and three filter chips below (All / Admins / Pending invites). Drop the memberCount prop and the inline pill vanishes; drop filters and the rail row disappears entirely so the block ends at the head’s hairline border.

Workspace

Members 12

<header class="team-header-block">
  <div class="team-header-block-head">
    <div class="team-header-block-text">
      <p class="team-header-block-eyebrow">Workspace</p>
      <h1 class="team-header-block-title">
        <span>Members</span>
        <span class="team-header-block-count">12</span>
      </h1>
    </div>
    <div class="team-header-block-actions">
      <button type="button" class="btn">Invite member</button>
    </div>
  </div>
  <div class="team-header-block-filters">
    <button type="button" class="chip">All</button>
    <button type="button" class="chip">Admins</button>
    <button type="button" class="chip">Pending invites</button>
  </div>
</header>
.team-header-block {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  padding-bottom: var(--s-5);
  border-bottom: 1px solid var(--hair);
}

.team-header-block-head {
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--s-4);
}

.team-header-block-text {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  min-width: 0;
}

.team-header-block-eyebrow {
  margin: 0;
  font: 500 11px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-faint);
}

.team-header-block-title {
  margin: 0;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--s-3);
  font: 600 28px/1.2 var(--f-display);
  color: var(--fg);
}

.team-header-block-count {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: var(--r-pill);
  background: var(--bg-sunk);
  color: var(--fg-soft);
  font: 500 12px/1 var(--f-mono);
  font-variant-numeric: tabular-nums;
}

.team-header-block-actions {
  flex-shrink: 0;
}

.team-header-block-filters {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--s-2);
}

@media (max-width: 480px) {
  .team-header-block-head {
    flex-direction: column;
    align-items: stretch;
  }
  .team-header-block-actions {
    width: 100%;
  }
  .team-header-block-actions > .btn {
    width: 100%;
  }
}
import { TeamHeaderBlock } from "@magicblocksai/ui";

<TeamHeaderBlock
  eyebrow="Workspace"
  title="Members"
  memberCount={12}
  inviteAction={
    <button type="button" className="btn">Invite member</button>
  }
  filters={
    <>
      <button type="button" className="chip">All</button>
      <button type="button" className="chip">Admins</button>
      <button type="button" className="chip">Pending invites</button>
    </>
  }
/>

23.6 Workspace members page

A page-shaped wrapper composing the chapter’s five primitives — <TeamHeaderBlock> on top, then the active-members <ul class="member-list">, then the pending-invites <ul class="invite-list">, then the audit-log .audit-log-stack — into the canonical workspace-admin surface. The three lower sections each carry a small uppercase sub-header (.workspace-members-page-section-label) styled as a 16px var(--f-display) 600-weight label in var(--fg-soft); the member-list section uses the <TeamHeaderBlock> as its de facto header so its own sub-header label is suppressed. The wrapper is stateless glue: the audit-log entries carry their own expand/collapse state, the invite-row actions wire the consumer’s handlers, and the team-header block’s count pill auto-binds to members.length so the surface stays in sync as the list grows. Used as the drop-in admin / settings / workspace page; composed primitives carry their own mobile reflow so the page surface reflows naturally at every breakpoint.

Five members · two invites · four audit entries

.workspace-members-page

The canonical full-page composition with realistic seed data — five members (Jay/Admin, Alex/Editor, Robin/Billing, Sasha/Viewer, Morgan/Editor) in the active list, two pending invites (newdev/Editor with resend+revoke, billing@/Billing with revoke-only), and four audit entries covering the canonical verb shapes (invited / changed role with open diff / updated billing with collapsed diff / archived). The header block carries the count pill auto-bound to the member-list length and an Invite member CTA on the right; the lower sections each open with their sub-header label and stack their list / diff payloads beneath.

Members 5

Jay Stockwell invited [email protected]
Robin Lee changed role for [email protected]
Before Editor
After Admin
Jay Stockwell updated billing for Pro plan
Morgan Cole archived Project Alpha
<div class="workspace-members-page">
  <header class="team-header-block">
    <div class="team-header-block-head">
      <div class="team-header-block-text">
        <h1 class="team-header-block-title">
          <span>Members</span>
          <span class="team-header-block-count">5</span>
        </h1>
      </div>
      <div class="team-header-block-actions">
        <button type="button" class="btn">Invite member</button>
      </div>
    </div>
  </header>
  <section class="workspace-members-page-section">
    <ul class="member-list">
      <li class="member-row">…</li>
      <!-- four more <li class="member-row">…</li> -->
    </ul>
  </section>
  <section class="workspace-members-page-section">
    <h2 class="workspace-members-page-section-label">Pending invites</h2>
    <ul class="invite-list">
      <li class="invite-row">…</li>
      <li class="invite-row">…</li>
    </ul>
  </section>
  <section class="workspace-members-page-section">
    <h2 class="workspace-members-page-section-label">Audit log</h2>
    <div class="audit-log-stack">
      <div class="audit-log-entry">…</div>
      <!-- three more entries -->
    </div>
  </section>
</div>
.workspace-members-page {
  display: flex;
  flex-direction: column;
  gap: var(--s-8);
}

.workspace-members-page-section {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.workspace-members-page-section-label {
  margin: 0;
  font: 600 16px/1.2 var(--f-display);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--fg-soft);
}
import { Avatar, WorkspaceMembersPage } from "@magicblocksai/ui";

<WorkspaceMembersPage
  title="Members"
  inviteAction={
    <button type="button" className="btn">Invite member</button>
  }
  members={[
    {
      avatar: <Avatar name="Jay Stockwell" />,
      name: "Jay Stockwell",
      email: "[email protected]",
      role: "admin",
      lastActive: "Active now",
      actions: (
        <button type="button" className="icon-btn"
                aria-label="Member actions for Jay Stockwell">…</button>
      ),
    },
    {
      avatar: <Avatar name="Alex Kim" />,
      name: "Alex Kim",
      email: "[email protected]",
      role: "editor",
      lastActive: "2 hours ago",
      actions: (
        <button type="button" className="icon-btn"
                aria-label="Member actions for Alex Kim">…</button>
      ),
    },
    {
      avatar: <Avatar name="Robin Lee" />,
      name: "Robin Lee",
      email: "[email protected]",
      role: "billing",
      lastActive: "Yesterday",
      actions: (
        <button type="button" className="icon-btn"
                aria-label="Member actions for Robin Lee">…</button>
      ),
    },
    {
      avatar: <Avatar name="Sasha Park" />,
      name: "Sasha Park",
      email: "[email protected]",
      role: "viewer",
      lastActive: "3 weeks ago",
      actions: (
        <button type="button" className="icon-btn"
                aria-label="Member actions for Sasha Park">…</button>
      ),
    },
    {
      avatar: <Avatar name="Morgan Cole" />,
      name: "Morgan Cole",
      email: "[email protected]",
      role: "editor",
      lastActive: "5 minutes ago",
      actions: (
        <button type="button" className="icon-btn"
                aria-label="Member actions for Morgan Cole">…</button>
      ),
    },
  ]}
  invites={[
    {
      email: "[email protected]",
      role: "editor",
      invitedBy: "Jay",
      sentAt: "2 days ago",
      onResend: () => resend("[email protected]"),
      onRevoke: () => revoke("[email protected]"),
    },
    {
      email: "[email protected]",
      role: "billing",
      invitedBy: "Alex",
      sentAt: "5 days ago",
      onRevoke: () => revoke("[email protected]"),
    },
  ]}
  auditLog={[
    {
      actor: { name: "Jay Stockwell", avatar: <Avatar size="sm" name="Jay Stockwell" /> },
      verb: "invited",
      target: <strong>[email protected]</strong>,
      timestamp: "2 hours ago",
    },
    {
      actor: { name: "Robin Lee", avatar: <Avatar size="sm" name="Robin Lee" /> },
      verb: "changed role for",
      target: <strong>[email protected]</strong>,
      timestamp: "Yesterday at 4:12pm",
      diff: { before: "Editor", after: "Admin" },
      defaultExpanded: true,
    },
    {
      actor: { name: "Jay Stockwell", avatar: <Avatar size="sm" name="Jay Stockwell" /> },
      verb: "updated billing for",
      target: <strong>Pro plan</strong>,
      timestamp: "Mar 14",
      diff: { before: "$29/seat", after: "$32/seat" },
    },
    {
      actor: { name: "Morgan Cole", avatar: <Avatar size="sm" name="Morgan Cole" /> },
      verb: "archived",
      target: <strong>Project Alpha</strong>,
      timestamp: "Feb 28",
    },
  ]}
/>

23.7 Settings — Credits

Workspace settings as a two-column shell — left sub-nav (Billing / Credits / Workspaces / Tags / Custom Fields / Availability) and a main pane that renders whichever sub-page is active. This composition shows the Credits view: a hero balance card with a soft accent wash, a Stripe compliance notice, the manual top-up package grid, and the auto top-up card (kit .switch + threshold input + package picker).

Settings shell — Credits sub-page · $1,247.50 balance · auto top-up off

.st-screen · .st-side · .st-balance-card · .st-package-grid

Six packages in a 6-up grid let operators pick the top-up they want; clicking a card sets .is-selected and unlocks the green Top up button. The auto top-up section reads as a single form with three controls (the toggle, the threshold currency input, the package picker) and a Save footer. Empty top-up history at the bottom is the kit's dashed-border empty-state pattern. Sidebar uses the same icon-glyph + active-stripe shape as the library shell (15.12) and the dashboard nav (13.19).

Credits

Manage your credits balance and top-ups for your workspace.

Available credits
$1,247.50 USD
All payments are processed securely via Stripe. Credits are non-refundable once added to your workspace.

Manual top up

Pick a package and pay once via Stripe.

Enable auto top-up

Automatically top up your credits when the balance runs low.

Top-up history

All credit transactions on this workspace.

No top ups yet Your workspace hasn’t made any credit transactions.
<div class="st-screen">
  <aside class="sub-nav st-side">
    <p class="sub-nav-label">Settings</p>
    <a class="sub-nav-item">Billing</a>
    <a class="sub-nav-item is-active">Credits</a>
    <a class="sub-nav-item">Workspaces</a>
    <a class="sub-nav-item">Tags</a>
    <a class="sub-nav-item">Custom Fields</a>
    <a class="sub-nav-item">Availability</a>
  </aside>

  <main class="st-main">
    <header class="st-main-head">
      <h2>Credits</h2>
      <p>Manage your credits balance and top-ups…</p>
    </header>

    <div class="st-tabs">
      <button class="is-active">Credits Overview</button>
      <button>Usage history</button>
    </div>

    <div class="st-balance-card">
      <div class="st-balance-meta">
        <div class="st-balance-label">Available credits</div>
        <div class="st-balance-value">$1,247.50 USD</div>
      </div>
      <div class="st-balance-actions">
        <button class="st-btn">Refresh</button>
      </div>
    </div>

    <div class="st-section-card">
      <h3 class="st-section-card-title">Manual top up</h3>
      <div class="st-package-grid">
        <button class="st-package">$25</button>
        <button class="st-package is-selected">$100</button>
        …
      </div>
      <div class="st-form-footer">
        <button class="is-primary">Top up</button>
      </div>
    </div>

    <div class="st-section-card">
      <div class="st-section-card-head">
        <h3 class="st-section-card-title">Enable auto top-up</h3>
        <label class="switch"><input type="checkbox" /><span class="switch-track"></span></label>
      </div>
      <div class="st-form-row">
        <label>Auto top-up when balance below</label>
        <input class="st-input" value="$ 50.00" />
      </div>
      …package picker + Save…
    </div>
  </main>
</div>
/* Chapter-private settings shell. Similar shape to .lb-screen
   from 15.12 (library) — both are 2-col sub-nav + main patterns.
   When a third consumer wants the same shape, promote to a kit-
   wide .settings-screen-* primitive. */
.st-screen { display: grid; grid-template-columns: 220px 1fr; }
.st-side { background: var(--bg-warm); border-right: 1px solid var(--hair); }
.st-side-item.is-active { background: var(--accent-soft); color: var(--accent); }
.st-balance-card::before {
  background: radial-gradient(420px 280px at 100% -10%,
    color-mix(in oklab, var(--accent) 8%, transparent), transparent 70%);
}
.st-package.is-selected { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
import {
  SubNav,
  SubNavLabel,
  SubNavItem,
  PackageGrid,
  PackageOption,
  PageHeader,
  Tabs,
  Switch,
} from "@magicblocksai/ui";

/* LEGO-piece composition. The .sub-nav-* primitive owns the vertical
   icon+label rail; .st-package-grid owns the radio-grid amount picker.
   Consumers stack <SubNavItem>s + <PackageOption>s to match their
   information architecture. The page body is whatever the section
   needs — balance card, manual top-up grid, auto-top-up toggles. */

export function SettingsCreditsPage({ balance, autoTopUp }) {
  const [amount, setAmount] = useState(100);
  return (
    <div className="st-screen">
      <SubNav>
        <SubNavLabel>Settings</SubNavLabel>
        <SubNavItem href="#credits" active>Credits</SubNavItem>
        <SubNavItem href="#members" count={12}>Members</SubNavItem>
        <SubNavItem href="#workspaces" count={4}>Workspaces</SubNavItem>
        <SubNavItem href="#integrations">Integrations</SubNavItem>
      </SubNav>
      <main className="st-main">
        <PageHeader title="Credits" subtitle="Manage your credits balance and top-ups for your workspace." />
        <Tabs items={["Credits Overview","Usage history"]} />
        <BalanceCard balance={balance} />
        <PackageGrid label="Top-up amount">
          {[25, 50, 100, 200, 500, 1000].map(n => (
            <PackageOption key={n} selected={amount === n} onClick={() => setAmount(n)}>
              ${n}
            </PackageOption>
          ))}
        </PackageGrid>
        <Switch checked={autoTopUp.enabled} /> Auto top-up
      </main>
    </div>
  );
}