/* ============================================================
   @font-face — vendored Inter + JetBrains Mono.
   Files live at ../fonts/ relative to this stylesheet.
   See ../fonts/README.md for upstream URLs and refresh notes.
   ============================================================ */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('../fonts/Inter-Variable.woff2') format('woff2-variations'),
       url('../fonts/Inter-Variable.woff2') format('woff2');
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../fonts/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('../fonts/JetBrainsMono-Bold.woff2') format('woff2');
}

/* ============================================================
   SourceBans++ 2026 — Theme stylesheet
   Plain CSS. No build step. Works with any backend.
   ============================================================ */

/* ---- Tokens ---- */
:root {
  /* Brand */
  --brand-50:  #fff7ed; --brand-100: #ffedd5; --brand-200: #fed7aa;
  --brand-300: #fdba74; --brand-400: #fb923c; --brand-500: #f97316;
  --brand-600: #ea580c; --brand-700: #c2410c; --brand-800: #9a3412;
  --brand-900: #7c2d12; --brand-950: #431407;

  /* Neutrals (zinc) */
  --zinc-50: #fafafa; --zinc-100: #f4f4f5; --zinc-200: #e4e4e7;
  --zinc-300: #d4d4d8; --zinc-400: #a1a1aa; --zinc-500: #71717a;
  --zinc-600: #52525b; --zinc-700: #3f3f46; --zinc-800: #27272a;
  --zinc-900: #18181b; --zinc-950: #09090b;

  /* Semantic (light) */
  --bg-page: var(--zinc-50);
  --bg-surface: #ffffff;
  --bg-muted: var(--zinc-100);
  --border: var(--zinc-200);
  --text: var(--zinc-900);
  --text-muted: var(--zinc-500);
  --text-faint: var(--zinc-400);
  --accent: var(--brand-600);
  --accent-hover: var(--brand-700);
  --accent-soft: var(--brand-50);
  --success: #059669; --success-bg: #ecfdf5;
  --warning: #d97706; --warning-bg: #fffbeb;
  --danger:  #dc2626; --danger-bg:  #fef2f2;
  --info:    #2563eb; --info-bg:    #eff6ff;

  /* Type */
  --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --fs-xs: 0.75rem; --fs-sm: 0.8125rem; --fs-base: 0.875rem;
  --fs-lg: 1rem; --fs-xl: 1.25rem; --fs-2xl: 1.5rem;

  /* Geometry */
  --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem;
  --radius-xl: 0.75rem; --radius-full: 9999px;
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.04);
  --shadow:    0 1px 3px 0 rgb(0 0 0 / 0.06), 0 1px 2px -1px rgb(0 0 0 / 0.06);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.08);
}

html.dark {
  --bg-page: var(--zinc-950);
  --bg-surface: var(--zinc-900);
  --bg-muted: var(--zinc-800);
  --border: var(--zinc-800);
  --text: var(--zinc-100);
  --text-muted: var(--zinc-400);
  --text-faint: var(--zinc-600);
  --accent-soft: rgb(67 20 7 / 0.4);
  --success-bg: rgb(6 78 59 / 0.4);
  --warning-bg: rgb(120 53 15 / 0.4);
  --danger-bg:  rgb(127 29 29 / 0.4);
  --info-bg:    rgb(30 58 138 / 0.4);
}

/* ---- Reset ---- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scrollbar-gutter: stable; }
body {
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--fs-base);
  line-height: 1.5;
  background: var(--bg-page);
  color: var(--text);
  font-feature-settings: "cv02","cv03","cv04","cv11";
  -webkit-font-smoothing: antialiased;
}
button { font: inherit; cursor: pointer; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }

/* ---- App shell ---- */
.app { min-height: 100vh; display: flex; }
.sidebar {
  width: 15rem; flex-shrink: 0; height: 100vh; position: sticky; top: 0;
  background: var(--bg-surface); border-right: 1px solid var(--border);
  display: flex; flex-direction: column;
}
.sidebar__brand { height: 3.5rem; padding: 0 1rem; display: flex; align-items: center; gap: 0.625rem; border-bottom: 1px solid var(--border); }
.sidebar__brand-mark { width: 1.75rem; height: 1.75rem; border-radius: var(--radius-md); background: var(--brand-600); color: white; display: grid; place-items: center; font-weight: 700; font-size: var(--fs-sm); }
.sidebar__nav { flex: 1; overflow-y: auto; padding: 0.75rem 0.5rem; }
.sidebar__section { margin-bottom: 1.25rem; }
.sidebar__section-label { padding: 0 0.75rem 0.375rem; font-size: 0.625rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-faint); }
.sidebar__link {
  display: flex; align-items: center; gap: 0.625rem; padding: 0 0.75rem; height: 2rem;
  border-radius: var(--radius-md); color: var(--text); font-weight: 500;
  text-decoration: none; transition: background-color .15s; width: 100%; border: 0; background: transparent; text-align: left;
}
.sidebar__link:hover { background: var(--bg-muted); }
.sidebar__link[aria-current="page"] { background: var(--zinc-900); color: white; }
/* #1207 CC-4 — dark-theme active nav state. The original
   `bg: var(--zinc-100); color: var(--zinc-900)` was a near-white pill
   sitting on the zinc-900 sidebar surface, which read as "hovered"
   rather than "selected" in the audit screenshots. Re-paint with the
   brand orange so the active row matches the brand and visibly
   differs from the inactive rows above and below it.

   We use `--brand-700` (#c2410c, the existing `--accent-hover` token)
   rather than `--accent` (`--brand-600` = #ea580c) for accessibility:
   `brand-600` on white is ~3.56:1, which clears WCAG AA Large Text
   (3:1) and Non-text (3:1) but FAILS AA Normal Text (4.5:1) for the
   14px / weight-500 nav label. `brand-700` on white is ~5.18:1 —
   clears AA Normal Text. As a side benefit it also stays visually
   distinct from the lighter `--accent` primary CTA, so "selected
   nav" doesn't read as "the orange Save button next to me" on pages
   that surface both at once.

   The `var(--on-accent, #fff)` fallback is intentional shape: a
   future PR that introduces a real `--on-accent` token (and a
   semantic `--accent-strong` paired with brand-700) can drop the
   literal here without re-touching the rule. The light-theme
   treatment above is unchanged — black pill on zinc-50 page reads
   correctly already (~17.7:1). */
html.dark .sidebar__link[aria-current="page"] { background: var(--brand-700); color: var(--on-accent, #fff); }
.sidebar__link-count { margin-left: auto; font-size: 0.625rem; padding: 0 0.375rem; height: 1rem; display: inline-flex; align-items: center; border-radius: var(--radius-sm); background: var(--bg-muted); color: var(--text-muted); font-variant-numeric: tabular-nums; }

.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }

.topbar {
  height: 3.5rem; position: sticky; top: 0; z-index: 30;
  background: rgb(255 255 255 / 0.8); backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--border);
  display: flex; align-items: center; gap: 0.5rem; padding: 0 1.25rem;
}
html.dark .topbar { background: rgb(9 9 11 / 0.8); }
.topbar__breadcrumbs { display: flex; align-items: center; gap: 0.375rem; font-size: var(--fs-base); color: var(--text-muted); }
.topbar__breadcrumbs > [aria-current] { color: var(--text); font-weight: 500; }
/* #1207 CC-1 + CC-3: the topbar palette trigger is icon-only at EVERY
   viewport. CC-1 (slice 1, #1208) collapsed it at <=768px because the
   labelled "search input + Ctrl-K hint" couldn't share a row with the
   breadcrumb + theme toggle on mobile; CC-3 (this slice) extends the
   same collapse to desktop because that labelled chrome was a duplicate
   affordance for the `<dialog id="palette-root">` the ⌘K shortcut
   already opens — both surfaces competed for attention and pulled the
   user's eye twice. The palette itself owns the search semantically;
   the trigger only opens it.

   The button visually matches the sibling theme-toggle (a ghost icon
   button — transparent bg, no border, hover paints `--bg-muted`) so
   the topbar reads as `[hamburger] [breadcrumb] [spacer] [palette]
   [theme]` with two equal-weight icon affordances on the right.

   The .topbar__search-label / .topbar__search-kbd hooks stay in the
   DOM (see core/title.tpl) but are visually hidden everywhere now so:
     - SR users still hear "Open command palette …" via the existing
       aria-label,
     - theme.js's applyPlatformHints() can still rewrite the kbd glyph
       to ⌘K on Mac after first paint without re-rendering — the
       hidden node is the live mutation target,
     - the kbd hints inside the palette result rows (DET-2) reuse the
       same [data-modkey] swap mechanism applyPlatformHints() drives.

   The desktop default size (2.25rem) matches `.btn--icon` so the
   trigger lines up with the theme toggle without a custom rule;
   the <=768px floor below bumps it to 2.75rem (44 CSS px) per the
   slice 1 review's touch-target contract (WCAG 2.1 AAA / Apple HIG /
   Material). */
.topbar__search {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem; padding: 0;
  border-radius: var(--radius-md);
  border: 1px solid transparent; background: transparent;
  color: var(--text-muted); font-size: var(--fs-base);
  transition: background-color .15s, color .15s;
}
.topbar__search:hover { background: var(--bg-muted); color: var(--text); }
.topbar__search-label, .topbar__search-kbd { display: none; }
@media (max-width: 768px) {
  .topbar__search { width: 2.75rem; height: 2.75rem; }
}

/* ---- Theme toggle icon swap ----
   The button renders both <i data-lucide="sun"> and <i data-lucide="moon">
   placeholders; we show whichever matches the resolved theme. theme.js
   only toggles `<html class="dark">` — no JS click work needed here.
   "system" mode resolves to one of the two via applyTheme(); a third
   `monitor` icon for system is a follow-up (#1185). */
.theme-toggle__moon { display: none; }
html.dark .theme-toggle__sun { display: none; }
html.dark .theme-toggle__moon { display: inline-block; }

/* ---- Buttons ---- */
/* Buttons resolve colour through --btn-bg / --btn-color / --btn-border /
   --btn-bg-hover so modifiers (.btn--primary, --secondary, --ghost,
   --danger) override the rendered colour via the cascade. The dark-mode
   base override is wrapped in :where() so its specificity drops to
   (0,1,0) — matching .btn / .btn--primary — which lets later modifier
   declarations win on source order. Without :where(), html.dark .btn
   would have specificity (0,2,1) and outrank every .btn--* modifier
   (orange CTA disappeared in dark mode — #1182). */
.btn {
  --btn-bg: var(--zinc-900);
  --btn-color: white;
  --btn-border: transparent;
  --btn-bg-hover: var(--zinc-800);
  display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
  height: 2.25rem; padding: 0 0.875rem; border-radius: var(--radius-md);
  font-size: var(--fs-base); font-weight: 500;
  border: 1px solid var(--btn-border);
  background: var(--btn-bg); color: var(--btn-color);
  transition: background-color .15s, border-color .15s;
}
.btn:hover { background: var(--btn-bg-hover); }
:where(html.dark) .btn {
  --btn-bg: var(--zinc-100);
  --btn-color: var(--zinc-900);
  --btn-bg-hover: white;
}
.btn--primary { --btn-bg: var(--brand-600); --btn-color: white; --btn-bg-hover: var(--brand-700); }
.btn--secondary {
  --btn-bg: var(--bg-surface);
  --btn-color: var(--text);
  --btn-border: var(--border);
  --btn-bg-hover: var(--bg-muted);
}
/* --btn-border self-reset keeps .btn--ghost transparent even when a
   sibling modifier (.btn--secondary, etc.) had set --btn-border earlier
   in the cascade — see #1183 (icon-only ghost should never show a
   border). */
.btn--ghost {
  --btn-bg: transparent;
  --btn-color: var(--text-muted);
  --btn-border: transparent;
  --btn-bg-hover: var(--bg-muted);
}
.btn--ghost:hover { --btn-color: var(--text); }
.btn--danger { --btn-bg: var(--danger); --btn-color: white; --btn-bg-hover: #b91c1c; }
.btn--sm { height: 2rem; padding: 0 0.75rem; font-size: var(--fs-xs); }
.btn--icon { width: 2.25rem; padding: 0; }

/* #1207 AUTH-2 — "Continue with Steam" contrast in dark theme. The
   button is rendered as `.btn--secondary` (page_login.tpl), which in
   dark mode resolves to `--bg-surface` (zinc-900) on `--bg-page`
   (zinc-950) — about 1.4:1 contrast, so the button reads as "disabled"
   next to the orange primary "Sign in" CTA. Re-paint with Steam's
   brand chrome (#1b2838 / #2a475e — the colours the Steam client itself
   uses) only in dark mode; light theme keeps the secondary surface,
   which already has plenty of contrast against the white card.
   Computed contrast: white on #1b2838 ≈ 14.93:1, clears AAA.

   --- On using `data-testid="login-steam"` as a CSS selector ---
   AGENTS.md treats `data-testid` as a Playwright/screenshot-harness
   hook, not a styling hook. This rule is the first place in the
   theme that keys CSS off a testid, and it's a deliberate trade-off
   over inventing a `.btn--steam` modifier:

     - The testid is already in the DOM (set in #1123 B1) and is
       guaranteed unique to this button — no other surface uses it.
     - Adding a `.btn--steam` modifier would require a template touch
       (page_login.tpl), which the audit explicitly suggested keeping
       CSS-only.
     - The testid contract and the visual contract for THIS button
       are both load-bearing for the same reason — the button is the
       Steam-login affordance — so coupling them is acceptable.

   If a future change renames the testid, this rule needs to follow.
   If a future PR introduces a `.btn--steam` modifier, swap the
   selector to that and drop this comment.

   The lucide gamepad icon picks up `currentColor` from `--btn-color`,
   so the icon also turns white. */
html.dark .btn[data-testid="login-steam"] {
  --btn-bg: #1b2838;
  --btn-color: #fff;
  --btn-border: #1b2838;
  --btn-bg-hover: #2a475e;
}

/* ---- Admin sub-tabs (intra-page section nav) ----
   Pair: web/includes/AdminTabs.php +
   web/themes/default/core/admin_tabs.tpl. The active tab carries
   aria-current="page" (set by the template when $tab.name ==
   $active_tab); the rule below underlines + bolds it so the strip
   no longer reads as an undifferentiated row of buttons (#1186).
   The selector intentionally targets any direct child carrying the
   attribute so it works for both <button> tabs (this template) and
   <a> tabs (page_admin_edit_admins_*.tpl) without per-element
   scoping. ".admin-tabs__back" pushes the Back link to the right
   edge so it visibly separates from the tab cluster. */
.admin-tabs > [aria-current="page"] {
  background: var(--bg-surface);
  color: var(--text);
  border-bottom: 2px solid var(--brand-600);
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
  font-weight: 600;
}
html.dark .admin-tabs > [aria-current="page"] { border-bottom-color: var(--brand-500); }
.admin-tabs__back { margin-left: auto; }

/* #1207 ADM-8: at <=768px the four-tab strip ("Add a ban · Ban
   protests · Ban submissions · Import bans") wraps onto two lines
   and the active-tab orange underline ends up under the wrapped
   second line, not under the active tab. The `flex` on the wrapper
   is the legacy default-wrap behaviour; switch to a horizontally
   scrollable single-line strip with snap points so every tab is
   reachable without wrap. The active tab additionally gets a
   chip-style background so the active state reads at a glance even
   while the underline is partly out of view. The selector chain
   is `> [aria-current="page"]` so it lands on whichever direct
   child carries the marker (works for both <button> and <a> tabs).
   The `.admin-tabs__back` link still right-floats via
   `margin-left: auto`; with `flex-wrap: nowrap` it would push the
   tab cluster off-screen on a narrow viewport, so we drop it to
   the in-flow position at mobile and let the cluster scroll
   independently. */
@media (max-width: 768px) {
  .admin-tabs {
    flex-wrap: nowrap;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .admin-tabs::-webkit-scrollbar { display: none; }
  .admin-tabs > * { flex-shrink: 0; scroll-snap-align: start; }
  .admin-tabs > [aria-current="page"] {
    background: var(--brand-600);
    color: white;
    border-bottom-color: transparent;
    border-radius: var(--radius-md);
  }
  html.dark .admin-tabs > [aria-current="page"] {
    background: var(--brand-500);
    color: var(--zinc-950);
  }
  .admin-tabs__back { margin-left: 0; }
}

/* ---- Inputs ---- */
.input, .select, .textarea {
  width: 100%; height: 2.25rem; padding: 0 0.75rem;
  border-radius: var(--radius-md); background: var(--bg-surface);
  border: 1px solid var(--border); color: var(--text); font-size: var(--fs-base);
  font-family: inherit; transition: border-color .15s, box-shadow .15s;
}
.input:focus, .select:focus, .textarea:focus {
  outline: none; border-color: var(--brand-500); box-shadow: 0 0 0 3px rgb(234 88 12 / 0.15);
}
.textarea { height: auto; padding: 0.625rem 0.75rem; resize: vertical; min-height: 5rem; }
.input--with-icon { padding-left: 2rem; }

/* File-input wrapper (#1189). The native button is hidden via `hidden`
   on the <input>; the styled <label class="btn btn--secondary"> is the
   click target, and the sibling span mirrors the chosen filename. */
.file-input { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }

.label { display: block; font-size: var(--fs-xs); font-weight: 500; color: var(--text); margin-bottom: 0.375rem; }

/* ---- Card ---- */
.card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-xl); }
.card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); }
.card__header h3 { margin: 0; font-size: var(--fs-base); font-weight: 600; color: var(--text); }
.card__header p { margin: 0.25rem 0 0; font-size: var(--fs-xs); color: var(--text-muted); }
.card__body { padding: 1.25rem; }

/* ---- Status pill ---- */
.pill { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0 0.5rem; height: 1.25rem; border-radius: var(--radius-full); font-size: 0.6875rem; font-weight: 500; box-shadow: inset 0 0 0 1px currentColor; }
.pill--permanent { background: var(--danger-bg); color: #b91c1c; }
.pill--active    { background: var(--warning-bg); color: #b45309; }
.pill--expired   { background: var(--bg-muted); color: var(--text-muted); }
.pill--unbanned  { background: var(--success-bg); color: #047857; }
.pill--online    { background: rgb(16 185 129 / 0.15); color: #047857; }
.pill--offline   { background: var(--bg-muted); color: var(--text-muted); }
html.dark .pill--permanent { color: #fca5a5; }
html.dark .pill--active    { color: #fcd34d; }
html.dark .pill--unbanned  { color: #6ee7b7; }
html.dark .pill--online    { color: #6ee7b7; }

/* ---- Ban row state border ---- */
.ban-row { border-left: 3px solid transparent; }
.ban-row--permanent { border-left-color: #ef4444; }
.ban-row--active    { border-left-color: #f59e0b; }
.ban-row--expired   { border-left-color: var(--zinc-300); }
.ban-row--unbanned  { border-left-color: #10b981; }

/* ---- Filter chip ---- */
.chip {
  display: inline-flex; align-items: center; gap: 0.375rem; padding: 0 0.625rem;
  height: 1.75rem; border-radius: var(--radius-full); border: 1px solid var(--border);
  background: var(--bg-surface); color: var(--text-muted);
  font-size: var(--fs-xs); font-weight: 500; transition: background-color .15s, color .15s;
  text-decoration: none;
}
.chip:hover { background: var(--bg-muted); color: var(--text); }
.chip[aria-pressed="true"],
.chip[data-active="true"] { background: var(--zinc-900); color: white; border-color: var(--zinc-900); }
/* #1207 CC-4 — dark-theme active chip (banlist `All / Permanent / Active /
   Expired / Unbanned`, admin/bans `Current / Archive`). The original
   near-white-on-zinc-900 pill blended with the surrounding inactive
   chips; this rule paints the active state with `--brand-700`
   (#c2410c) to match the active sidebar nav above. See the sidebar
   rule's comment for the full rationale on `brand-700` vs `--accent`
   (~5.18:1 vs ~3.56:1 on white text — chip text is 12px / weight 500,
   normal text by WCAG, AA requires 4.5:1).

   The `data-active="true"` and `aria-pressed="true"` selectors are
   #1123 testability hooks the e2e suite already targets — we're only
   changing the visual treatment, not the DOM contract. */
html.dark .chip[aria-pressed="true"],
html.dark .chip[data-active="true"] { background: var(--brand-700); color: var(--on-accent, #fff); border-color: var(--brand-700); }
.chip__dot { width: 0.375rem; height: 0.375rem; border-radius: 50%; }
/* Segmented chip group (e.g. admin/bans Current/Archive). The chips
   inside inherit `.chip` styling; this just lays them out and gives
   the row a consistent gap so it doesn't collapse onto the heading. */
.chip-row { display: inline-flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }

/* ---- Table ---- */
.table { width: 100%; font-size: var(--fs-base); border-collapse: collapse; }
.table thead tr { background: rgb(0 0 0 / 0.02); border-bottom: 1px solid var(--border); }
html.dark .table thead tr { background: rgb(255 255 255 / 0.02); }
.table th { text-align: left; font-size: 0.625rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted); padding: 0.625rem 0.75rem; }
.table th:first-child, .table td:first-child { padding-left: 1.25rem; }
.table th:last-child, .table td:last-child { padding-right: 1.25rem; }
.table tbody tr { border-bottom: 1px solid var(--border); cursor: pointer; }
.table tbody tr:hover { background: var(--bg-muted); }
.table td { padding: 0.75rem; vertical-align: middle; }
/* #1207 ADM-5: row actions are *always* visible — the previous
   `opacity: 0 → 1 on tr:hover` treatment was a discoverability dead
   end (no hover on touch, hostile to `prefers-reduced-motion: reduce`,
   and the audit's blocker for the comms list specifically). The
   queue-row pattern (#1207 PUB-2) already drops the hover gate on
   `<details>`-rendered queues; matching the table here means every
   list surface in the panel exposes its actions the same way. */
.table .row-actions { display: flex; gap: 0.25rem; justify-content: flex-end; }

/* PUB-1 (#1207): banlist column widths + horizontal-scroll fallback.
   With realistic per-row content the auto-layout previously
   compressed the BANNED date to 3 lines, clipped the STATUS header
   to "STA…", and pushed the per-row pill partly off the right edge.
   Two-part fix:

   1. Pin the timestamp / length / admin / status / actions cells to
      one line via `white-space: nowrap` so the column never
      compresses below its natural content width. STATUS additionally
      uses `width: 1%` — the canonical "shrink-to-content" trick for
      table-layout: auto: the browser obeys the smallest width it
      can give the cell without wrapping the content, so the column
      ends up exactly as wide as the natural content of "Status"
      (header) / its pill, freeing the rest of the row for the
      truncate-able Player / Reason / Server columns.
   2. Wrap the `<table>` in `.table-scroll` (page_bans.tpl) so when
      the parent is narrower than the natural sum of column widths
      (1024-1100px viewport zone after the sidebar collapses, plus
      any unusually long player / reason combos) the table scrolls
      horizontally instead of clipping the rightmost column. The
      card's rounded corners stay intact because the card itself
      keeps its own `overflow: hidden`. */
.table-scroll { overflow-x: auto; }
.table .col-length,
.table .col-banned,
.table .col-admin,
.table .col-status,
.table .col-actions { white-space: nowrap; }
.table .col-status { width: 1%; }

/* ---- Avatar ---- */
.avatar { display: inline-grid; place-items: center; border-radius: 50%; color: white; font-weight: 600; flex-shrink: 0; }

/* ---- Toast + drawer + palette ---- */
.toast-stack { position: fixed; top: 1rem; right: 1rem; z-index: 80; display: flex; flex-direction: column; gap: 0.5rem; width: min(22.5rem, calc(100vw - 2rem)); }
.toast { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 0.75rem; display: flex; gap: 0.75rem; animation: slide-in .2s ease; }
@keyframes slide-in { from { transform: translateX(100%); opacity: 0; } }
/* #1207: honour prefers-reduced-motion. The drawer's slide-in
   keyframe runs the element from translateX(100%) to its rest
   position over 250ms; without this guard a Playwright spec that
   measures a bounding box right after `data-drawer-open="true"`
   settles can land mid-animation and read a transform-shifted
   position. AGENTS.md's "Playwright E2E specifics" rule explicitly
   calls out that animations should never gate visibility — this
   is the canonical CSS opt-out for the entire chrome.
   `animation-duration: 0.001s` is preferred over `animation: none`
   because the latter cancels animation events fired at the start
   of an animation; 0.001s lets them fire so any JS that listens
   on `animationend` still works deterministically. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

.drawer-backdrop { position: fixed; inset: 0; background: rgb(9 9 11 / 0.4); backdrop-filter: blur(2px); z-index: 50; }
.drawer { position: fixed; right: 0; top: 0; height: 100%; width: min(35rem, 100vw); background: var(--bg-page); border-left: 1px solid var(--border); box-shadow: var(--shadow-lg); z-index: 51; display: flex; flex-direction: column; animation: slide-in .25s ease; }

/* #1207 CC-2 + DET-1: mobile drawer rescue.
   - The drawer container already collapses to 100vw via the
     `min(35rem, 100vw)` cap above; what overflowed at iPhone-13 width
     was the *content* (long SteamIDs, copy buttons, the four-tab
     strip). The rules below let the id values wrap, give the tab
     strip a deterministic horizontally scrollable lane with snap
     points, and stop ANY mobile-browser-injected `<a href="tel:…">`
     auto-link (the Steam3 / SteamID values look phone-like to
     iOS Safari + Chrome's heuristics, which is the source of the
     pinkish highlight in DET-1) from inheriting tap-to-dial color.
     The header.tpl meta tag is the primary opt-out; this is the
     defensive belt-and-suspenders for Android variants that ignore
     `format-detection`.
   - .drawer__ids overrides the inline `grid-template-columns`
     6rem/1fr declared in player-drawer.tpl + theme.js: at <=768px
     the 6rem label column eats almost a third of a 390px viewport
     and the value column then can't fit a 17-character SteamID +
     copy button without overflow. Drop the label column to 4.5rem
     and let the value wrap (`min-width: 0` lets a grid track
     actually shrink below its content's natural width). */
/* `min-width: 0` lets the 1fr grid track shrink below its content's
   natural min-content size; `overflow-wrap: anywhere` then breaks the
   long SteamID at any character so the value column fits inside a 100vw
   drawer without horizontal scroll. The non-standard `word-break:
   break-word` alias was redundant with `overflow-wrap: anywhere` and
   has been dropped (#1208 review finding 2). */
.drawer .drawer__ids dd,
.drawer .drawer__ban dd { min-width: 0; overflow-wrap: anywhere; }
.drawer a[href^="tel:"],
.drawer a[href^="sms:"] { color: inherit; text-decoration: none; pointer-events: none; }
@media (max-width: 768px) {
  .drawer .drawer__ids,
  .drawer .drawer__ban { grid-template-columns: 4.5rem minmax(0, 1fr) !important; }
  /* Tab strip: when the four labels (Overview · History · Comms ·
     Notes) don't fit, let the strip scroll horizontally with snap
     so each label lands in view as a unit instead of clipping
     mid-word. The `.drawer__tabs` wrapper already has
     `overflow-x:auto` baked into its inline style by
     `renderDrawerBody` — these rules layer the snap behaviour on
     top and ensure each tab button is full-width-shrink-resistant. */
  .drawer .drawer__tabs { scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }
  .drawer .drawer__tabs > [role="tab"] { scroll-snap-align: start; flex-shrink: 0; }
}

/* Page-level scroll lock when the player drawer is open.
   APPLIES AT EVERY VIEWPORT — this is intentional, not a misscoped
   mobile fix. The drawer is a modal-style surface (Linear, Vercel,
   Notion all do the same): while it's open the underlying page
   content is non-interactive context, and letting wheel/touch scroll
   bleed through to the page underneath produces the "I scrolled the
   wrong thing" misclick the audit's CC-2 flagged on mobile. Locking
   page scroll on desktop too keeps the drawer-open state symmetric
   across viewports so a desktop test that asserts the lock matches
   what a mobile user observes.
   The <aside id="drawer-root" data-drawer-open="…"> mirror is set
   by theme.js (showDrawer / closeDrawer) per #1123's "state in
   attributes, not just styling" rule. The rule below is the only
   way to gate body scroll without a JS body class — keeping the
   gate purely declarative means a JS failure that leaves the
   drawer half-rendered also leaves the body scrollable, which is
   strictly better than the alternative (locked-body + invisible
   drawer = trapped user). The selector is intentionally rooted at
   `:has(#drawer-root[data-drawer-open="true"])` so it scopes to
   the actual open state and unlocks automatically on close.
   The mobile sibling rule below ALSO locks <body> because some
   mobile chromes carry their own scroll context on <body> when
   <html> is overflow:hidden (iOS Safari quirk) and the lock has to
   take both.
   Browser baseline note (#1208 review finding 4): `:has()` is a
   Web Baseline 2023 feature (Chrome 105+, Safari 15.4+, Firefox
   121+). This is the codebase's first usage. On older browsers the
   selector silently doesn't match, the lock isn't applied, and the
   page scrolls behind the drawer — the drawer itself still opens /
   closes, so the failure mode is degraded UX, not broken state. */
html:has(#drawer-root[data-drawer-open="true"]) { overflow: hidden; }
@media (max-width: 768px) {
  html:has(#drawer-root[data-drawer-open="true"]) body { overflow: hidden; }
}

/* Sidebar drawer click-dismiss backdrop (#1178). z-index 40 sits
   above page chrome but below `.sidebar.is-open` (z-index 41 below)
   so the open drawer remains tap-targetable. Visibility is gated by
   the [data-visible] mirror that theme.js flips on open/close. */
.sidebar-backdrop { position: fixed; inset: 0; background: rgb(9 9 11 / 0.4); backdrop-filter: blur(2px); z-index: 40; display: none; }
.sidebar-backdrop[data-visible="true"] { display: block; }

.palette-backdrop { position: fixed; inset: 0; background: rgb(9 9 11 / 0.4); backdrop-filter: blur(2px); z-index: 60; display: grid; place-items: start center; padding-top: 10vh; }
.palette { width: min(36rem, calc(100vw - 2rem)); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-xl); box-shadow: var(--shadow-lg); overflow: hidden; }
.palette__input { display: flex; align-items: center; gap: 0.75rem; height: 3rem; padding: 0 1rem; border-bottom: 1px solid var(--border); }
.palette__input input { flex: 1; border: 0; outline: none; background: transparent; color: var(--text); font-size: var(--fs-base); }

/* #1207 DET-2: palette result-row hint group.
   Every player-kind result row (`[data-result-kind="ban"]`) is built
   by `renderPaletteResults` in theme.js and lays out as
     [icon] [name+steam stack] [kbd hint group]
   The `.palette__row-hints` container sits at the right edge via
   `margin-left: auto` and surfaces the two interactions the row
   supports — bare Enter opens the player drawer, Ctrl/Cmd+Enter
   copies the SteamID via `navigator.clipboard.writeText`. The
   keyboard handler lives in theme.js (`handlePaletteCopyShortcut`);
   this CSS just lays out the hints.

   The kbds are server-rendered in non-Mac form ("Ctrl" / "Enter");
   theme.js's `applyPlatformHints` swaps `[data-modkey]` → ⌘ and
   `[data-enterkey]` → ⏎ on Mac after each render so the visible
   glyphs match the platform's modifier vocabulary.

   The label spans (".palette__row-hint-label") collapse at narrow
   viewports so the kbd glyphs alone fit alongside a long player
   name + SteamID — the keyboard interactions still work; the verbose
   prose ("to open drawer", "to copy steamid") just falls back to the
   implied conventions on a tight viewport. The paired `aria-hidden`
   on the wrapper keeps screen readers from announcing the hint group
   over the row's actual content (the SR user navigates the rows via
   arrow keys, not by listening to per-row decoration). */
.palette__row-meta { min-width: 0; flex: 1 1 0; }
.palette__row-hints {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-shrink: 0;
}
.palette__row-hint {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  font-size: 0.625rem;
  color: var(--text-faint);
  white-space: nowrap;
}
.palette__row-hints kbd {
  display: inline-block;
  padding: 0.0625rem 0.25rem;
  border-radius: var(--radius-sm);
  background: var(--bg-page);
  border: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 0.625rem;
  color: var(--text-muted);
  font-weight: 400;
}
@media (max-width: 640px) {
  .palette__row-hint-label { display: none; }
}

/* ---- Skeleton ---- */
@keyframes shimmer { 0% { background-position: -200px 0; } 100% { background-position: calc(200px + 100%) 0; } }
.skel { background: linear-gradient(90deg, var(--zinc-100) 0, var(--zinc-200) 40px, var(--zinc-100) 80px); background-size: 200px 100%; animation: shimmer 1.4s linear infinite; border-radius: var(--radius-sm); }
html.dark .skel { background: linear-gradient(90deg, var(--zinc-800) 0, var(--zinc-700) 40px, var(--zinc-800) 80px); background-size: 200px 100%; }

/* ---- Queue rows (admin submissions / protests) ----
   PUB-2 (#1207): the admin moderation queues (page_admin_bans_*.tpl)
   render each row as a `<details><summary>`. At desktop width the
   row lays out as `[name+steam stack] [date] [Ban] [Remove]
   [Contact]`; at mobile width that same horizontal pack pushes the
   third action ("Contact") off the visible edge and forces the date
   to wrap to two lines.

   The fix promotes the public banlist's card chrome (see
   `responsive-banlist-cards` in #1124) to these queues: at <=768px
   the summary stacks vertically — name+steam on top, then a date /
   action row below — so every action is reachable without a
   horizontal scroll. The `[data-testid="row-action-*"]` hooks
   stay primary (#1123 testability contract).

   The `.queue-row__*` classes carry the layout (instead of inline
   `style="flex-shrink:0"` on each child) so the mobile media query
   can override `flex` / `order` without fighting inline-style
   specificity. Selector qualifies `details.queue-row > summary` so
   the rule only reaches `<details>` rows (current + archive
   submissions and protests). The public banlist's mobile cards use
   `<a class="ban-row …">`, not `<details>`, so they're unaffected
   even if a future template typos `class="queue-row"` onto the
   wrong element. */
details.queue-row > summary {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 1rem;
  cursor: pointer;
  list-style: none;
}
details.queue-row > summary::-webkit-details-marker { display: none; }
.queue-row__body { flex: 1 1 0; min-width: 0; }
.queue-row__date {
  flex-shrink: 0;
  white-space: nowrap;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}
details.queue-row > summary > .row-actions {
  flex-shrink: 0;
  display: flex;
  gap: 0.25rem;
  opacity: 1;
}

@media (max-width: 768px) {
  details.queue-row > summary {
    flex-wrap: wrap;
    align-items: flex-start;
  }
  /* `body` takes the full first row; `date` and `row-actions` share
     a second row below. `order` keeps DOM source order intact for
     screen readers / keyboard nav while letting the visual layout
     match the banlist card pattern. The wrap on row-actions itself
     means a fourth/fifth action (archive flow) re-wraps onto a
     third visual row instead of overflowing the card. */
  .queue-row__body { flex: 1 1 100%; }
  .queue-row__date {
    order: 2;
    flex: 1 1 auto;
    align-self: center;
  }
  details.queue-row > summary > .row-actions {
    order: 3;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 0.375rem;
  }
}

/* ---- Comms mobile card actions ----
   #1207 ADM-5: the comms list's mobile card is split into two siblings
   — a clickable `.ban-card__summary` anchor that filters by SteamID,
   and a `.ban-card__actions` row of Edit / Unmute / Remove / Re-apply
   buttons. The buttons live OUTSIDE the anchor so we don't produce
   nested-interactive HTML; the anchor's tap target stays the full
   summary row (avatar + name + meta + chevron) so the card's primary
   "filter by this player" affordance keeps working unchanged. */
.ban-card__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.375rem;
  padding: 0 1rem 0.75rem;
}

/* ---- Responsive ---- */
[data-mobile-menu] { display: none; }
@media (max-width: 1024px) {
  .sidebar { display: none; }
  .sidebar.is-open { display: flex; position: fixed; inset: 0 auto 0 0; z-index: 41; box-shadow: var(--shadow-lg); }
  [data-mobile-menu] { display: inline-flex; }
}
@media (max-width: 768px) {
  .table { display: none; }
  .ban-cards { display: block; }
  /* #1181: filter chip rows wrap onto multiple lines on mobile
     instead of horizontal-scrolling, so every chip is reachable
     without a swipe. The .scroll-x desktop affordance is the
     overflow fallback when the chip row would otherwise extend
     past a wide viewport. */
  .scroll-x { overflow-x: visible; flex-wrap: wrap; row-gap: 0.5rem; }
}
@media (min-width: 769px) {
  .ban-cards { display: none; }
}

/* ---- Utility classes used by templates ---- */
.flex { display: flex; } .flex-1 { flex: 1; } .flex-col { flex-direction: column; }
.items-center { align-items: center; } .items-start { align-items: flex-start; }
.justify-between { justify-content: space-between; } .justify-end { justify-content: flex-end; }
.gap-1 { gap: 0.25rem; } .gap-2 { gap: 0.5rem; } .gap-3 { gap: 0.75rem; } .gap-4 { gap: 1rem; }
.grid { display: grid; }
.text-xs { font-size: var(--fs-xs); } .text-sm { font-size: var(--fs-sm); }
.text-muted { color: var(--text-muted); } .text-faint { color: var(--text-faint); }
.font-mono { font-family: var(--font-mono); }
.font-medium { font-weight: 500; } .font-semibold { font-weight: 600; }
.tabular-nums { font-variant-numeric: tabular-nums; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.p-4 { padding: 1rem; } .p-5 { padding: 1.25rem; } .p-6 { padding: 1.5rem; }
.m-0 { margin: 0; } .mt-2 { margin-top: 0.5rem; } .mt-4 { margin-top: 1rem; } .mt-6 { margin-top: 1.5rem; }
.mb-2 { margin-bottom: 0.5rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; }
.space-y-3 > * + * { margin-top: 0.75rem; } .space-y-4 > * + * { margin-top: 1rem; } .space-y-6 > * + * { margin-top: 1.5rem; }

/* Hide scrollbars on chip rows */
.scroll-x { overflow-x: auto; scrollbar-width: none; }
.scroll-x::-webkit-scrollbar { display: none; }

/* ---- Empty state (#1207 unified empty-state pattern) ----
   Used everywhere a list / table / queue renders zero rows. Two
   flavours, picked per surface in the template:

     .empty-state              -- first-run (no data exists yet);
                                  template emits a primary CTA
                                  ("Add a ban", "Add a server", …)
                                  gated on the matching ADMIN_* perm.
     .empty-state[data-filtered="true"]
                               -- filtered state (data exists, but
                                  the active filter excludes every
                                  row); CTA is a secondary "Clear
                                  filters" anchor that resets the
                                  search/chip state via plain GET.

   The split follows the AGENTS.md "Empty states" convention. CTAs
   live in `.empty-state__actions` so the icon + heading + body copy
   stay vertically centered while the action row keeps its own gap.
   `.empty-state__icon` uses the muted bg + faint foreground so it
   reads as decorative (the heading + body are the load-bearing
   copy). All children inherit the table/card colours, so dropping
   the block into a `<td colspan>` (banlist/commslist) renders
   identically to a card body. */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 0.5rem;
  padding: 2.5rem 1.5rem;
  color: var(--text-muted);
}
.empty-state__icon {
  width: 2.5rem;
  height: 2.5rem;
  border-radius: var(--radius-full);
  background: var(--bg-muted);
  color: var(--text-faint);
  display: grid;
  place-items: center;
  margin-bottom: 0.25rem;
}
.empty-state__title {
  margin: 0;
  font-size: var(--fs-base);
  font-weight: 600;
  color: var(--text);
}
.empty-state__body {
  margin: 0;
  font-size: var(--fs-sm);
  max-width: 28rem;
}
.empty-state__actions {
  margin-top: 0.75rem;
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  justify-content: center;
}

/* ---- Settings save action row (#1207 SET-2) ----
   The settings page's save row sits at the bottom of a tall form;
   on mobile, with the page's `.p-6` (1.5rem) padding plus the
   footer's own 1rem padding, the button visually butted against the
   "SourceBans++ N/A" credit (#1207 SET-2). Adding a bottom margin
   on the action row + a top border / extra top padding on the
   shared footer below gives the row breathing room without a
   sticky element. The selector is keyed to `.settings-actions` so
   only the settings save row picks up the rule — other forms
   (login, edit ban, …) keep their existing layouts. */
.settings-actions {
  margin-bottom: 1.5rem;
}
@media (max-width: 768px) {
  .settings-actions { margin-bottom: 2rem; }
}

/* ---- Settings fieldset (#1207 ADM-7) ----
   Used by the "Authentication" block on the admin settings page where
   three numeric inputs (auth.maxlife / auth.maxlife.remember /
   auth.maxlife.steam) used to share one row. Pre-fix:
   `.grid + grid-template-columns: repeat(3, 1fr)` left the inputs
   narrower than their labels at desktop, and on mobile the help text
   ("Token lifetimes (in minutes)") sat on the card header, divorced
   from the field it qualified.

   The fix replaces the `.card__header h3 + p` chrome with a real
   `<fieldset>` + `<legend>`, stacks the three inputs vertically, and
   ties each input to its own `.settings-fieldset__help` paragraph via
   `aria-describedby`. The fieldset removes the browser-default
   1px inset border + padding so it visually inherits the parent
   `.card`'s chrome instead of double-painting it.

   The `__legend` row mimics `.card__header`'s shape (title + muted
   sub-line) so the fieldset reads as a card-section heading; the
   `__body` re-applies the same padding `.card__body` uses so the
   inputs sit at the same indent as sibling cards on the same form.

   Inputs are width-clamped at 18rem max — wide enough that the
   `<input type=number>` arrows + a 5-digit minute count both fit, and
   meaningfully wider than the labels so the input never reads as a
   smaller affordance than the prose introducing it. The clamp drops
   to 100% at <=768px so mobile viewports never end up with an
   18rem-locked input clipped under the chrome. */
.settings-fieldset {
  border: 0;
  margin: 0;
  padding: 0;
  min-width: 0;
}
.settings-fieldset__legend {
  display: block;
  width: 100%;
  padding: 1rem 1.25rem;
  border-bottom: 1px solid var(--border);
}
.settings-fieldset__title {
  display: block;
  font-size: var(--fs-base);
  font-weight: 600;
  color: var(--text);
}
.settings-fieldset__hint {
  display: block;
  margin-top: 0.25rem;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}
.settings-fieldset__hint code {
  padding: 0 0.25rem;
  border-radius: var(--radius-sm);
  background: var(--bg-muted);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.875em;
}
.settings-fieldset__body {
  padding: 1.25rem;
}
.settings-fieldset__input {
  width: 100%;
  max-width: 18rem;
}
.settings-fieldset__help {
  margin: 0.375rem 0 0;
  font-size: var(--fs-xs);
  color: var(--text-muted);
  line-height: 1.45;
}
@media (max-width: 768px) {
  .settings-fieldset__input { max-width: 100%; }
}

/* ---- "Your permissions" grid (#1207 ADM-9) ----
   The pre-fix layout was a 2-column `.grid` (web side / SourceMod
   side) where the web side was a flat 30-item bullet list, leaving
   "Add Bans" visually equal to "Edit Groups" — hard to scan at a
   glance and impossible to tell where bans-related flags ended and
   admin-management ones began.

   The fix splits each side into two layers:
     - `.permissions-side` is the outer "Web" / "SourceMod" column.
       It owns the heading + an inner grid that lays out one
       `.permissions-group` `<section>` per category.
     - `.permissions-group` is the per-category card. It carries
       a small heading (`__title`) and the granted-permissions list
       (`__list`).

   Layout schedule:
     - <1024px (mobile + small tablet): single column. The web side
       and the SourceMod side stack; inside each side, every
       `.permissions-group` also stacks. This is the touch-first
       layout per #1123's mobile-first rules.
     - >=1024px (desktop): the two sides sit side-by-side via a
       2-column outer grid (`.permissions-card__body`), and inside
       the web side the categories themselves expand to a 2-column
       grid so an owner with all 7 categories doesn't end up with a
       narrow scroll-strip.
     - >=1280px: the web side bumps to 3 columns so a full-permissions
       owner sees every category in one viewport without scrolling.

   The `.permissions-group__title` style intentionally mirrors the
   "uppercase muted" aesthetic of the original "WEB" / "SERVER"
   subheaders the previous template used, so the visual hierarchy
   feels familiar even though the structure underneath is new. */
.permissions-card__body {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: 1fr;
}
.permissions-side__heading {
  margin: 0 0 0.75rem;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.permissions-grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: 1fr;
}
.permissions-group {
  margin: 0;
  padding: 0.75rem 0.875rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-page);
}
.permissions-group__title {
  margin: 0 0 0.375rem;
  font-size: var(--fs-xs);
  font-weight: 600;
  color: var(--text);
}
.permissions-group__list {
  list-style: disc;
  margin: 0;
  padding-left: 1.125rem;
  font-size: var(--fs-sm);
  color: var(--text);
}
.permissions-group__list li + li { margin-top: 0.125rem; }
.permissions-empty {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-muted);
}
@media (min-width: 1024px) {
  .permissions-card__body {
    grid-template-columns: 2fr 1fr;
    gap: 2rem;
  }
  .permissions-grid--web { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1280px) {
  .permissions-grid--web { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* ---- Footer (#1207 CC-6 + SET-2) ----
   The page-bottom credit footer in core/footer.tpl. A subtle
   top border + a touch more vertical padding visually separates
   it from form action rows above so the "Save changes" button on
   the settings page no longer reads as overlapping the credit
   (SET-2). The link is muted by default — footer renders inside
   `.text-faint` (muted), but a raw <a> would inherit the
   browser-default underlined link colour and stand out as a
   stranded blue word; only reveal the affordance on hover/focus
   so keyboard users still get a visible focus ring (CC-6). */
.app-footer {
  text-align: center;
  padding: 1.25rem 1rem;
  margin-top: 1rem;
  border-top: 1px solid var(--border);
  font-size: var(--fs-xs);
  color: var(--text-faint);
}
.app-footer a {
  color: inherit;
  text-decoration: none;
}
.app-footer a:hover,
.app-footer a:focus-visible {
  color: var(--accent);
  text-decoration: underline;
}

/* ---- Dashboard intro Markdown editor + preview (#1207 SET-1) ----
   Two-column grid that drops to one column on mobile. The textarea
   stays a plain <textarea> by design (#1113 anti-pattern: no WYSIWYG).
   The preview pane on the right is patched in place by the inline
   JS in page_admin_settings_settings.tpl after a debounced call to
   `system.preview_intro_text`, which renders through
   `Sbpp\Markup\IntroRenderer` (CommonMark, html_input=escape,
   allow_unsafe_links=false) — so what visitors see on `/` matches
   exactly. */
.dash-intro-editor {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.75rem;
  align-items: stretch;
}
.dash-intro-editor > .textarea { min-height: 14rem; }
.dash-intro-preview {
  position: relative;
  min-height: 14rem;
  padding: 0.75rem 1rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--bg-page);
  overflow-y: auto;
}
.dash-intro-preview__label {
  position: absolute;
  top: 0.375rem;
  right: 0.5rem;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.dash-intro-preview__body { font-size: var(--fs-sm); }
.dash-intro-preview__body > :first-child { margin-top: 0; }
.dash-intro-preview__body > :last-child  { margin-bottom: 0; }
.dash-intro-preview[data-loading="true"] .dash-intro-preview__body { opacity: 0.6; }
@media (max-width: 768px) {
  .dash-intro-editor { grid-template-columns: 1fr; }
  .dash-intro-preview { min-height: 8rem; }
}

/* ============================================================
   #1207 ADM-3 — admin-admins page-level table of contents
   ============================================================
   The audit flagged admin-admins as ~7 stacked surfaces (search +
   admins list + add admin + overrides + add override) on one long
   scroll. The fix wraps the page in a sticky anchor sidebar at
   >=1024px and an accordion ToC at <1024px, with each section
   anchored via `id="…"` + `scroll-margin-top` so jumps clear the
   sticky topbar (3.5rem).

   Markup contract (see admin.admins.toc.tpl + page_admin_admins_*.tpl):
     .admin-admins-shell           outer wrapper (grid host on desktop)
       .admin-admins-toc           <aside> with the link list
         .admin-admins-toc__details      <details open> on every viewport;
                                         the open/close toggle only does
                                         something at <1024px (mobile/
                                         narrow-tablet accordion). At
                                         >=1024px we force the contents
                                         visible regardless of [open]
                                         state — see the desktop block.
         .admin-admins-toc__summary      mobile-only chrome (hidden on
                                         desktop)
         .admin-admins-toc__nav          the link container
         .admin-admins-toc__list         <ul>
         .admin-admins-toc__link         each <a>
       .admin-admins-content        the cross-template content column
         .admin-admins-section      the anchored <section>s
         .admin-admins-section__heading  per-section <h2>
*/
.admin-admins-section { scroll-margin-top: 4rem; }
.admin-admins-section__heading {
  font-size: var(--fs-base);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted);
  margin: 0 0 0.75rem;
}
.admin-admins-toc {
  font-size: var(--fs-sm);
  margin-bottom: 1rem;
}
.admin-admins-toc__details {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-surface);
}
.admin-admins-toc__summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.625rem 0.875rem;
  font-weight: 600;
  cursor: pointer;
  list-style: none;
  user-select: none;
}
.admin-admins-toc__summary::-webkit-details-marker { display: none; }
.admin-admins-toc__summary-label { display: inline-flex; align-items: center; gap: 0.5rem; }
.admin-admins-toc__chevron {
  transition: transform .15s ease;
  color: var(--text-muted);
}
.admin-admins-toc__details[open] .admin-admins-toc__chevron { transform: rotate(180deg); }
.admin-admins-toc__nav { padding: 0 0.5rem 0.5rem; }
.admin-admins-toc__list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
}
.admin-admins-toc__link {
  display: block;
  padding: 0.4375rem 0.625rem;
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  text-decoration: none;
  transition: background-color .12s, color .12s;
}
.admin-admins-toc__link:hover,
.admin-admins-toc__link:focus-visible {
  background: var(--bg-muted);
  color: var(--text);
  outline: none;
}
@media (prefers-reduced-motion: reduce) {
  .admin-admins-toc__chevron { transition: none; }
  .admin-admins-toc__link    { transition: none; }
}

/* Desktop layout — anchor sidebar floats next to the content column.
   The sidebar is `position: sticky`, scoped to the shell, so it stays
   pinned beneath the topbar (top: 4rem) while the user scrolls
   through the sections. We force the <details> contents visible
   regardless of [open] so accidental "collapse" at desktop can't
   strand the user without navigation. */
@media (min-width: 1024px) {
  .admin-admins-shell {
    display: grid;
    grid-template-columns: 14rem minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
  }
  .admin-admins-toc {
    position: sticky;
    top: 4rem;
    align-self: start;
    margin-bottom: 0;
  }
  .admin-admins-toc__details {
    border: none;
    background: transparent;
  }
  .admin-admins-toc__summary { display: none; }
  /* Keep the link list visible whether the user has toggled the
     <details> closed or not — at desktop the sidebar is a permanent
     navigation surface, the accordion gesture only matters at mobile. */
  .admin-admins-toc__details .admin-admins-toc__nav { display: block !important; }
  .admin-admins-toc__nav { padding: 0; }
  .admin-admins-toc__link { font-size: var(--fs-sm); }
  .admin-admins-content { min-width: 0; }
}
