/* ==========================================================================
   VotingSite · Minecraft server list
   Dark by default, light theme under html[data-theme="light"].
   No external fonts / no CDN (CSP-safe). Everything is system or generic.
   ========================================================================== */

/* --------------------------------------------------------------- tokens -- */
:root {
  --bg: #0b0f14;
  --bg-elev: #0f141b;
  --panel: #131922;
  --panel-2: #182030;
  --border: #1f2733;
  --border-soft: #19202b;
  --text: #e6edf3;
  --muted: #8b949e;
  --accent: #3fb950;
  --accent-dim: #2a7f38;
  --accent-ink: #06170a;
  --gold: #d29922;
  --cyan: #4dd0e1;
  --danger: #f85149;
  --shadow: 0 1px 2px rgba(0, 0, 0, .5), 0 8px 24px rgba(0, 0, 0, .35);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);

  --r: 8px;
  --r-sm: 5px;
  --r-lg: 12px;
  --gap: 16px;
  --maxw: 1180px;

  --font: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  /* "Minecraft-ish": chunky, slightly condensed, falls back to system UI */
  --font-display: "Minecraftia", "Pixelated MS Sans Serif", "Trebuchet MS",
    "Segoe UI Semibold", var(--font);
  --font-mono: ui-monospace, "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
}

html[data-theme="light"] {
  --bg: #f2f5f8;
  --bg-elev: #ffffff;
  --panel: #ffffff;
  --panel-2: #f6f8fa;
  --border: #d5dde5;
  --border-soft: #e5eaf0;
  --text: #14202b;
  --muted: #5b6875;
  --accent: #1f8a34;
  --accent-dim: #1a7a2c;
  --accent-ink: #ffffff;
  --gold: #9a6d05;
  --cyan: #0f7c8c;
  --danger: #c62b22;
  --shadow: 0 1px 2px rgba(16, 32, 48, .08), 0 8px 24px rgba(16, 32, 48, .08);
  --shadow-sm: 0 1px 2px rgba(16, 32, 48, .08);
}

/* ----------------------------------------------------------------- base -- */
*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 15px/1.55 var(--font);
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

h1, h2, h3, h4 {
  font-family: var(--font-display);
  letter-spacing: .2px;
  line-height: 1.2;
  margin: 0 0 .5em;
}
h1 { font-size: clamp(1.6rem, 1.1rem + 2.2vw, 2.5rem); }
h2 { font-size: 1.35rem; }
h3 { font-size: 1.1rem; }

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

img { max-width: 100%; display: block; }

code, kbd, pre { font-family: var(--font-mono); font-size: .9em; }

hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }

:focus-visible {
  outline: 2px solid var(--cyan);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

.container {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: 16px;
}

.muted { color: var(--muted); }
.muted a { color: inherit; text-decoration: underline; }

/* --------------------------------------------------------------- header -- */
.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  background: color-mix(in srgb, var(--bg-elev) 88%, transparent);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--border);
}
.site-header > .container,
.site-header nav,
.site-header .inner {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 60px;
  flex-wrap: wrap;
}

.logo {
  font-family: var(--font-display);
  font-size: 1.2rem;
  font-weight: 700;
  letter-spacing: .5px;
  color: var(--text);
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-right: 6px;
  line-height: 1;
}

/* The wordmark's ink offset, corrected on the text alone.
   Rasterising Minecraftia shows its glyphs never reach the baseline at all: a
   cap 'M' at 19.2px inks from 29px down to 13px ABOVE the baseline, and even
   the descender of 'p' stops 10px above it. That rules out `text-box: trim-both
   cap alphabetic` · the alphabetic edge is nowhere near the visible glyphs, so
   the trim lands in the wrong place (tried, measured, no change to the gap).
   Measured directly instead: the cap midline sits 0.39em above where flex
   centring puts it, which left the wordmark 7.5px high against the cube.
   The value comes from the cap metrics of 'M', not from the word itself, so it
   holds if config.siteName changes. The old fix was a flat 1px on the whole <a>,
   which moved the cube too and so could never close the gap. */
.logo-word { transform: translateY(.39em); }
.logo:hover { text-decoration: none; color: var(--accent); }
/* The mark is the real <span class="logo-cube"> in the markup. It used to be an
   unstyled 0x0 span sitting next to a ::before that drew the square, so the flex
   gap counted twice and the wordmark sat off-centre. One element, one gap. */
.logo-cube {
  width: 18px;
  height: 18px;
  flex: none;
  border-radius: 3px;
  background:
    linear-gradient(135deg, var(--accent) 0 50%, var(--accent-dim) 50% 100%);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .35) inset;
}

/* Had no rule at all, so the language select, theme toggle, Log in and Sign up
   ran together with no spacing and no vertical centring. */
.header-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  /* Deliberately no `margin-left:auto` and no flex-grow on .nav · the header
     container wraps, so anything that eats the free space pushes this block
     onto a second row. Let it sit naturally after the nav. */
}
.header-actions .btn { white-space: nowrap; }

.nav { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.nav a {
  color: var(--muted);
  padding: 7px 11px;
  border-radius: var(--r-sm);
  font-weight: 600;
  font-size: .92rem;
  white-space: nowrap;
}
.nav a:hover { background: var(--panel); color: var(--text); text-decoration: none; }
.nav a.active, .nav a[aria-current] { color: var(--text); background: var(--panel-2); }

.site-header .spacer, .nav + .spacer { flex: 1 1 auto; margin-left: auto; }

.lang-switch { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
.lang-switch select,
select, input, textarea {
  font: inherit;
  color: var(--text);
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 7px 9px;
}
.lang-switch select { padding: 6px 8px; cursor: pointer; }
select:hover { border-color: var(--muted); }

#theme-toggle {
  min-width: 38px;
  padding-inline: 10px;
  font-size: 1rem;
  line-height: 1;
}

/* -------------------------------------------------------------- buttons -- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font: 600 .92rem/1 var(--font);
  color: var(--text);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: var(--r);
  padding: 10px 16px;
  cursor: pointer;
  white-space: nowrap;
  transition: transform .08s ease, background .15s ease, border-color .15s ease, box-shadow .15s ease;
}
/* Base hover for a plain .btn. Every coloured variant below MUST restate its own
   background on :hover · this rule is (0,2,0) and a bare `.btn-vote` is (0,1,0),
   so a variant that only sets a base background loses its colour on hover.
   That was the white-on-white VOTE and Sign up in light mode. */
.btn:hover { background: var(--panel); border-color: var(--muted); text-decoration: none; }
.btn:active { transform: translateY(1px); }
.btn[disabled], .btn.is-loading { opacity: .6; cursor: not-allowed; pointer-events: none; }

.btn-primary {
  background: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 70%, #000);
  color: var(--accent-ink);
  box-shadow: 0 2px 0 color-mix(in srgb, var(--accent) 55%, #000);
}
.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, #fff); }

.btn-ghost { background: transparent; border-color: var(--border); color: var(--muted); }
.btn-ghost:hover { color: var(--text); background: var(--panel); }

.btn-vote {
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--accent) 92%, #fff) 0%, var(--accent) 55%, var(--accent-dim) 100%);
  border: 1px solid color-mix(in srgb, var(--accent) 60%, #000);
  color: var(--accent-ink);
  font-family: var(--font-display);
  font-size: 1.05rem;
  letter-spacing: .5px;
  text-transform: uppercase;
  padding: 14px 28px;
  border-radius: var(--r);
  box-shadow: 0 3px 0 color-mix(in srgb, var(--accent) 45%, #000),
              0 6px 18px color-mix(in srgb, var(--accent) 28%, transparent);
}
.btn-vote:hover {
  /* Restates the gradient and border · see the note on .btn:hover. */
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--accent) 92%, #fff) 0%, var(--accent) 55%, var(--accent-dim) 100%);
  border-color: color-mix(in srgb, var(--accent) 60%, #000);
  filter: brightness(1.06);
}
.btn-vote:active { transform: translateY(2px); box-shadow: 0 1px 0 color-mix(in srgb, var(--accent) 45%, #000); }

/* Used on the primary CTA of the claim, attribute-survey, reminder and form
   pages · seven views asked for a large button and got a default-sized one,
   because the class had no rule. Scales padding and type, not the colour. */
.btn-lg {
  padding: 12px 22px;
  font-size: 1rem;
  border-radius: var(--r);
}

/* Spinner injected by app.js (span.spinner inside a button) */
.spinner {
  width: 14px; height: 14px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  display: inline-block;
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* --------------------------------------------------------- cards / bits -- */
.card {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r);
  padding: 18px;
  margin-bottom: var(--gap);
  box-shadow: var(--shadow-sm);
}
.card > :last-child { margin-bottom: 0; }

.card-title {
  font-family: var(--font-display);
  font-size: 1.02rem;
  font-weight: 700;
  letter-spacing: .3px;
  margin: 0 0 12px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--border-soft);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}

.badge {
  display: inline-block;
  font: 600 .72rem/1 var(--font);
  letter-spacing: .4px;
  text-transform: uppercase;
  padding: 5px 8px;
  border-radius: 4px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  color: var(--muted);
  vertical-align: middle;
}

.flash {
  border: 1px solid var(--border);
  border-left-width: 4px;
  border-radius: var(--r-sm);
  padding: 11px 14px;
  margin-bottom: var(--gap);
  font-size: .94rem;
}
.flash.ok {
  border-left-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
.flash.err {
  border-left-color: var(--danger);
  background: color-mix(in srgb, var(--danger) 12%, var(--panel));
}

/* ----------------------------------------------------------------- hero -- */
.hero {
  position: relative;
  overflow: hidden;
  border-bottom: 1px solid var(--border);
  padding: 46px 0 40px;
  background:
    radial-gradient(70% 120% at 50% -20%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%),
    var(--bg-elev);
  text-align: center;
}
/* subtle blocky grid + noise, purely decorative */
.hero::before {
  content: "";
  position: absolute; inset: 0;
  background-image:
    linear-gradient(to right, color-mix(in srgb, var(--text) 6%, transparent) 1px, transparent 1px),
    linear-gradient(to bottom, color-mix(in srgb, var(--text) 6%, transparent) 1px, transparent 1px);
  background-size: 32px 32px;
  mask-image: radial-gradient(80% 70% at 50% 30%, #000 20%, transparent 78%);
  pointer-events: none;
}
.hero > * { position: relative; }
.hero h1 { margin-bottom: 10px; text-shadow: 0 2px 0 rgba(0, 0, 0, .35); }
html[data-theme="light"] .hero h1 { text-shadow: none; }
.hero p { color: var(--muted); max-width: 620px; margin: 0 auto 22px; font-size: 1.02rem; }

.searchbar {
  display: flex;
  gap: 8px;
  max-width: 620px;
  margin: 0 auto;
}
.searchbar input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 12px 14px;
  border-radius: var(--r);
  background: var(--panel);
  font-size: 1rem;
}
.searchbar input::placeholder { color: var(--muted); }
.searchbar button { padding-inline: 20px; border-radius: var(--r); }

/* --------------------------------------------------------------- layout -- */
.grid-2 {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  gap: 22px;
  align-items: start;
  padding: 26px 0 40px;
}
.grid-2 > aside { position: sticky; top: 76px; }
@media (max-width: 900px) {
  .grid-2 { grid-template-columns: 1fr; gap: 16px; }
  .grid-2 > aside { position: static; }
}

/**
 * Honeypot. `.hp` had NO rule at all, so the bot trap rendered as a visible
 * empty box under the vote captcha · and the vote route rejects any request
 * that fills it in ("Bot detected"). Real people were being blocked by a field
 * that was never supposed to be on screen.
 *
 * Positioned off-screen rather than display:none · some bots skip hidden
 * inputs, which would defeat the trap.
 */
.hp {
  position: absolute !important;
  left: -9999px !important;
  width: 1px !important;
  height: 1px !important;
  opacity: 0 !important;
  pointer-events: none;
}

main {
  flex: 1 0 auto;
  /* The header is sticky, so page content started flush underneath it · on any
     page whose first element is a bare <h1> (dashboard, legal, auth) the
     heading was clipped by the header rather than sitting below it. */
  padding-block-start: 22px;
}
/* Anything an in-page #anchor jumps to must clear the sticky header too. */
main :target { scroll-margin-top: 78px; }

.site-footer {
  border-top: 1px solid var(--border);
  background: var(--bg-elev);
  color: var(--muted);
  font-size: .88rem;
  padding: 26px 0;
  margin-top: auto;
}
.site-footer a { color: var(--muted); text-decoration: none; }
.site-footer a:hover { color: var(--text); text-decoration: underline; }

/* `.footer-inner` and `.footer-links` shipped in the markup with no rules at all,
   so the 15 nav links collapsed into one run-on underlined sentence. The inner
   row is the two columns (brand blurb / link grid); the links are a wrapping
   grid so they read as columns instead of prose. */
.footer-inner {
  display: flex;
  flex-wrap: wrap;
  gap: 24px 40px;
  align-items: flex-start;
  justify-content: space-between;
}
.footer-inner > :first-child { flex: 1 1 16rem; max-width: 34rem; }
.footer-inner strong {
  font-family: var(--font-display);
  font-size: 1rem;
  color: var(--text);
  letter-spacing: .3px;
  display: block;
  margin-bottom: 6px;
}
.footer-inner p { margin: 0; }

.footer-links {
  flex: 1 1 24rem;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
  gap: 6px 18px;
  align-content: start;
}
.footer-links a {
  padding: 3px 0;
  line-height: 1.5;
  border-radius: var(--r-sm);
}

/* -------------------------------------------------------- sponsor strip -- */
.sponsored-strip {
  display: flex;
  gap: 10px;
  overflow-x: auto;
  padding: 10px 0 14px;
  scrollbar-width: thin;
}
.sponsored-strip > * {
  flex: 0 0 auto;
  background: var(--panel);
  border: 1px solid var(--gold);
  border-radius: var(--r);
  padding: 10px 14px;
  font-weight: 600;
  font-size: .9rem;
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--gold) 20%, transparent),
              0 0 18px color-mix(in srgb, var(--gold) 12%, transparent);
}

/* --------------------------------------------------------- server table -- */
.server-table {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r);
  overflow: hidden;
}
.server-table th {
  text-align: left;
  font: 600 .72rem/1 var(--font);
  letter-spacing: .7px;
  text-transform: uppercase;
  color: var(--muted);
  background: var(--panel-2);
  padding: 12px 14px;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
.server-table td {
  padding: 14px;
  border-bottom: 1px solid var(--border-soft);
  vertical-align: middle;
}
.server-table tbody tr:last-child td { border-bottom: 0; }
.server-table tbody tr { transition: background .15s ease; }
.server-table tbody tr:hover { background: var(--panel-2); }

/* Premium row tiers · progressively fancier -------------------------------- */
.row-premium-1 td {
  background: color-mix(in srgb, var(--accent) 7%, transparent);
}
.row-premium-1 td:first-child { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 60%, transparent); }

.row-premium-2 td {
  background: color-mix(in srgb, var(--gold) 9%, transparent);
}
.row-premium-2 td:first-child { box-shadow: inset 4px 0 0 var(--gold); }
.row-premium-2:hover td { background: color-mix(in srgb, var(--gold) 14%, transparent); }
.row-premium-2 .srv-name { color: var(--gold); text-shadow: 0 0 12px color-mix(in srgb, var(--gold) 45%, transparent); }

.row-premium-3 { position: relative; isolation: isolate; }
.row-premium-3 td {
  background: color-mix(in srgb, var(--cyan) 8%, transparent);
  border-bottom-color: color-mix(in srgb, var(--cyan) 25%, transparent);
  border-top: 1px solid color-mix(in srgb, var(--cyan) 25%, transparent);
}
.row-premium-3 td:first-child {
  box-shadow: inset 4px 0 0 var(--cyan);
  position: relative;
}
/* animated diamond shimmer on the leading edge */
.row-premium-3 td:first-child::after {
  content: "";
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 4px;
  background: linear-gradient(180deg, var(--cyan), #ffffff, var(--accent), var(--cyan));
  background-size: 100% 300%;
  animation: diamond-slide 3.5s linear infinite;
}
.row-premium-3 .srv-name {
  color: var(--cyan);
  text-shadow: 0 0 14px color-mix(in srgb, var(--cyan) 55%, transparent);
}
@keyframes diamond-slide {
  0% { background-position: 0 0; }
  100% { background-position: 0 -300%; }
}

/* rank ------------------------------------------------------------------- */
.rank {
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--muted);
  text-align: center;
  min-width: 46px;
  display: inline-block;
}
.rank-1, .rank-2, .rank-3 {
  color: #10161e;
  width: 40px; height: 40px;
  line-height: 40px;
  border-radius: 50%;
  font-size: 1.15rem;
  text-align: center;
  box-shadow: inset 0 -2px 0 rgba(0, 0, 0, .28), 0 0 14px rgba(0, 0, 0, .3);
}
.rank-1 { background: linear-gradient(160deg, #ffdf6e, var(--gold)); }
.rank-2 { background: linear-gradient(160deg, #e8edf2, #a8b3bf); }
.rank-3 { background: linear-gradient(160deg, #e0a678, #a4643a); color: #1b0f07; }

/* server cell ------------------------------------------------------------ */
.srv-cell { display: flex; gap: 12px; align-items: flex-start; min-width: 0; }
/* Renders either a real server icon (<img>) or the 2-letter fallback. The
   fallback used to sit top-left because only the <img> properties were set. */
.srv-icon {
  width: 64px; height: 64px;
  flex: 0 0 64px;
  border-radius: var(--r);
  border: 1px solid var(--border);
  background: var(--panel-2);
  object-fit: cover;
  image-rendering: pixelated;
  /* Centring for the initials case; harmless on an <img>. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-size: 1.1rem;
  letter-spacing: .5px;
  color: var(--muted);
  overflow: hidden;
}
/* The initials fallback. serverIcon() computes a per-server hue from the slug
   and ships it as `style="--h:<n>"`, but nothing ever read that variable · so
   every generated icon rendered in the same flat --panel-2 grey and the hue was
   computed and thrown away. Reading it here is what makes the fallback icons
   tell servers apart at a glance. The initials also need the display font's
   metrics correction: the glyphs sit high in their box, so centring the box
   left them riding above the middle of the tile. Same fix as .logo/.rank-num. */
.srv-icon-gen {
  background:
    linear-gradient(140deg,
      hsl(var(--h, 140) 45% 30%),
      hsl(var(--h, 140) 40% 18%));
  border-color: hsl(var(--h, 140) 35% 34%);
  color: hsl(var(--h, 140) 55% 82%);
  line-height: 1;
  padding-top: 2px;
}
html[data-theme="light"] .srv-icon-gen {
  background:
    linear-gradient(140deg,
      hsl(var(--h, 140) 62% 88%),
      hsl(var(--h, 140) 55% 78%));
  border-color: hsl(var(--h, 140) 40% 68%);
  color: hsl(var(--h, 140) 55% 26%);
}

.srv-cell > div { min-width: 0; }
.srv-name {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--text);
  display: inline-block;
  margin-bottom: 4px;
  overflow-wrap: anywhere;
}
.srv-name:hover { color: var(--accent); text-decoration: none; }

.srv-ip {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: .8rem;
  color: var(--muted);
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 3px 7px;
  margin-bottom: 6px;
  overflow-wrap: anywhere;
}

.srv-tags { display: flex; flex-wrap: wrap; gap: 5px; }
.srv-tags span {
  font-size: .72rem;
  font-weight: 600;
  color: var(--muted);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 3px 7px;
  white-space: nowrap;
}
.srv-tags span:hover { color: var(--text); border-color: var(--muted); }
/* The version chip read identically to the gameplay tags next to it, so "1.20"
   looked like a tag someone had typed. It is data, not a tag · mono, like every
   other literal value on the site (.srv-ip, .mono). */
.srv-tags .srv-ver {
  font-family: var(--font-mono);
  font-size: .68rem;
  letter-spacing: 0;
}

/* players / status ------------------------------------------------------- */
.players { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 600; }
.dot {
  display: inline-block;
  width: 9px; height: 9px;
  border-radius: 50%;
  margin-right: 7px;
  vertical-align: 1px;
}
.dot.online {
  background: var(--accent);
  box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 70%, transparent);
  animation: pulse 2s ease-out infinite;
}
.dot.offline { background: var(--danger); opacity: .8; }
@keyframes pulse {
  70% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
  100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); }
}

.votes { white-space: nowrap; font-variant-numeric: tabular-nums; }
.votes strong {
  font-family: var(--font-display);
  font-size: 1.15rem;
  color: var(--accent);
  display: block;
}

/* tier badge ------------------------------------------------------------- */
.tier-badge {
  display: inline-block;
  font: 700 .68rem/1 var(--font);
  letter-spacing: .8px;
  text-transform: uppercase;
  padding: 5px 8px;
  border-radius: 4px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--muted);
}
.tier-badge[data-tier="VIP"] {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
  background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.tier-badge[data-tier="PREMIUM"] {
  color: var(--gold);
  border-color: color-mix(in srgb, var(--gold) 60%, transparent);
  background: color-mix(in srgb, var(--gold) 14%, transparent);
}
.tier-badge[data-tier="DIAMOND"] {
  color: var(--cyan);
  border-color: color-mix(in srgb, var(--cyan) 60%, transparent);
  background: color-mix(in srgb, var(--cyan) 14%, transparent);
  box-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 22%, transparent);
}

/* stacked-card table under 720px (uses td[data-label] emitted server-side) */
@media (max-width: 720px) {
  .server-table, .server-table tbody, .server-table tr, .server-table td { display: block; width: 100%; }
  .server-table { border: 0; background: transparent; border-radius: 0; overflow: visible; }
  .server-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }
  .server-table tbody tr {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: var(--r);
    margin-bottom: 12px;
    padding: 4px 0;
    overflow: hidden;
  }
  .server-table td {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 9px 14px;
    border-bottom: 1px solid var(--border-soft);
  }
  .server-table td:last-child { border-bottom: 0; }
  .server-table td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 auto;
    font: 600 .7rem/1 var(--font);
    letter-spacing: .6px;
    text-transform: uppercase;
    color: var(--muted);
  }
  /* the server cell spans full width, no label gutter */
  .server-table td:has(.srv-cell) { display: block; }
  .server-table td:has(.srv-cell)::before { display: none; }
  .server-table td:has(.rank) { justify-content: flex-start; }
  .row-premium-1 td:first-child,
  .row-premium-2 td:first-child,
  .row-premium-3 td:first-child { box-shadow: none; }
  .row-premium-1 tr, .row-premium-1 { border-left: 3px solid var(--accent); }
  .row-premium-2 { border-left: 4px solid var(--gold); }
  .row-premium-3 { border-left: 4px solid var(--cyan); }
  .row-premium-3 td:first-child::after { display: none; }
  .btn-vote { width: 100%; }
}

/* ------------------------------------------------------------- pagination */
.pager {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
  padding: 22px 0 6px;
}
.pager a, .pager span {
  min-width: 38px;
  text-align: center;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  background: var(--panel);
  color: var(--muted);
  font-weight: 600;
  font-size: .9rem;
}
.pager a:hover { color: var(--text); border-color: var(--muted); text-decoration: none; }
.pager .active, .pager a.active {
  background: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 65%, #000);
  color: var(--accent-ink);
}

/* ---------------------------------------------------------- detail page -- */
.server-hero {
  position: relative;
  border: 1px solid var(--border);
  border-radius: var(--r);
  overflow: hidden;
  min-height: 220px;
  display: flex;
  align-items: flex-end;
  background: var(--panel-2) center / cover no-repeat;
  margin-bottom: var(--gap);
  padding: 22px;
}
.server-hero::before {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(3, 6, 10, .15) 0%, rgba(3, 6, 10, .82) 78%);
}
.server-hero > * { position: relative; z-index: 1; color: #f2f7fb; }
.server-hero h1 { text-shadow: 0 2px 8px rgba(0, 0, 0, .7); margin-bottom: 6px; }
.server-hero img { border-radius: var(--r); }

/* ---- the hero is a DARK surface in BOTH themes --------------------------- */
/* `.server-hero::before` lays a near-black scrim (up to rgba(3,6,10,.82)) over
   the banner whatever the theme, and the content sits at the bottom where that
   scrim is darkest. But everything inside took its colour from the page-level
   tokens, which in light mode are tuned for a white page · so the MOTD line
   rendered dark grey on near-black and was effectively invisible, with the
   chips, badges and Save label only marginally better.
   `.server-hero > *` set light ink, but only on DIRECT children; the MOTD spans,
   badges and chips are deeper descendants that re-set their own colour.
   Re-point the ink tokens for the subtree instead of patching each element, so
   anything added to the hero later inherits the correct palette by default. */
html[data-theme="light"] .server-hero {
  --text: #f2f7fb;
  --muted: #c2ccd8;
  --border: rgba(255, 255, 255, .30);
  --border-soft: rgba(255, 255, 255, .18);
  --panel: rgba(255, 255, 255, .10);
  --panel-2: rgba(255, 255, 255, .15);
  --bg: rgba(0, 0, 0, .28);
  --bg-elev: rgba(255, 255, 255, .10);
}
/* The MOTD colour classes carry their own light-theme palette (motd.css), which
   is re-picked for a white panel · #7a5c00 for yellow, #5c646f for gray. On the
   hero's dark scrim the original dark-theme palette is the readable one. */
html[data-theme="light"] .server-hero .motd-rich {
  --mc-black: #0b0f14;
  --mc-dark-blue: #3f48cc;
  --mc-dark-green: #00aa00;
  --mc-dark-aqua: #00aaaa;
  --mc-dark-red: #d4342a;
  --mc-dark-purple: #b14ec4;
  --mc-gold: #ffaa00;
  --mc-gray: #aaaaaa;
  --mc-dark-gray: #7c848f;
  --mc-blue: #5555ff;
  --mc-green: #55ff55;
  --mc-aqua: #55ffff;
  --mc-red: #ff5555;
  --mc-light-purple: #ff55ff;
  --mc-yellow: #ffff55;
  --mc-white: #ffffff;
}

.stat-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
  gap: 10px;
  margin-bottom: var(--gap);
}
.stat {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r);
  padding: 12px 14px;
  text-align: center;
}
.stat > span, .stat .label {
  display: block;
  font: 600 .7rem/1 var(--font);
  letter-spacing: .6px;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 9px;
}
.stat > strong, .stat .value {
  display: block;
  font-family: var(--font-display);
  /* Fluid rather than a flat 1.3rem: "2,755/4,000" plus the status dot is the
     widest value on the row and used to wrap, pushing the dot onto its own line
     and making that one card taller than the other five. */
  font-size: clamp(1rem, 1.6vw + .55rem, 1.3rem);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  /* The value inherited body line-height (1.55), giving a 32px line box around
     ~20px of ink. Because the pixel display font's glyphs sit high inside their
     metrics box, all of that slack fell *below* the digits · the value read as
     jammed up under its label with a dead gap beneath it. Collapse the box and
     re-add the breathing room deliberately, above and below. Same treatment as
     .logo and .rank-num. */
  line-height: 1;
  padding-block: 1px 2px;
}
/* Lay the dot and the number out as a centred row. `vertical-align` cannot do
   this reliably: the display font's ascenders are tall, so a 9px dot aligned to
   the x-height still reads as sitting below the digits. */
.stat > strong {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
}
.stat > strong .dot { margin-right: 0; vertical-align: baseline; }

.desc { line-height: 1.65; overflow-wrap: anywhere; }
.desc h2, .desc h3 { margin-top: 1.2em; }
.desc p { margin: 0 0 1em; }
.desc ul, .desc ol { padding-left: 1.3em; margin: 0 0 1em; }
.desc img { border-radius: var(--r); margin: 10px 0; }
.desc a { text-decoration: underline; }
.desc pre {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 12px;
  overflow-x: auto;
}
.desc blockquote {
  margin: 0 0 1em;
  padding-left: 14px;
  border-left: 3px solid var(--border);
  color: var(--muted);
}

.review {
  border-top: 1px solid var(--border-soft);
  padding: 14px 0;
}
.review:first-of-type { border-top: 0; padding-top: 0; }
.review header, .review .review-head {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 6px;
}
.review strong { font-weight: 700; }
.review p { margin: 0; color: var(--text); }

.stars {
  color: var(--gold);
  letter-spacing: 2px;
  font-size: .95rem;
  white-space: nowrap;
}
.stars .off, .stars .empty { color: var(--border); }

.chart {
  width: 100%;
  overflow: hidden;
  border-radius: var(--r-sm);
}
.chart svg { display: block; width: 100%; height: auto; }
/* sparklines drawn by app.js */
[data-sparkline] { display: block; min-height: 34px; }
[data-sparkline] svg { display: block; width: 100%; height: 100%; }

.copy-ip { position: relative; font-family: var(--font-mono); }
.copy-ip.copied {
  background: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 65%, #000);
  color: var(--accent-ink);
}

.vote-form { display: grid; gap: 12px; }
.vote-form .btn-vote { width: 100%; }

/* --------------------------------------------------------------- forms -- */
.form-row { margin-bottom: 14px; }
.form-row label {
  display: block;
  font-weight: 600;
  font-size: .88rem;
  margin-bottom: 6px;
  color: var(--text);
}
.form-row input[type="text"],
.form-row input[type="email"],
.form-row input[type="password"],
.form-row input[type="url"],
.form-row input[type="number"],
.form-row input:not([type]),
.form-row textarea,
.form-row select {
  width: 100%;
  padding: 10px 12px;
  border-radius: var(--r-sm);
  background: var(--bg-elev);
}
.form-row textarea { min-height: 140px; resize: vertical; line-height: 1.5; }
.form-row input:focus, .form-row textarea:focus, .form-row select:focus { border-color: var(--accent); }
.form-row input[aria-invalid="true"], .form-row .invalid { border-color: var(--danger); }
.form-row input[type="checkbox"], .form-row input[type="radio"] { width: auto; margin-right: 6px; }

.form-help { display: block; color: var(--muted); font-size: .82rem; margin-top: 5px; }

/* -------------------------------------------------------- premium plans -- */
.plans {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 18px;
  align-items: stretch;
  /* Top padding leaves room for the featured card's ribbon, which straddles
     the card's top border. */
  padding: 20px 0 26px;
}
.plan {
  position: relative;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  padding: 22px 20px;
  display: flex;
  flex-direction: column;
  text-align: center;
  transition: transform .15s ease, border-color .15s ease;
}

/* Had no rule, so "Most popular" rendered as a bare line of body text sitting
   directly on top of the uppercase <h3>, reading as "Most popular PREMIUM".
   It is a ribbon: taken out of flow so every card's <h3> stays on one line,
   and drawn in --gold on --panel so it keeps contrast in both themes. */
.plan-ribbon {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translate(-50%, -50%);
  white-space: nowrap;
  font-family: var(--font-display);
  font-size: .68rem;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--gold);
  background: var(--panel);
  border: 1px solid var(--gold);
  border-radius: 999px;
  padding: 4px 12px;
  line-height: 1;
}

/* Also had no rule · the ranking-boost disclaimer read at full body size and
   competed with the perk list. It is a footnote to the price. */
.boost {
  font-size: .8rem;
  line-height: 1.4;
  color: var(--muted);
  margin: -8px 0 16px;
}
.plan:hover { border-color: var(--muted); transform: translateY(-2px); }
.plan h3 {
  font-size: 1rem;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--muted);
}
.plan .price {
  font-family: var(--font-display);
  font-size: 2.1rem;
  line-height: 1;
  margin: 4px 0 16px;
  color: var(--text);
}
.plan .price small, .plan .price span {
  font-family: var(--font);
  font-size: .8rem;
  color: var(--muted);
  font-weight: 400;
}
.plan ul { list-style: none; padding: 0; margin: 0 0 20px; text-align: left; }
.plan ul li {
  position: relative;
  padding: 7px 0 7px 24px;
  font-size: .9rem;
  border-bottom: 1px solid var(--border-soft);
}
.plan ul li:last-child { border-bottom: 0; }
.plan ul li::before {
  content: "✓";
  position: absolute;
  left: 0;
  color: var(--accent);
  font-weight: 700;
}
.plan ul li.off { color: var(--muted); }
.plan ul li.off::before { content: "✕"; color: var(--border); }
/* The free tier's CTA is a direct <a class="btn">, the paid tiers wrap theirs in
   a <form>. Only the former was being pushed down, because `margin-top:auto` on
   the button did nothing inside a non-flex <form> · so the paid cards' buttons
   floated ~84px short of the card bottom. Push whichever element is actually the
   last flex child, and make the form a column so its select and button stack. */
.plan > .btn,
.plan > form { margin-top: auto; }
.plan > form {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.plan .btn, .plan .btn-primary { width: 100%; }
.plan > form select { width: 100%; }

.plan.featured {
  border-color: var(--gold);
  box-shadow: 0 0 0 1px var(--gold), 0 0 30px color-mix(in srgb, var(--gold) 18%, transparent);
  z-index: 1;
}
.plan.featured h3 { color: var(--gold); }
/* Deliberately no `transform: scale()` here. Scaling the featured card pushed
   its top and bottom edges out of line with its neighbours, which is what read
   as "the plan cards are misaligned". The gold border, glow and ribbon carry
   the emphasis without breaking the row. */
.plan.featured:hover { transform: translateY(-2px); }

/* -------------------------------------------------------------- utility -- */
.hidden, [hidden] { display: none !important; }
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
}
.table-wrap { overflow-x: auto; }
.is-busy { opacity: .55; transition: opacity .15s ease; }

/* -------------------------------------------------------- small screens -- */
@media (max-width: 620px) {
  .site-header > .container, .site-header .inner { min-height: 54px; padding-block: 8px; }
  .nav { order: 3; width: 100%; overflow-x: auto; padding-bottom: 2px; }
  .lang-switch { margin-left: auto; }
  .hero { padding: 32px 0 28px; }
  .searchbar { flex-direction: column; }
  .searchbar button { width: 100%; }
  .card { padding: 14px; }
  .footer-inner { flex-direction: column; gap: 18px; }
  .footer-links { grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr)); }
}

/* -------------------------------------------------- reduced motion / print */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
  .plan.featured, .plan.featured:hover { transform: none; }
  .btn:active { transform: none; }
}

@media print {
  .site-header, .site-footer, .pager, .btn, .sponsored-strip { display: none !important; }
  body { background: #fff; color: #000; }
  .card, .server-table { border-color: #ccc; box-shadow: none; }
}

/* ============================================================ multi-game, updates, auction
   Added with the game-agnostic listings, owner update posts and the sponsor auction. */

.game-strip {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  padding: 4px 0 8px;
  /* Hiding the scrollbar entirely left no clue that the row scrolls at all.
     The original complaint was that the *default* bar is a full-width grey rail
     that reads as a broken element · so style it down instead of removing it:
     a short thumb over a transparent track, the same treatment .rail-track in
     discovery.css already uses. Only the thumb is painted, so there is no rail
     when there is nothing to scroll. */
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
}
.game-strip::-webkit-scrollbar { height: 6px; }
.game-strip::-webkit-scrollbar-track { background: transparent; }
.game-strip::-webkit-scrollbar-thumb {
  background: var(--border);
  border-radius: 99px;
}
.game-strip:hover::-webkit-scrollbar-thumb { background: var(--muted); }
.game-strip a {
  flex: 0 0 auto;
  padding: 7px 13px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel);
  color: var(--muted);
  font-size: .88rem;
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
  transition: border-color .15s, color .15s, background .15s;
}
.game-strip a:hover { color: var(--text); border-color: var(--accent-dim); }
.game-strip a.active {
  background: color-mix(in srgb, var(--accent) 16%, var(--panel));
  border-color: var(--accent);
  color: var(--text);
}

.srv-tags .srv-game {
  background: color-mix(in srgb, var(--cyan) 14%, transparent);
  border-color: color-mix(in srgb, var(--cyan) 40%, transparent);
  color: var(--cyan);
  font-weight: 600;
}

/* ----------------------------------------------------------- update posts */
.post { border-top: 1px solid var(--border-soft); padding-top: 14px; margin-top: 14px; }
.post:first-of-type { border-top: 0; margin-top: 0; }
.post h2, .post h3 { margin: 0 0 4px; font-size: 1.05rem; }
.post h2 { font-size: 1.25rem; }
.post p { margin: 8px 0 0; color: var(--text); line-height: 1.6; }
.post.pinned {
  background: color-mix(in srgb, var(--gold) 7%, transparent);
  border-left: 3px solid var(--gold);
  border-radius: var(--r-sm);
  padding: 12px 14px;
}
/* Server updates are unbounded · an owner who posts weekly for a year buries
   everything below this card. Cap the list and let it scroll inside itself; the
   `max-height` only engages once there is more than roughly three posts, so
   short lists are untouched. Matches the slim bar used by .game-strip. */
.post-scroll {
  max-height: 30rem;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
  /* Keeps the scrollbar off the text. */
  padding-right: 4px;
}
.post-scroll::-webkit-scrollbar { width: 6px; }
.post-scroll::-webkit-scrollbar-track { background: transparent; }
.post-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
.post-scroll:hover::-webkit-scrollbar-thumb { background: var(--muted); }
/* An in-page #post-N link has to clear the top of the scroll container, not
   the sticky header, once the list is its own scrolling box. */
.post-scroll .post { scroll-margin-top: 8px; }

.post-form { border: 1px dashed var(--border); border-radius: var(--r); padding: 14px; margin-bottom: 18px; }

.update-list { list-style: none; margin: 0; padding: 0; }
.update-list li {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 9px 0;
  border-bottom: 1px solid var(--border-soft);
  font-size: .92rem;
}
.update-list li:last-child { border-bottom: 0; }
.update-list a { font-weight: 600; }
.update-list .muted { font-size: .8rem; }

/* --------------------------------------------------------------- auction */
.rules { margin: 0; padding-left: 18px; color: var(--muted); font-size: .9rem; line-height: 1.7; }
.rules strong { color: var(--text); }

.badge.ok { border-color: var(--accent); color: var(--accent); }
.badge.err { border-color: var(--danger); color: var(--danger); }

form.inline { display: inline-block; margin: 0 4px 0 0; }
form.inline + .form-help { margin-top: 8px; }

/* ============================================================ layout repairs
   Components added after the original stylesheet: hero stats, breadcrumbs, the
   filter panel, active-filter chips, the sponsor strip and the tag clouds. */

.hero-stats {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px 28px;
  margin-top: 18px;
  color: var(--muted);
  font-size: .92rem;
}
.hero-stats strong { color: var(--text); font-size: 1.05rem; }

.crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin: 18px 0 -6px;
  font-size: .85rem;
  color: var(--muted);
}
.crumbs a { color: var(--muted); text-decoration: none; }
.crumbs a:hover { color: var(--accent); }

/* -------------------------------------------------------- filter panel */
.filters {
  display: block;
  margin: 18px 0 0;
  padding: 14px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
}
.filters-top {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: stretch;
}
.filters .searchbar {
  display: flex;
  flex: 1 1 320px;
  min-width: 0;
  gap: 8px;
}
.filters .searchbar input { flex: 1 1 auto; min-width: 0; }
.filters .sort-select { flex: 0 0 auto; min-width: 168px; }

.filter-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  margin-left: 6px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: .72rem;
  font-weight: 800;
}

.filter-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 12px;
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border-soft);
}
.filter-grid[hidden] { display: none; }
.filter-grid > label {
  display: flex;
  flex-direction: column;
  gap: 5px;
  font-size: .78rem;
  font-weight: 600;
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--muted);
}
.filter-grid > label.chk {
  flex-direction: row;
  align-items: center;
  gap: 8px;
  text-transform: none;
  letter-spacing: 0;
  font-size: .9rem;
  color: var(--text);
  align-self: end;
  padding-bottom: 8px;
}
.filter-grid select { width: 100%; }

/* ------------------------------------------- classes that shipped unstyled --
   All of the following appear in src/views markup and had no matching rule.
   See the report in the commit message for the ones judged vestigial instead. */

/* Tag / language / store checkbox grids on the add & edit forms. Without a rule
   the labels were inline text runs, so the checkboxes wrapped mid-phrase and
   the boxes did not line up with their words. .chk only had a rule scoped to
   .filter-grid, so it also styled nothing here. */
.checkgrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 8px;
  margin-top: 6px;
}
.checkgrid .chk {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  background: var(--panel-2);
  font-size: .88rem;
  line-height: 1.35;
  cursor: pointer;
}
.checkgrid .chk:hover { border-color: var(--muted); }
.checkgrid .chk input { margin: 2px 0 0; flex: none; }
.checkgrid .chk:has(input:checked) {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.checkgrid .chk .form-help { display: block; margin-top: 2px; }

/* Grouping fieldsets on the legacy edit form. A bare <fieldset> renders with
   the UA's 2px groove border, which is the one thing on these pages that did
   not look like the rest of the site. */
.store-box,
.votifier-box {
  border: 1px solid var(--border);
  border-radius: var(--r);
  background: var(--panel-2);
  padding: 14px 16px;
  margin: 0 0 var(--gap);
  min-width: 0;
}
.store-box legend,
.votifier-box legend {
  font-family: var(--font-display);
  font-size: .95rem;
  color: var(--text);
  padding: 0 6px;
}

/* Website / Discord / Trailer button row on the server page. */
.links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--gap); }

/* Readonly inputs that exist to be selected and copied (API key, plugin
   checksum). They are inputs so the text is selectable, but they were picking
   up the full editable-field treatment and read as something to type into. */
.copyable {
  width: 100%;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  color: var(--muted);
  padding: 7px 9px;
  font-size: .82rem;
  cursor: text;
}
.copyable:focus { color: var(--text); border-color: var(--accent); }

/* Utility pair used by the error page and a few narrow cards. */
.narrow { max-width: 46rem; margin-inline: auto; }
.center { text-align: center; }
.center .btn { margin-inline: auto; }

/* The vote page's two states. Both are a single centred column · the page has
   one job, and the card was running the full container width. */
.vote-card,
.vote-done {
  max-width: 42rem;
  margin-inline: auto;
  text-align: center;
}
.vote-card > form,
.vote-done > form { text-align: left; }
.vote-card h1,
.vote-done h1 { margin-bottom: .4em; }
.filter-actions {
  grid-column: 1 / -1;
  display: flex;
  gap: 8px;
  padding-top: 4px;
}

.active-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 14px 0 0;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 11px;
  border: 1px solid var(--accent-dim);
  border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--text);
  font-size: .82rem;
  text-decoration: none;
}
.chip:hover { border-color: var(--danger); color: var(--danger); }
.chip span { opacity: .6; font-weight: 700; }
.chip-clear { background: none; border-style: dashed; border-color: var(--border); color: var(--muted); }

/* ------------------------------------------------------- sponsor strip */
/* The first stylesheet treated this as a flex chip strip with a bordered
   `> *` rule; the markup is now a heading plus a card grid, so reset both. */
.sponsored-strip {
  display: block;
  overflow: visible;
  margin: 26px 0 0;
  padding: 0;
}
.sponsored-strip > * {
  flex: initial;
  background: none;
  border: 0;
  border-radius: 0;
  padding: 0;
  font-weight: inherit;
  font-size: inherit;
  box-shadow: none;
}
.sponsored-strip .card-title { margin-bottom: 12px; }
.sponsor-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: var(--gap);
  align-items: start;
}
.sponsor-card {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px;
  margin: 0;
}
.sponsor-card .sponsor-banner {
  width: 100%;
  height: auto;
  border-radius: var(--r-sm);
  aspect-ratio: 468 / 60;
  object-fit: cover;
}
.sponsor-head { display: flex; gap: 10px; align-items: center; min-width: 0; }
.sponsor-head .srv-icon { width: 44px; height: 44px; flex: 0 0 44px; font-size: .9rem; }
.sponsor-head > div { min-width: 0; }
.sponsor-head .srv-name { display: block; font-size: 1.02rem; }
.sponsor-head .muted { font-size: .8rem; }
.sponsor-card .srv-ip { width: 100%; text-align: left; }
.sponsor-card .btn-vote { width: 100%; text-align: center; }
.sponsor-card.tier-3 { border-color: color-mix(in srgb, var(--cyan) 55%, var(--border)); }
.sponsor-card.tier-2 { border-color: color-mix(in srgb, var(--gold) 55%, var(--border)); }

/* ----------------------------------------------------------- tag cloud */
.tag-cloud {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
}
.tag-cloud a {
  display: inline-block;
  padding: 5px 10px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel-2);
  color: var(--muted);
  font-size: .8rem;
  line-height: 1.2;
  text-decoration: none;
  white-space: nowrap;
}
.tag-cloud a:hover { border-color: var(--accent); color: var(--text); }

.promo .btn { margin-right: 8px; margin-top: 4px; }
.admin-nav { display: flex; gap: 10px; flex-wrap: wrap; }

/* ------------------------------------------------- server table polish
   A <td> given display:flex or inline-block leaves the table's column model,
   which is what was throwing the rows out of alignment. Cells stay table-cells;
   all the flex/badge work happens on wrappers inside them. */
.server-table td.rank {
  display: table-cell;
  width: 64px;
  padding-left: 8px;
  padding-right: 8px;
  text-align: center;
  vertical-align: middle;
}
.rank-num {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 40px;
  height: 40px;
  font-family: var(--font-display);
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.rank-num.rank-1, .rank-num.rank-2, .rank-num.rank-3 {
  width: 40px;
  border-radius: 50%;
  font-size: 1.1rem;
  color: #10161e;
  box-shadow: inset 0 -2px 0 rgba(0, 0, 0, .28), 0 0 14px rgba(0, 0, 0, .3);
}
.rank-num.rank-1 { background: linear-gradient(160deg, #ffdf6e, var(--gold)); }
.rank-num.rank-2 { background: linear-gradient(160deg, #e8edf2, #a8b3bf); }
.rank-num.rank-3 { background: linear-gradient(160deg, #e0a678, #a4643a); color: #1b0f07; }

.server-table td.srv-cell { display: table-cell; vertical-align: middle; }
.server-table td.srv-cell .srv-icon { float: left; margin-right: 12px; }
.server-table .srv-meta {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 5px;
  min-width: 0;
  overflow: hidden;      /* contains the floated icon */
}
.server-table .srv-tags { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.server-table .srv-tags > span {
  padding: 2px 7px;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--panel-2);
  font-size: .72rem;
  color: var(--muted);
  white-space: nowrap;
}
.server-table .srv-ip { align-self: flex-start; max-width: 100%; }
.server-table td { vertical-align: middle; }
.server-table .vote-cta { text-align: right; white-space: nowrap; }
/* Vote is the call to action; Save sits quietly beside it. Kept as inline-block
   rather than a flex cell so the mobile "table becomes cards" rules at the top
   of this file still own the cell's display. */
.server-table .vote-cta .fav-form { display: inline-block; vertical-align: middle; margin-left: 8px; }
/* Icon only in a row, the same as inside a discovery card: the word "Save"
   costs the server-name column enough width to start truncating names, and the
   button already carries the full label in its title and aria-label. */
.server-table .vote-cta .fav-btn .fav-label { display: none; }
.server-table .vote-cta .fav-btn { padding-inline: 9px; }

@media (max-width: 720px) {
  .filters { padding: 12px; }
  .filters .sort-select { flex: 1 1 100%; }
  .sponsor-cards { grid-template-columns: 1fr; }
}

.countdown {
  margin: 10px 0 0;
  font-size: .9rem;
  font-variant-numeric: tabular-nums;
  color: var(--gold);
}
.countdown:empty { display: none; }
.countdown.ready { color: var(--accent); }

/* Denser list rows: a 64px icon plus two lines of tags made each row ~180px,
   so only three servers fit above the fold. */
.server-table td { padding-top: 12px; padding-bottom: 12px; }
.server-table td.srv-cell .srv-icon {
  width: 48px;
  height: 48px;
  flex: 0 0 48px;
  font-size: .82rem;
}
.server-table .srv-tags {
  max-height: 24px;
  overflow: hidden;
  flex-wrap: wrap;
}
.server-table .srv-name { margin-bottom: 0; font-size: 1rem; }
.server-table .srv-ip { padding: 3px 8px; font-size: .78rem; }

/* The oversized vote button is right for a hero or a card, not for a list row. */
.server-table .btn-vote {
  padding: 9px 20px;
  font-size: .92rem;
  box-shadow: 0 2px 0 color-mix(in srgb, var(--accent) 45%, #000);
}
.server-table .srv-meta { gap: 4px; }
.server-table .players, .server-table .votes { font-size: .95rem; }

/* Name and tier badge share one line; previously the badge wrapped onto its
   own row inside the flex column, adding ~25px to every row. */
.srv-title {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  max-width: 100%;
}
.srv-title .srv-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.srv-title .tier-badge { flex: 0 0 auto; }

/* ------------------------------------------------------ player profile */
.player-head { display: flex; gap: 18px; align-items: center; flex-wrap: wrap; }
.player-avatar { width: 96px; height: 96px; border-radius: var(--r); image-rendering: pixelated; border: 1px solid var(--border); }
.player-head h1 { margin-bottom: 4px; }
.player-games { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }

.saved-list { list-style: none; margin: 0; padding: 0; }
.saved-list li {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(auto, 460px);
  align-items: start;
  column-gap: 14px;
  row-gap: 8px;
  padding: 18px 0;
  border-bottom: 1px solid var(--border-soft);
}
.saved-list li:last-child { border-bottom: 0; }
.saved-list .muted { flex: 1 1 200px; font-size: .85rem; }

/* `.saved-item` is the <li> above, so it inherits the row layout; these are the
   pieces inside it that had no rule. `.saved-main` is the name + summary block
   that should absorb the free space so the Edit/Delete controls stay right;
   without it the row distributed space evenly and the controls floated mid-row.
   (The <details> control itself · `.saved-edit > summary` · is styled in
   filters.css.) */
.saved-main {
  flex: 1 1 260px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.saved-main strong { overflow-wrap: anywhere; }

/* The "email me new matches" toggle inside the edit form. A bare <label> put
   the checkbox and its text on separate baselines. */
.saved-check {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: .88rem;
  cursor: pointer;
}
.saved-check input { flex: none; margin: 0; }

.voter-list { list-style: none; margin: 0; padding: 0; }
.voter-list li { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: .9rem; }
.voter-list img { border-radius: 3px; image-rendering: pixelated; }
.voter-list .muted { margin-left: auto; font-size: .78rem; }

/* -------------------------------------------------- sponsor placements */
.placement-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 18px 0;
}
.placement-tabs a {
  padding: 8px 13px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel);
  color: var(--muted);
  font-size: .88rem;
  font-weight: 600;
  text-decoration: none;
}
.placement-tabs a:hover { color: var(--text); border-color: var(--accent-dim); }
.placement-tabs a.active {
  background: color-mix(in srgb, var(--gold) 15%, var(--panel));
  border-color: var(--gold);
  color: var(--text);
}
.placement-tabs .muted { font-weight: 400; font-size: .78rem; }
.slot-keys a.active { border-color: var(--gold); color: var(--text); }

/* ------------------------------------------------ mode switch + P2W block */
.list-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  /* Needs a bottom margin: the filter chip row sits directly under it and the
     two were touching. */
  margin: 16px 0 14px;
}
.mode-switch {
  display: inline-flex;
  padding: 4px;
  gap: 4px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 999px;
}
.mode-switch a {
  padding: 8px 16px;
  border-radius: 999px;
  color: var(--muted);
  font-weight: 600;
  font-size: .9rem;
  text-decoration: none;
  white-space: nowrap;
}
.mode-switch a:hover { color: var(--text); }
.mode-switch a.active {
  background: var(--accent);
  color: var(--accent-ink);
  box-shadow: 0 1px 0 color-mix(in srgb, var(--accent) 55%, #000);
}

.p2w-block {
  margin-top: 16px;
  padding: 14px;
  border: 1px solid var(--border);
  border-left: 3px solid var(--muted);
  border-radius: var(--r-sm);
  background: var(--panel-2);
}
.p2w-block[data-tone="ok"]   { border-left-color: var(--accent); }
.p2w-block[data-tone="warn"] { border-left-color: var(--gold); }
.p2w-block[data-tone="bad"]  { border-left-color: var(--danger); }
.p2w-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.p2w-tier { font-weight: 700; }
.p2w-tier[data-tier="none"], .p2w-tier[data-tier="cosmetic"] { color: var(--accent); }
.p2w-tier[data-tier="convenience"] { color: var(--gold); }
.p2w-tier[data-tier="advantage"] { color: var(--danger); }
.p2w-dispute { margin-top: 10px; font-size: .88rem; }

.store-grid { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.store-chk span { display: flex; flex-direction: column; }
.store-chk[data-tier="advantage"] strong { color: var(--danger); }
.store-chk[data-tier="convenience"] strong { color: var(--gold); }
.store-chk[data-tier="cosmetic"] strong { color: var(--accent); }

/* -------------------------------------------------------- server hero fix
   `.server-hero-inner` had no rule at all, so the icon, title block and vote
   button collapsed into one column. */
.server-hero-inner {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  gap: 20px;
  align-items: center;
  width: 100%;
}
.server-hero .srv-icon {
  width: 72px;
  height: 72px;
  flex: 0 0 72px;
  font-size: 1.1rem;
  align-self: center;
}
.server-hero h1 {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 8px;
}
.server-hero .motd {
  margin: 0 0 12px;
  color: #cbd6e2;
  font-size: .95rem;
}
.hero-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
/* The IP is a <button> and the country/version chips are <span>s, so they had
   different heights and line-heights · centring them still left the text on
   visibly different baselines. One height, one centred row inside each. */
.hero-chips > * {
  display: inline-flex;
  align-items: center;
  min-height: 30px;
  margin: 0;
}

/* The "full statistics" line under the stat strip: button first, then the
   sentence that says what is behind it. Wraps as a unit on a phone. */
.stat-row-more {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin: -4px 0 var(--gap);
}
.stat-row-more .muted { font-size: .88rem; }

/* Vote + Save stack in the hero's third column. */
.hero-actions { display: flex; flex-direction: column; align-items: stretch; gap: 8px; min-width: 0; }
.hero-actions .fav-form { margin: 0; min-width: 0; }
.hero-actions .fav-btn { width: 100%; justify-content: center; }
/* The hero paints a dark image behind everything, so the saved-badge chips need
   the same light ink the rest of the hero uses. */
.server-hero .badge-row { margin-top: 10px; }

@media (max-width: 720px) {
  .server-hero-inner { grid-template-columns: auto minmax(0, 1fr); }
  .server-hero-inner > .btn-vote,
  .server-hero-inner > .hero-actions { grid-column: 1 / -1; width: 100%; }
  .hero-actions { flex-direction: row; }
  /* min-width:0 or the Save form keeps its content width and pushes the pair
     past the hero's right edge on a phone. */
  .hero-actions > * { flex: 1 1 0; min-width: 0; }
}

/* Tag links under the description read as chips, not a run-on sentence. */
.srv-tags.big { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.srv-tags.big a {
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel-2);
  color: var(--muted);
  font-size: .85rem;
  text-decoration: none;
}
.srv-tags.big a:hover { border-color: var(--accent); color: var(--text); }

.event-list { list-style: none; margin: 0; padding: 0; }
.event-list li {
  padding: 12px 0;
  border-bottom: 1px solid var(--border-soft);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.event-list li:last-child { border-bottom: 0; }
.event-list li.live { border-left: 3px solid var(--accent); padding-left: 12px; }
.event-list .badge { margin-left: 8px; }

/* -------------------------------------------------------------- sort bar
 * Sits in the list header, above the results and below the mode switch. Chips
 * rather than a <select>: every sort is a linkable URL, the active one is
 * visible without opening anything, and it works with JavaScript off. Scrolls
 * horizontally on narrow screens the same way .game-strip does. */
.sort-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 14px;
}
.sort-bar-label {
  flex: 0 0 auto;
  font-size: .8rem;
  font-weight: 700;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--muted);
}
.sort-bar ul {
  display: flex;
  gap: 6px;
  margin: 0;
  padding: 2px 0;
  list-style: none;
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.sort-bar ul::-webkit-scrollbar { display: none; }
.sort-bar li { flex: 0 0 auto; }
.sort-bar a {
  display: block;
  padding: 6px 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel);
  color: var(--muted);
  font-size: .85rem;
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
  transition: border-color .15s, color .15s, background .15s;
}
.sort-bar a:hover { color: var(--text); border-color: var(--accent-dim); }
.sort-bar a.active {
  background: color-mix(in srgb, var(--accent) 16%, var(--panel));
  border-color: var(--accent);
  color: var(--text);
}

/* ------------------------------------------------------- referral prompt */
.referral-form { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.referral-form input { width: 100%; }

/* ==========================================================================
   SERVER PAGE · TABS AND GALLERY                    (appended block, one edit)

   Everything below belongs to src/views/pages.js serverPage(): the pure-CSS
   tab rail (.sp-*) and the Gallery tab with its :target lightbox (.gal-*).
   Kept in one appended block on purpose · style.css was being edited elsewhere
   at the same time, and interleaving these rules with the existing ones would
   have made both changes harder to unpick.

   The switch is the same one the add/edit form uses (public/css/serverform.css,
   and the header comment in src/views/serverform.js): radios named `_stab` sit
   ahead of .sp-shell and `#sp-tab-x:checked ~ .sp-shell #sp-panel-x` reveals a
   panel. No JavaScript, nothing to nonce.

   Two lists below are paired by hand with the tabs in pages.js · the :checked
   rules and the :target rules. A tab added there needs a line added in both.

   With this stylesheet missing every panel is simply a visible block, which is
   the long single scroll the tabs replaced. Nothing here may be the only thing
   keeping content reachable.
   ========================================================================== */

/* ------------------------------------------------------- the tab machinery */

/* Off-screen rather than display:none · a hidden radio cannot be focused, and
   the tabs must stay operable from the keyboard. */
.sp-radio {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
  margin: 0;
}

.sp-shell { display: block; }

.sp-rail {
  display: flex;
  gap: 6px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  -ms-overflow-style: none;
  padding: 2px 2px 10px;
  margin: 0 -2px var(--gap);
  scroll-snap-type: x proximity;
  border-bottom: 1px solid var(--border-soft);
}
.sp-rail::-webkit-scrollbar { display: none; }

.sp-tab {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
  scroll-snap-align: start;
  padding: 9px 16px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel-2);
  color: var(--muted);
  font-size: .9rem;
  font-weight: 700;
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
  transition: color .12s, border-color .12s, background .12s;
}
.sp-tab:hover { color: var(--text); border-color: var(--muted); }

.sp-count {
  font-variant-numeric: tabular-nums;
  font-size: .74rem;
  font-weight: 700;
  padding: 1px 7px;
  border-radius: 999px;
  background: var(--bg);
  border: 1px solid var(--border);
  color: var(--muted);
}

.sp-panels { min-width: 0; }
.sp-panel { display: none; }

/* One line per tab, paired by id with serverTabs() in src/views/pages.js. */
#sp-tab-overview:checked ~ .sp-shell #sp-panel-overview,
#sp-tab-gallery:checked  ~ .sp-shell #sp-panel-gallery,
#sp-tab-reviews:checked  ~ .sp-shell #sp-panel-reviews,
#sp-tab-updates:checked  ~ .sp-shell #sp-panel-updates,
#sp-tab-vote:checked     ~ .sp-shell #sp-panel-vote { display: block; }

#sp-tab-overview:checked ~ .sp-shell [for="sp-tab-overview"],
#sp-tab-gallery:checked  ~ .sp-shell [for="sp-tab-gallery"],
#sp-tab-reviews:checked  ~ .sp-shell [for="sp-tab-reviews"],
#sp-tab-updates:checked  ~ .sp-shell [for="sp-tab-updates"],
#sp-tab-vote:checked     ~ .sp-shell [for="sp-tab-vote"] {
  color: var(--text);
  background: color-mix(in srgb, var(--accent) 16%, var(--panel));
  border-color: var(--accent);
}
#sp-tab-overview:checked ~ .sp-shell [for="sp-tab-overview"] .sp-count,
#sp-tab-gallery:checked  ~ .sp-shell [for="sp-tab-gallery"] .sp-count,
#sp-tab-reviews:checked  ~ .sp-shell [for="sp-tab-reviews"] .sp-count,
#sp-tab-updates:checked  ~ .sp-shell [for="sp-tab-updates"] .sp-count {
  color: var(--text);
  border-color: var(--accent);
}

/* The radio is off-screen, so its focus ring has to be drawn on the label. */
#sp-tab-overview:focus-visible ~ .sp-shell [for="sp-tab-overview"],
#sp-tab-gallery:focus-visible  ~ .sp-shell [for="sp-tab-gallery"],
#sp-tab-reviews:focus-visible  ~ .sp-shell [for="sp-tab-reviews"],
#sp-tab-updates:focus-visible  ~ .sp-shell [for="sp-tab-updates"],
#sp-tab-vote:focus-visible     ~ .sp-shell [for="sp-tab-vote"] {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ---------------------------------------------- deep links beat the radio

   A permalink must land where it points: #post-12 opens Updates, #review-8 and
   #write-review open Reviews, #referral opens Vote, a lightbox opens Gallery.
   The radio rules above are id-heavy, so the override is `!important` rather
   than a combinatorial pile of selectors · deliberate, and the only use of it
   in this block.

   Scoped to `.sp-panels :target`, so an anchor elsewhere on the page (the
   sidebar's #chip-in, for one) can never switch the tab under the reader.
   Split into separate rules on purpose: a browser without :has() drops only
   the rules that need it and still honours a direct link to a panel. */
.sp-panels:has(:target) .sp-panel { display: none !important; }
.sp-panels .sp-panel:target { display: block !important; }
.sp-panels .sp-panel:has(:target) { display: block !important; }

/* The rail has to follow. `!important` here for the same reason as above: the
   :checked rules that light a label carry an id and would otherwise leave TWO
   tabs looking active · the one the radio remembers and the one the link
   opened. Verified in the browser; without it Overview and Gallery both lit. */
.sp-shell:has(.sp-panels :target) .sp-tab {
  color: var(--muted) !important;
  background: var(--panel-2) !important;
  border-color: var(--border) !important;
}
.sp-shell:has(#sp-panel-overview:target) [for="sp-tab-overview"],
.sp-shell:has(#sp-panel-overview :target) [for="sp-tab-overview"],
.sp-shell:has(#sp-panel-gallery:target) [for="sp-tab-gallery"],
.sp-shell:has(#sp-panel-gallery :target) [for="sp-tab-gallery"],
.sp-shell:has(#sp-panel-reviews:target) [for="sp-tab-reviews"],
.sp-shell:has(#sp-panel-reviews :target) [for="sp-tab-reviews"],
.sp-shell:has(#sp-panel-updates:target) [for="sp-tab-updates"],
.sp-shell:has(#sp-panel-updates :target) [for="sp-tab-updates"],
.sp-shell:has(#sp-panel-vote:target) [for="sp-tab-vote"],
.sp-shell:has(#sp-panel-vote :target) [for="sp-tab-vote"] {
  color: var(--text) !important;
  background: color-mix(in srgb, var(--accent) 16%, var(--panel)) !important;
  border-color: var(--accent) !important;
}

/* The Vote tab's own card. The rewards block, the reminder card and the
   referral form below it bring their own styling. */
.sp-vote-card .btn-vote { margin-top: 4px; }

/* ------------------------------------------------------------- gallery */

.gal-card .card-title .muted { font-weight: 600; font-size: .9rem; }

.gal-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
  gap: 10px;
  margin-top: var(--gap);
}

.gal-cell { margin: 0; min-width: 0; }

.gal-link {
  display: block;
  border-radius: var(--r);
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--panel-2);
}
.gal-link:hover { border-color: var(--accent); }
.gal-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The aspect ratio is the safety rail, not the styling. These URLs are remote
   and owner-supplied: without a fixed box one 12000px-tall JPEG pushes the
   whole page off the screen, and a 1px one collapses the grid. */
.gal-thumb {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: var(--bg);
  transition: transform .18s ease;
}
.gal-link:hover .gal-thumb { transform: scale(1.03); }

.gal-cap {
  margin-top: 6px;
  font-size: .82rem;
  color: var(--muted);
  overflow-wrap: anywhere;
}

.gal-empty .form-help { max-width: 60ch; }

/* ------------------------------------------------ lightbox, :target only */
.gal-lightbox { display: none; }

.gal-lightbox:target {
  display: flex;
  position: fixed;
  inset: 0;
  z-index: 90;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(4, 7, 11, .88);
}

/* Full-bleed click target behind the picture: clicking away closes, which is
   what everybody expects from a lightbox. */
.gal-scrim {
  position: absolute;
  inset: 0;
  display: block;
}

.gal-figure {
  position: relative;
  margin: 0;
  max-width: min(1200px, 92vw);
  max-height: 88vh;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.gal-full {
  display: block;
  max-width: 100%;
  max-height: 78vh;
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: var(--r);
  background: var(--bg);
}

/* Fixed light-on-dark, not themed. The scrim is dark in both themes, so
   var(--text) here rendered near-black on near-black in the light theme and the
   caption was effectively invisible. */
.gal-figcap {
  display: flex;
  align-items: baseline;
  gap: 10px;
  color: rgba(233, 240, 246, .78);
  font-size: .85rem;
  overflow-wrap: anywhere;
}
.gal-counter { font-variant-numeric: tabular-nums; font-weight: 700; color: #e9f0f6; }
.gal-figcap-text { min-width: 0; }

.gal-close,
.gal-nav {
  position: absolute;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--panel);
  color: var(--text);
  font-size: 1.4rem;
  line-height: 1;
  text-decoration: none;
}
.gal-close:hover,
.gal-nav:hover { border-color: var(--accent); color: var(--accent); }
.gal-close:focus-visible,
.gal-nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.gal-close { top: 16px; right: 16px; }
.gal-prev { top: 50%; left: 12px; transform: translateY(-50%); }
.gal-next { top: 50%; right: 12px; transform: translateY(-50%); }

/* ------------------------------------------------ the review abuse gate */

/* Only rendered when the reviewer has not voted for this server · see the
   comment above reviewForm() in src/views/reviews.js. Exactly one of these
   may exist per page: a second would mean a second `name="c"` and a review
   that can never post. */
.rv-captcha {
  display: grid;
  gap: 6px;
  margin: var(--gap) 0 0;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-left: 3px solid var(--gold);
  border-radius: var(--r);
  background: var(--panel-2);
}
.rv-captcha-label { font-weight: 700; font-size: .92rem; }
.rv-captcha-input {
  max-width: 140px;
  font-variant-numeric: tabular-nums;
}
.rv-captcha-help {
  margin: 0;
  max-width: 62ch;
  font-size: .82rem;
  color: var(--muted);
}

@media (max-width: 560px) {
  .gal-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
  .gal-lightbox:target { padding: 12px; }
  .gal-prev { left: 6px; }
  .gal-next { right: 6px; }
}

@media (prefers-reduced-motion: reduce) {
  .gal-link:hover .gal-thumb { transform: none; }
}

/* ==========================================================================
   Vote-fraud review queue  (/admin/fraud, src/views/fraud.js)
   --------------------------------------------------------------------------
   Admin-only surface. None of these classes may be reused on a public page:
   showing a server its own suspicion score is how you teach someone to evade
   it. The severity bar is deliberately understated · it orders the queue, and
   the sentence next to it is what an admin is meant to read.
   ========================================================================== */

.fr-intro p { margin: 0 0 10px; max-width: 78ch; }
.fr-intro p:last-child { margin-bottom: 0; }

.fr-flag { border-left: 3px solid var(--danger); }
.fr-flag-done { border-left-color: var(--border); opacity: .72; }

.fr-head {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap);
  align-items: flex-start;
  justify-content: space-between;
}
.fr-head-main { flex: 1 1 320px; min-width: 0; }

.fr-name {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin: 0 0 6px;
  font-size: 1.05rem;
}

/* The one sentence. This is the product of the whole detector, so it gets the
   weight on the card rather than the number does. */
.fr-reason {
  margin: 0;
  max-width: 82ch;
  font-size: 1rem;
  line-height: 1.5;
}

.fr-meta { margin: 10px 0 0; font-size: .85rem; }

.fr-sev {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
}
.fr-sev-bar {
  display: block;
  width: 96px;
  height: 6px;
  border-radius: 999px;
  background: var(--panel-2);
  overflow: hidden;
}
.fr-sev-fill { display: block; height: 100%; background: var(--muted); }
.fr-sev-num {
  font-variant-numeric: tabular-nums;
  font-size: .85rem;
  color: var(--muted);
}
.fr-sev-low  .fr-sev-fill { background: var(--accent); }
.fr-sev-mid  .fr-sev-fill { background: var(--gold); }
.fr-sev-high .fr-sev-fill { background: var(--danger); }

.fr-signals { margin: 12px 0 0; }
.fr-signals > summary {
  cursor: pointer;
  font-size: .88rem;
  color: var(--muted);
  padding: 4px 0;
}
.fr-signals > summary:hover { color: var(--text); }

.fr-signal-list { list-style: none; margin: 8px 0 0; padding: 0; }
.fr-signal-list > li {
  padding: 10px 12px;
  margin: 0 0 8px;
  border: 1px solid var(--border-soft);
  border-radius: var(--r-sm);
  background: var(--panel-2);
}
.fr-signal-list > li > p { margin: 6px 0 0; max-width: 78ch; }
.fr-signal-key {
  font-family: var(--font-mono);
  font-size: .78rem;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--cyan);
}
.fr-signal-weight {
  margin-left: 6px;
  font-variant-numeric: tabular-nums;
  font-size: .78rem;
  color: var(--muted);
}

/* The raw counts. Never an address or a hash · only how many there were. */
.fr-metrics {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 6px 14px;
  margin: 8px 0 0;
}
.fr-metrics > div {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  padding: 3px 0;
  border-bottom: 1px solid var(--border-soft);
}
.fr-metrics dt { font-size: .82rem; color: var(--muted); }
.fr-metrics dd {
  margin: 0;
  font-size: .82rem;
  font-variant-numeric: tabular-nums;
}

.fr-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin: var(--gap) 0 0;
  padding: 12px 0 0;
  border-top: 1px solid var(--border-soft);
}
.fr-dismiss {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  margin-left: auto;
}
.fr-dismiss input[type="text"] { min-width: 220px; }

.fr-votes td { vertical-align: top; }

@media (max-width: 640px) {
  .fr-sev { width: 100%; }
  .fr-sev-bar { flex: 1 1 auto; width: auto; }
  .fr-dismiss { margin-left: 0; width: 100%; }
  .fr-dismiss input[type="text"] { flex: 1 1 100%; min-width: 0; }
}

/* ==========================================================================
   Optical centring for the pixel display font  (visual pass, round 3)
   --------------------------------------------------------------------------
   Measured rather than guessed. Rasterising Minecraftia to a canvas and
   scanning for ink rows shows the glyphs are drawn very high inside extremely
   tall font metrics · the ink STOPS about 0.6em ABOVE the baseline, so the
   canvas even reports a negative `actualBoundingBoxDescent`:

     glyph   size     ink height   ink centre above line-box centre
     "1"     12.8px   12px         0.469em
     "49"    16.8px   14px         0.417em
     "VOTE"  16.8px   14px         0.417em
     "HLASOVAT" 16.8px 14px        0.417em      <- same as "VOTE"
     "7"     20.8px   19px         0.433em

   Two things follow.

   1. Flex/grid centring centres the LINE BOX, not the ink, so every
      fixed-height display-font element renders visibly high. The correction is
      ~0.43em and it is a constant fraction of the font size, so expressing it
      in em fixes every size at once · which the previous hand-tuned 1px and
      2px nudges could not do across 26px, 32px and 40px medals.

   2. It does not vary with the string, so the Czech "HLASOVAT" needs exactly
      the same treatment as "VOTE". Nothing here is tuned to a label.

   Padding shifts centred content by HALF its value, so a fixed-height box that
   centres its content needs 2 x 0.43em of top padding. A box whose height comes
   from its own padding instead moves by the full amount, so it takes +0.43em on
   top and -0.43em off the bottom, leaving the height unchanged.
   ========================================================================== */

/* Rank medals: main ranking table, /stats leaderboard and the per-server
   "Top voters" table, at 40px / 32px / 26px respectively. */
.rank-num {
  line-height: 1;
  padding-top: .86em;
}

/* The vote button's height is padding-driven, so shift rather than pad. */
.btn-vote {
  line-height: 1;
  padding-top: calc(14px + .43em);
  padding-bottom: calc(14px - .43em);
}
.server-table .btn-vote {
  padding-top: calc(9px + .43em);
  padding-bottom: calc(9px - .43em);
}

/* Non-text elements sitting inline with display-font text.
   `align-items: center` puts them on the text's BOX centre, but the ink rides
   ~0.39em above that, so the badge or dot reads as sitting low against the
   letters. Measured on three separate pairs · the hero <h1> and its tier badge
   (0.401em), a stat value and its status dot (0.384em), and the logo wordmark
   and its cube (0.390em) · which is the same number three times, so it is a
   property of the font and not per-case tuning.
   The text is a bare text node in each case and cannot be transformed, so the
   small element moves up to the cap midline instead. That is also the less
   disruptive direction: the text's own position is already set by the layout
   around it. */
/* The shift has to be measured in the TEXT's em, not the badge's. A plain
   `translateY(-.39em)` on the badge resolves against the badge's own much
   smaller font-size · measured, it moved the hero badge 4.3px when it needed
   14.6px, and only looked right on the status dot because a dot inherits its
   parent's size. Registering the property makes `.39em` compute against the
   element that declares it and inherit down as an absolute length. */
@property --cap-shift {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}
.server-hero h1,
.stat > strong { --cap-shift: .39em; }
/* .srv-title is not itself display-font · its .srv-name child is, at 1.05rem -
   so the value is stated against that size rather than the row's. */
.srv-title { --cap-shift: calc(.39 * 1.05rem); }

.server-hero h1 > .tier-badge,
.srv-title > .tier-badge,
.stat > strong > .dot { transform: translateY(calc(-1 * var(--cap-shift))); }

/* NOTE: the "Java + Bedrock (cross-play)" emoji reported alongside these is NOT
   this problem and is not fixable here · it is an <option> inside a native
   <select> (src/views/serverform.js, the platform field), rendered by the OS
   widget in system-ui, not the display font. Option text cannot be styled. */

/* ==========================================================================
   Saved searches: row height  (visual pass, round 3)
   --------------------------------------------------------------------------
   Each row was ~250px tall for two lines of text, with Edit stranded far below
   the title. Cause: `.saved-list .muted { flex: 1 1 200px }` further up was
   written when `.muted` was a direct child of the row, which is a flex ROW -
   there, 200px is a width. `.saved-main` is a flex COLUMN, so the same
   declaration became a 200px flex-basis on the cross... on the block axis, i.e.
   200px of height for a one-line "tag: Skyblock".
   Reset it inside the column and let the row size to its content.
   ========================================================================== */
.saved-main > .muted {
  flex: 0 0 auto;
  font-size: .85rem;
}
.saved-item { align-items: center; }

/* ==========================================================================
   Light-theme accent: contrast  (visual pass, round 3)
   --------------------------------------------------------------------------
   Lighthouse's "background and foreground colors do not have a sufficient
   contrast ratio" on the homepage resolves almost entirely to one token. The
   light theme's --accent was #1f8a34, which against the surfaces it actually
   lands on measures:

       on #ffffff (panel)      4.43:1     needs 4.5
       on #f6f8fa (panel-2)    4.16:1
       on #f2f5f8 (bg)         4.05:1
       on a premium row tint   4.02:1

   · so every green link, server name, vote count, "Choose plan" label and
   active badge failed AA by a hair. Darkening the token to #1a7a2c clears it
   on all four (4.94-5.43:1) and is a small enough shift that the palette reads
   the same; it also lifts white-on-accent buttons from 4.43 to 5.43.
   --accent-dim moves down with it so hover and gradient states stay distinct.
   Dark theme is untouched: #3fb950 on #131922 is already ~7:1.
   ========================================================================== */
html[data-theme="light"] {
  --accent: #1a7a2c;
  --accent-dim: #14631f;
}

/* ==========================================================================
   Landing-page FAQ  (SEO pass, appended)
   --------------------------------------------------------------------------
   src/seo.js has emitted FAQPage JSON-LD on every game/category landing page
   since it was written, for four questions the page itself never rendered.
   Google requires FAQ markup to describe content the visitor can actually see,
   so faqBlock() in src/views/pages.js now renders them and these are its rules.

   Plain <details>: no JavaScript, so nothing here needs a CSP nonce, and the
   answers sit in the DOM whether or not a panel is open · which is what makes
   them count as content on a category page that may only have three listings.
   ========================================================================== */
.faq { margin-top: 24px; }
.faq-item {
  border-top: 1px solid var(--border);
  padding: 12px 0;
}
.faq-item:first-of-type { border-top: 0; }
.faq-item > summary {
  cursor: pointer;
  font-weight: 600;
  color: var(--text);
  list-style: none;
  display: flex;
  align-items: center;
  gap: 8px;
}
.faq-item > summary::-webkit-details-marker { display: none; }
.faq-item > summary::before {
  content: "+";
  color: var(--accent);
  font-weight: 700;
  width: 1em;
  flex: 0 0 auto;
}
.faq-item[open] > summary::before { content: "−"; }
.faq-item > summary:hover { color: var(--accent); }
.faq-item > p {
  margin: 8px 0 0 calc(1em + 8px);
  color: var(--muted);
  line-height: 1.6;
}

/* ==========================================================================
   Links inside running text  (a11y pass)
   --------------------------------------------------------------------------
   Lighthouse `link-in-text-block` on the server page:
     aside > section#supporters > p.form-help > a · link #3fb950 against the
     surrounding #8b949e at 1.21:1, with no other styling.
   The site-wide `a { text-decoration: none }` is right for nav, cards, chips
   and buttons, where position and shape already say "this is a control". It is
   wrong for a link sitting mid-sentence: hue becomes the only thing marking it,
   and at 1.21:1 against the prose around it a colour-blind reader cannot see
   there is a link there at all. WCAG 1.4.1 · do not use colour as the only
   visual means of conveying information.
   So: underline links that sit in a text block, and keep everything that is
   really a control unmarked. `.muted a` above already did this for one case;
   this generalises it.
   ========================================================================== */
p a, li a, dd a, figcaption a, .form-help a, .desc a, .st-foot a {
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: from-font;
}
/* Anything that is a control rather than prose keeps its own shape. */
p a.btn, li a.btn, dd a.btn, .form-help a.btn, .desc a.btn,
p a[class*="chip"], li a[class*="chip"],
p a[class*="pill"], li a[class*="pill"],
p a[class*="badge"], li a[class*="badge"],
li a.srv-name, li a.fchip { text-decoration: none; }

/* ==========================================================================
   Active-filter chips: native list semantics  (a11y pass)
   --------------------------------------------------------------------------
   `.fchips` was a <div role="list"> whose children were bare <a>s, which
   role="list" does not permit (Lighthouse aria-required-children). It is now a
   real <ul>/<li>, so the list semantics survive without the violation. These
   two rules only strip the UA list chrome · the flex layout, the gap and the
   chip styling all still come from .fchips / .fchip in filters.css, which are
   keyed on class and so are unaffected by the element change.
   ========================================================================== */
ul.fchips { list-style: none; margin: 0; padding: 0; }
ul.fchips > li { display: flex; min-width: 0; max-width: 100%; margin: 0; }

/* ==========================================================================
   HEADER: ONE ROW, AND ONE ROW THAT SCROLLS WHEN IT CANNOT BE
   --------------------------------------------------------------------------
   The header had wrapped to two rows at desktop width. Adding a seventh nav
   item ("What is on") and switching to Czech ("Přidat server", "Najdi si
   server", "Odhlásit") pushed the row past the 1148px the container can give
   it: logo 121 + nav 693 + actions 361 + gaps = 1203.

   Two separate things were needed, and only the first is load-bearing.

   1. STRUCTURE. `.site-header > .container` had `flex-wrap: wrap` and `.nav`
      had it too, so the moment the row overflowed, the whole actions block
      (language, theme, Log in, Sign up) dropped onto a second line and the
      header doubled in height. Now the container does not wrap at all above
      the mobile breakpoint, and `.nav` is the single flexible child: it
      absorbs the squeeze and scrolls horizontally, exactly the way
      `.game-strip` and `.sort-bar ul` already do further down the page. So
      the header is one row at ANY width above 620px, whatever a translator
      does to the labels, and the worst case is a nav you swipe rather than a
      header that grows.

   2. FIT. Scrolling is the safety net, not the plan: at 1180px (the
      container's max-width) everything should simply be visible. Trimming the
      gaps, the link padding and the nav font size gives back ~81px, which
      brings the Czech row to ~1122px and leaves room to spare. These are the
      numbers to revisit if a ninth nav item ever appears; the structure above
      is what stops that from breaking the layout again.

   Below 620px the original mobile treatment is kept as it was (nav on its own
   full-width scrolling row), hence restoring `flex-wrap: wrap` there.
   ========================================================================== */
@media (min-width: 621px) {
  .site-header > .container,
  .site-header .inner {
    flex-wrap: nowrap;
    gap: 10px;
  }

  /* The one child allowed to shrink. `min-width: 0` is what actually permits
     it. A flex item's automatic minimum size is its content, so without this
     the nav refuses to go below its natural width and overflows the header
     instead of scrolling inside it.

     Selector is `.site-header .nav`, not `.nav`: the block near the top of
     this file styles `.site-header nav` (0,1,1), which outranks a bare `.nav`
     (0,1,0) no matter how far down the file it sits. Written as `.nav` these
     declarations lost silently: the nav kept `flex-wrap: wrap` and `gap:
     14px` and wrapped INSIDE itself, making the header 86px tall instead of
     60px. Same reason for `.site-header .header-actions` below. */
  .site-header .nav {
    flex: 1 1 auto;
    min-width: 0;
    flex-wrap: nowrap;
    gap: 2px;
    overflow-x: auto;
    /* `overflow-x: auto` alone computes overflow-y to `auto` as well, which
       can raise a stray vertical scrollbar inside a 60px-tall box. */
    overflow-y: hidden;
    scrollbar-width: none;
    -ms-overflow-style: none;
    overscroll-behavior-x: contain;
  }
  .site-header .nav::-webkit-scrollbar { display: none; }
  .site-header .nav a { padding-inline: 9px; font-size: .88rem; }

  /* Never shrinks and never wraps: these are the controls a visitor must be
     able to hit, and a half-width "Sign up" is worse than a scrolling nav. */
  .site-header .header-actions { flex: 0 0 auto; gap: 6px; }
}

/* Small screens. `.header-actions` is ~361px wide with no wrap or overflow
   rule of its own, which is wider than a 320px viewport. That is the
   horizontal-scroll bug this must not reintroduce. Letting the block wrap
   within itself keeps it inside the viewport without touching the desktop
   row above. */
@media (max-width: 620px) {
  .site-header > .container,
  .site-header .inner { flex-wrap: wrap; }

  /* The mobile rule above has said `.nav { width: 100%; overflow-x: auto }`
     for a long time, which only means anything if the nav is a single line,
     but `.site-header nav` (higher specificity than `.nav`) kept
     `flex-wrap: wrap`, so it wrapped instead of scrolling: seven items and
     Czech labels rendered as FOUR stacked rows, a 140px-tall nav. Same
     nowrap-and-scroll treatment as the desktop row, and `min-height: 0` so
     this secondary row is one link tall rather than inheriting the 60px meant
     for the full header. */
  .site-header .nav {
    flex-wrap: nowrap;
    gap: 2px;
    min-height: 0;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    -ms-overflow-style: none;
    overscroll-behavior-x: contain;
  }
  .site-header .nav::-webkit-scrollbar { display: none; }

  .site-header .header-actions {
    flex-wrap: wrap;
    justify-content: flex-end;
    min-width: 0;
    max-width: 100%;
  }
  .site-header .lang-switch { min-width: 0; }
  .site-header .lang-switch select { max-width: 100%; }
}

/* ==========================================================================
   SERVER DETAIL PAGE: breadcrumb rhythm, hero legibility, FAQ density
   (visual pass, round 4 · appended block, see the note at the top of the
   layout-repairs section about several agents editing this file)
   ========================================================================== */

/* -------------------------------------------------- 1. breadcrumb rhythm --
   `.crumbs` carried `margin: 18px 0 -6px`. The negative bottom margin pulled
   the trail down onto the hero panel, so "Home › Minecraft › Skyblock ›
   Crystal Peaks" sat flush against it with 40px of air above and none below.
   Both numbers were one-offs. The page's rhythm is `--gap`, which is what
   every card, the hero and the stat strip already use for their bottom
   margin, so the trail now sits in the same grid as everything else. */
.crumbs { margin: var(--gap) 0; }

/* --------------------------------- 2. the hero is a DARK surface, always --
   `.server-hero::before` paints a near-black scrim (up to rgba(3,6,10,.82))
   in BOTH themes, so the hero is a dark panel on a white page. An earlier
   pass re-pointed the ink tokens for the subtree, which was the right idea,
   but it re-pointed `--panel-2` while `.server-hero` itself still declares
   `background: var(--panel-2)`. The element therefore repainted its own base
   with the new value, `rgba(255,255,255,.15)`, i.e. near-white. The scrim
   only reaches full strength at 78% of the height, so the whole panel came
   out mid-grey and every one of the light-ink descendants landed on it:
   measured on /server/137-crystal-peaks in light mode, the h1 was 2.09:1,
   the MOTD body 1.93:1, the DIAMOND badge 1.09:1 and the meta chips 3.05:1.

   Two things fix that at the root rather than per element:

   a) an OPAQUE dark base colour, stated as a longhand so it survives whatever
      `--panel-2` is re-pointed to, and so it stays underneath a banner image
      if `.has-banner` ever gets a rule (today it has none, see the report);
   b) ONE token block that applies in both themes rather than only in light.
      The accent family is included because it was the gap that made the tier
      badge and any coloured chip resolve against a white page: `--cyan` in
      light mode is #0f7c8c, which is a dark teal on a near-black scrim. */
.server-hero,
html[data-theme="light"] .server-hero {
  /* Longhand, not the `background` shorthand: the shorthand higher up also
     carries `center / cover no-repeat`, which must survive. */
  background-color: #0d131b;

  --text: #f3f7fb;
  /* Was #8b949e (dark) / #c2ccd8 (light). Both read as "disabled control" on
     the chips, which are the address, the country, the version and the
     languages: information, not greyed-out controls. */
  --muted: #d3dde8;
  --border: rgba(255, 255, 255, .38);
  --border-soft: rgba(255, 255, 255, .22);
  --panel: rgba(255, 255, 255, .14);
  --panel-2: rgba(255, 255, 255, .18);
  --bg: rgba(0, 0, 0, .45);
  --bg-elev: rgba(255, 255, 255, .14);

  /* The dark-theme accents, in both themes, because the surface is dark in
     both. Gold and cyan are lifted a little further for the tier badge's 14%
     tint of itself. */
  --accent: #3fb950;
  --accent-dim: #2a7f38;
  --accent-ink: #06170a;
  --gold: #e0ac3d;
  --cyan: #5cd8e8;
  --danger: #ff6b62;

  --ink-vip: #3fb950;
  --ink-premium: #e0ac3d;
  --ink-diamond: #5cd8e8;
}

/* The generated monogram is the last hero descendant still painted from the
   page palette: `html[data-theme="light"] .srv-icon-gen` gave it a pastel
   tile, which on a near-black hero reads as a sticker rather than an icon.
   Same hue, the dark-surface values. */
html[data-theme="light"] .server-hero .srv-icon-gen {
  background:
    linear-gradient(140deg,
      hsl(var(--h, 140) 45% 30%),
      hsl(var(--h, 140) 40% 18%));
  border-color: hsl(var(--h, 140) 35% 34%);
  color: hsl(var(--h, 140) 55% 82%);
}

/* Enumerating every rule that matches a hero descendant and carries
   `[data-theme="light"]` in its selector turns up four: `.srv-icon-gen` and
   `.motd-rich` (both handled above), `.fav-btn` (which only reads --bg-elev,
   re-pointed above, so it follows), and `.badge-chip` in badges.css, which
   forces an OPAQUE white pill, `color-mix(var(--chip) 12%, #fff)`, with ink
   mixed 62% toward #111a22. On the hero's scrim that is a white sticker with
   grey text: measured 3.01:1. Put the dark-surface treatment back. */
html[data-theme="light"] .server-hero .badge-chip {
  --chip-bg: color-mix(in srgb, var(--chip) 16%, transparent);
  --chip-bd: color-mix(in srgb, var(--chip) 46%, var(--border));
  color: var(--chip);
}
html[data-theme="light"] .server-hero .badge-chip:hover,
html[data-theme="light"] .server-hero .badge-chip:focus-visible {
  background: color-mix(in srgb, var(--chip) 26%, transparent);
  color: var(--chip);
}
/* The chip's tooltip reads `var(--bg-elev, var(--panel))`, and both of those
   are translucent inside the hero, so the panel it pops up in was see-through
   in either theme. It is a floating surface, not part of the scrim: give it a
   solid one. */
.server-hero .badge-chip[data-tip]::after,
.server-hero .badge-chip[data-tip]::before {
  background: #10161f;
  border-color: rgba(255, 255, 255, .28);
}
.server-hero .badge-chip[data-tip]::after { color: #f3f7fb; }

/* ---------------------------------------- 3. the tier badge had no colour --
   `.tier-badge[data-tier="DIAMOND"]` never matched anything rendered by the
   server. tierBadge() in src/views/layout.js emits `data-tier="diamond"` from
   PREMIUM_TIERS[].key, and CSS attribute VALUES are case sensitive in an HTML
   document, so every tier badge on the site fell through to the base rule:
   `color: var(--muted)` on `background: var(--panel-2)`, which is the grey on
   grey that reads as no badge at all. public/js/app.js renders the same badge
   client side and upper-cases the value, so the two emitters disagree; the
   `i` flag matches both and keeps matching whichever way they drift.

   The ink is a token rather than a literal so the hero can re-point it above
   with the rest of its palette instead of needing a `.server-hero` exception
   here. On a white page the page-level accents are too light behind a 14%
   tint of themselves (gold measured 3.86:1, cyan 4.07:1, green 4.47:1), so
   the light theme carries its own darker inks. */
:root {
  --ink-vip: var(--accent);
  --ink-premium: var(--gold);
  --ink-diamond: var(--cyan);
}
html[data-theme="light"] {
  --ink-vip: #14631f;
  --ink-premium: #7a5602;
  --ink-diamond: #0a5c68;
}
.tier-badge[data-tier="vip" i] { --tier: var(--ink-vip); }
.tier-badge[data-tier="premium" i] { --tier: var(--ink-premium); }
.tier-badge[data-tier="diamond" i] { --tier: var(--ink-diamond); }
.tier-badge[data-tier="vip" i],
.tier-badge[data-tier="premium" i],
.tier-badge[data-tier="diamond" i] {
  color: var(--tier);
  border-color: color-mix(in srgb, var(--tier) 58%, transparent);
  background: color-mix(in srgb, var(--tier) 14%, transparent);
}
.tier-badge[data-tier="diamond" i] {
  box-shadow: 0 0 12px color-mix(in srgb, var(--tier) 22%, transparent);
}

/* ------------------------------------------------- 4. per-server FAQ grid --
   serverFaqItems() in src/seo.js produces eleven questions, and each one
   rendered as a full-width row: one short line of text, a `+`, and half a
   screen of empty space to its right, filling most of a viewport at the foot
   of the Overview panel.

   Layout only. The markup is untouched, the block stays at the bottom of
   Overview, and the visible list is still the same array the FAQPage JSON-LD
   is built from, which is what makes the structured data legal.

   `auto-fit` rather than a width breakpoint: the panel is ~765px wide beside
   the sidebar but full width once `.grid-2` stacks below 900px, so a viewport
   query would be measuring the wrong box. A 270px minimum gives two columns
   on the desktop panel, and one column both on a phone and in the narrow
   two-column window around 900px where a second track would be cramped.

   Every item keeps a top rule, including the first: with two columns the item
   at the top of the right column needs one, and `:first-of-type` can only
   exempt one of the two. */
#server-faq {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
  column-gap: 26px;
  align-items: start;
}
#server-faq > .card-title { grid-column: 1 / -1; }
#server-faq > .faq-item {
  border-top: 1px solid var(--border);
  padding: 10px 0;
}

/* ==========================================================================
   Paid features that are actually gated
   (trailer embed · homepage banner · the locked delivery funnel)

   Added in one block so the three surfaces that /premium sells can be read
   together. Each of them is a paid perk, so each of them is also LABELLED:
   money buys placement and presentation here, never rank, and that only holds
   if a visitor can tell which is which without reading the source.
   ========================================================================== */

/* -------------------------------------------------------- trailer embed --
   A facade, not an iframe. No YouTube request happens until .trailer-play is
   pressed and public/js/app.js swaps in .trailer-frame · see the header of
   src/views/trailer.js. The button therefore has to LOOK like a video, which
   is what the 16/9 box and the play glyph are for. */
.trailer-embed { margin: var(--gap) 0 0; }

.trailer-play {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
  background: var(--bg-soft, #0e141c);
  cursor: pointer;
}
.trailer-play:hover, .trailer-play:focus-visible { border-color: var(--accent); }

.trailer-poster {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The poster is the listing's own screenshot, not a YouTube thumbnail, so its
     aspect ratio is whatever the owner uploaded. Cover, then dim, so the play
     glyph stays readable over a bright screenshot. */
  filter: brightness(.72);
}
.trailer-play:hover .trailer-poster { filter: brightness(.85); }

/* No screenshot and no banner: draw the box rather than collapse it. */
.trailer-poster-blank {
  display: block;
  width: 100%;
  height: 100%;
  background:
    radial-gradient(circle at 50% 45%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
    var(--bg-soft, #0e141c);
}

.trailer-icon {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 2.6rem;
  line-height: 1;
  color: #fff;
  text-shadow: 0 2px 14px rgba(0, 0, 0, .6);
  pointer-events: none;
}
.trailer-play:hover .trailer-icon { transform: scale(1.08); }

.trailer-frame {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  border: 1px solid var(--border);
  border-radius: 10px;
}

/* The caption carries the privacy statement AND the plain YouTube link, so the
   block still works with JavaScript off · the link is the no-JS path. */
.trailer-cap { margin-top: 6px; font-size: .84rem; }
.trailer-cap a { margin-left: 6px; }
.trailer-embed.is-playing .trailer-cap { opacity: .7; }

@media (prefers-reduced-motion: reduce) {
  .trailer-play:hover .trailer-icon { transform: none; }
}

/* ------------------------------------------------------ homepage banner --
   One sold slot above the ranking. The tag is not decoration and must never be
   hidden at any width: it is the only thing separating "paid to be here" from
   "earned its way here", and the whole ranking argument rests on that line
   being visible. */
.home-banner {
  position: relative;
  margin: 0 0 var(--gap);
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-left: 3px solid var(--cyan);
  border-radius: 10px;
  background: color-mix(in srgb, var(--cyan) 6%, transparent);
}

.home-banner-tag {
  display: inline-block;
  margin-bottom: 6px;
  font-size: .68rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--muted);
}

.home-banner-link {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  text-decoration: none;
  color: inherit;
}

.home-banner-img {
  max-width: 100%;
  height: auto;
  border-radius: 4px;
}

.home-banner-txt { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.home-banner-txt .muted { font-size: .86rem; }

/* The hover affordance is the panel, not an underline on the name.
   An underline under one word inside a block that is entirely a link reads as a
   second, nested link to somewhere else · and in the pixel display face it lands
   as a hard green rule directly beneath the name, which looks like a border the
   design did not ask for rather than like a hover state. Lifting the border and
   the tint says "this whole strip is clickable", which is what is actually true.
   `.srv-name` is explicitly cleared so the base `a:hover` rule cannot put the
   underline back. */
.home-banner-link:hover .srv-name,
.home-banner-link:focus-visible .srv-name { text-decoration: none; }
.home-banner:has(.home-banner-link:hover),
.home-banner:has(.home-banner-link:focus-visible) {
  border-color: var(--cyan);
  background: color-mix(in srgb, var(--cyan) 12%, transparent);
}

/* ------------------------------------------- the funnel, locked by plan --
   Same shape as the measured panel (.fn-* live in account.css), so a free
   owner sees the sections that exist rather than a gap where something used to
   be. Zero and unavailable must never look the same · the striped bar is the
   one in account.css that already means "not a number". */
.funnel-panel.is-plan-locked .fn-bar { opacity: .5; }
.funnel-panel.is-plan-locked .fn-stage-note { opacity: .8; }
.fn-export { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 14px; }
.fn-export .muted { font-size: .84rem; }

/* ==========================================================================
   DISPLAY-FONT INK OFFSET, SWEPT RATHER THAN PATCHED
   --------------------------------------------------------------------------
   Measured with canvas TextMetrics on the real rendered font: Minecraftia at
   19.2px inks a cap "M" from 30px above the baseline down to 11px above it. It
   never reaches the baseline at all, and its optical centre sits 1.07em above
   it. Everything the font does not have falls back to a normal face, so a
   fallback glyph or a chip lands well below the letters next to it. Two
   different starting points, so two derived constants, both from that one
   measurement:

     · a box aligned by its CENTRE (a flex item under `align-items: center`,
       or the logo cube) starts 0.70em above the baseline, so it needs
       0.39em of lift. That is the existing `--cap-shift`, unchanged.
     · a glyph aligned by its BASELINE (an emoji or symbol in a text run)
       has its ink centre about 0.36em above the baseline, so it needs
       0.70em. Measured per glyph: ✅ .703, ✕ .703, ⛏ .674, ♥ .729.

   Both are registered with @property so the em resolves against the element
   that DECLARES it, which is the display-font element, and then inherits down
   as an absolute length. Writing `translateY(-.7em)` on the glyph instead
   resolves against the glyph's own font-size and undershoots, which is the
   trap the earlier hero badge fix hit.

   Found by enumerating every element whose computed font-family resolves to
   Minecraftia and which shares its first line box with a fallback glyph, a
   chip or a button, across 26 pages. See the report for the full list.
   ========================================================================== */
@property --glyph-shift {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

h1, h2, h3, h4,
.card-title, .logo, .scard-stamp, .scard-edge { --glyph-shift: .7em; }

/* The wrapper displayIcon() in src/views/layout.js puts around an emoji that
   is concatenated into display-font text. A bare text node cannot carry a
   transform, which is why the glyph needs an element of its own. */
.dfi,
.scard-stamp > b,
.scard-edge > b {
  display: inline-block;
  transform: translateY(calc(-1 * var(--glyph-shift)));
}

/* Chips and buttons that sit inside a heading. `.server-hero h1` and
   `.srv-title` were each given this treatment by hand; this generalises it to
   every heading that actually contains one, so the next `<h2>Name <span
   class="badge">` does not need a rule of its own. The `:has()` guard keeps
   the flex context off headings that are plain text. */
:is(h1, h2, h3, h4):has(> .badge, > .tier-badge, > .stars, > .btn) {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  --cap-shift: .39em;
}
:is(h1, h2, h3, h4) > .badge,
:is(h1, h2, h3, h4) > .tier-badge,
:is(h1, h2, h3, h4) > .stars,
:is(h1, h2, h3, h4) > .btn {
  transform: translateY(calc(-1 * var(--cap-shift)));
}

/* -------------------------------------------------------- owner tools --
   The card on the server page and the same actions on a /me row, both drawn
   by src/views/ownertools.js. Only the layout differs here; the markup and the
   wording are shared, which is the point of that file. */
.owner-tool-links { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.owner-tools .bump-form { display: block; margin: 10px 0 6px; }

/* The countdown is a <span> inside the button, so it must not introduce its
   own font or baseline · it is the button's own label, one second later. */
.bump-countdown { font: inherit; font-variant-numeric: tabular-nums; }

/* ---------------------------------------------------------- claim page --
   The Votifier host/port/token fields (src/views/claim.js) only mean
   anything once "Votifier test vote" is the chosen method; showing them
   unconditionally next to three other methods just added clutter for
   everyone picking DNS, MOTD or the meta tag. `:not(:has())` so a browser
   without `:has()` support fails open to always-visible rather than
   permanently hidden. */
.claim-picker:not(:has(input[name="method"][value="votifier"]:checked)) .votifier-fields {
  display: none;
}
.bump-countdown-inline { font-size: .8rem; }
.bump-note { display: inline-block; margin-left: 6px; }

/* On a /me row the control sits with the four action links and has to stay on
   the same wrap line as them rather than stretching the row. */
.acct-owner-actions .bump-form { display: inline-block; margin: 0; }

/* Display-font headings with nothing inline to align TO still sit high inside
   their own line box, which is what makes the two headings on the vote
   confirmation page read as jammed under the line above with a gap beneath.
   Measured on `.attr-survey-title` at 18.4px in a 22.1px line box: the ink
   starts 4.5px ABOVE the box and stops 8.6px short of its bottom, so half the
   imbalance is 6.6px, which is 0.36em, the same constant again. Moving the ink
   down rather than the box keeps every margin around the heading as it was.
   This is the `.logo-word` treatment, and it is deliberately NOT applied to
   h1-h4 as a whole: `.server-hero h1`, `.srv-title`, `.stat > strong`, the
   logo and `.rank-num` already correct this per component, and a blanket rule
   would double up on all five. See the report. */
.attr-survey-title,
.sv-ask-title { transform: translateY(.39em); }

/* ======================================================================
   Points vs votes, and the owner's vote-cooldown choice
   ----------------------------------------------------------------------
   The ranking counter stopped being a vote count when owners gained the
   ability to choose how often their players may vote, so every surface
   that shows the counter now shows the real vote count beside it. These
   are the classes that pair the two, plus the trade table on the owner
   form and the published formula on /about.
   ====================================================================== */

/* The small true-vote count under a points figure. Used in the listing
   row, the server page stat tiles, the dashboard and the leaderboard, so
   it is deliberately layout-neutral: block, quiet, tabular. */
.row-votes,
.stat-sub {
  display: block;
  font-size: .72rem;
  font-weight: 400;
  line-height: 1.3;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* "4x a day" next to the Vote button on a listing row. Only rendered for
   servers that shortened their cooldown, so it never appears on the
   majority of rows and can afford to be noticeable. */
.vote-freq {
  display: inline-block;
  margin-top: 4px;
  padding: 1px 6px;
  border: 1px solid var(--border);
  border-radius: 999px;
  font-size: .68rem;
  line-height: 1.6;
  color: var(--muted);
  white-space: nowrap;
}

/* Leaderboard points column: the number leads, "this month" explains the
   window, since the Votes column beside it covers the chosen range. */
.st-points { font-variant-numeric: tabular-nums; }

/* ---- the owner's picker on the server form ------------------------- */
.sf-cooldown-table {
  width: 100%;
  margin: 10px 0 4px;
  border-collapse: collapse;
  font-size: .82rem;
  font-variant-numeric: tabular-nums;
}
.sf-cooldown-table caption {
  text-align: left;
  padding-bottom: 6px;
  color: var(--muted);
  font-size: .78rem;
}
.sf-cooldown-table th,
.sf-cooldown-table td {
  padding: 5px 8px;
  text-align: right;
  border-bottom: 1px solid var(--border);
}
.sf-cooldown-table thead th {
  text-align: right;
  color: var(--muted);
  font-weight: 600;
  font-size: .74rem;
}
.sf-cooldown-table th[scope="row"],
.sf-cooldown-table thead th:first-child { text-align: left; }
/* The row the owner is currently on. Marked, never recommended: nothing
   in this table says which choice is the good one, because that depends
   on a community we cannot see from here. */
.sf-cooldown-table tr.is-current { background: var(--surface-2, rgba(127, 127, 127, .12)); }
.sf-cooldown-table tr.is-current th[scope="row"] { font-weight: 700; }

/* ---- the published formula on /about ------------------------------- */
.points-table td,
.points-table th[scope="row"] { font-variant-numeric: tabular-nums; }
.points-table td:not(:first-child),
.points-table thead th:not(:first-child) { text-align: right; }

@media (max-width: 640px) {
  /* The trade table is four narrow numeric columns; letting it scroll
     inside its own box is better than shrinking the page's type. */
  .sf-cooldown-table,
  .points-table { display: block; overflow-x: auto; }
}

/* `.card-title` is a `justify-content: space-between` flex row, so giving the
   icon an element of its own turned it into a flex item and pushed it to the
   far left with the heading text stranded at the right. The icon belongs to
   the words next to it, so a heading that carries one packs to the start. */
:is(h1, h2, h3, h4, .card-title):has(> .dfi) { justify-content: flex-start; }

/* ==========================================================================
   Formatted descriptions (.rt-*), and room for a fourth browsing mode.

   The classes below are the COMPLETE set src/richtext.js can emit. Nothing an
   owner types produces a class name, so this list is the whole surface: if a
   rule is not here, the parser cannot reach it.

   `.desc` already styles p / ul / a inside the About panel, so these mostly add
   the parts a plain-text description never had: heading rhythm, list spacing
   and a link that is visibly a link. They are written to work on their own
   anywhere `.rt` is used, not only inside `.desc`.
   ========================================================================== */
.rt-p { margin: 0 0 1em; }
.rt-p:last-child { margin-bottom: 0; }

.rt-h {
  margin: 1.4em 0 .5em;
  font-size: 1.05rem;
  line-height: 1.3;
}
.rt-h:first-child { margin-top: 0; }
h4.rt-h { font-size: 1rem; }
h5.rt-h { font-size: .94rem; color: var(--muted); }

.rt-list { margin: 0 0 1em; padding-left: 1.3em; }
.rt-list:last-child { margin-bottom: 0; }
.rt-li { margin: .25em 0; }

.rt-b { font-weight: 700; }
.rt-i { font-style: italic; }

/* An owner's own link, always rel="noopener nofollow external". Marked out from
   our own links so a reader can tell whose link they are about to follow. */
.rt-a { text-decoration: underline; text-underline-offset: 2px; }
.rt-a:hover { text-decoration-thickness: 2px; }

/* The switch carries four modes now ("Ask for a server" is the longest label
   on it), so it wraps instead of pushing the controls row sideways. */
.mode-switch { flex-wrap: wrap; }

/* ==========================================================================
   MOBILE ROUND: page-level sideways scroll, clipped names, stray underlines
   ========================================================================== */

/* ------------------------------------------------ 1. horizontal overflow --
   At 375px the server page document was 526px wide. The element actually too
   wide was `nav.sp-rail`: it laid out at 512px with clientWidth 512, so it was
   not scrolling at all, and every card in the column inherited that width.

   Cause is one missing `minmax(0, …)`. The desktop rule is
   `grid-template-columns: minmax(0, 1fr) 300px`, but the mobile override drops
   to a bare `1fr`, and a `1fr` track has an automatic minimum of min-content.
   The widest min-content in that column is the tab rail, whose five `.sp-tab`
   pills are `white-space: nowrap; flex: 0 0 auto` and sum to 512px, so the
   track was sized to the rail instead of the rail scrolling inside the track.
   Restoring the floor takes the document back to 375px and leaves the rail at
   347px wide with a 512px scrollWidth, which is a rail that scrolls in place.

   This is the general shape of the bug, not a one-off: any `1fr` track holding
   a nowrap scroller needs the explicit zero minimum. */
@media (max-width: 900px) {
  .grid-2 { grid-template-columns: minmax(0, 1fr); }
}

/* The rail also carried `margin: 0 -2px`, which put its edges 2px outside the
   container's padding box on both sides. That is 4px of bleed a scroll
   container has no business having, and it is the same negative-margin trap a
   previous mobile audit found. The 2px of padding it already has is what gives
   the first and last tab room for a focus ring, so the margin is not needed
   for that. `min-width: 0` so the rail can always be narrower than its tabs. */
.sp-rail {
  margin-inline: 0;
  min-width: 0;
}

/* -------------------------------------- 2. clipped names and tier badges --
   The stacked-card treatment for `.server-table` under 720px turns every `td`
   into a block. It was written when the server cell was `td > div.srv-cell`,
   so it says `td:has(.srv-cell)`, but the class now sits ON the td, which
   `:has()` does not match. The live rule was therefore
   `.server-table td.srv-cell { display: table-cell }` from further down the
   sheet, which at (0,2,1) also outranks the media query's `.server-table td`
   at (0,1,1): media queries add no specificity.

   So on a phone the server cell stayed a table-cell sharing a row with the
   rank cell, and still printed its "SERVER" data-label. `.srv-meta` got 197px
   of a 343px row, which is what cut "Crystal Peaks" to "Crystal Pea" and left
   the address chip clipped. As a block it gets 253px: the name fits whole and
   the tier badge sits beside it. */
@media (max-width: 720px) {
  .server-table td.srv-cell { display: block; }
  .server-table td.srv-cell::before { display: none; }
}

/* A name may still have to truncate on a narrow phone, and when it does it
   must say so. `.srv-title .srv-name` sets this, but the base class is used in
   sponsor heads, discovery cards and the stats board too, and any of those can
   be given `overflow: hidden` by a container. Stating it on the class means a
   name is never cut mid-word without the mark that says it was cut. The tier
   badge is `flex: 0 0 auto` and the name is the flex item that shrinks, so the
   paid badge is never what disappears. */
.srv-name { text-overflow: ellipsis; }

/* ------------------------------------------- 3. underlines on wrapper links --
   `a:hover { text-decoration: underline }` is right for a link that is a run
   of text. It is wrong for a link that WRAPS a laid-out block, because the
   underline then paints across everything inside it: on the homepage sponsored
   placement it struck through the server name, the tier badge and the MOTD
   line at once. Each of these three already sets `text-decoration: none` at
   rest and simply never guarded the hover state.

   The banner's affordance was originally an underline on `.srv-name`. That has
   since been replaced by a lift on the panel itself: underlining one word inside
   a block that is wholly a link reads as a nested link, and in the pixel display
   face it drew as a hard green rule under the name. See `.home-banner:has(…)`. */
.home-banner-link:hover,
.fb-tab:hover,
.gal-link:hover { text-decoration: none; }

/* The `p a, li a, …` rule above underlines links inside running text, which is
   WCAG 1.4.1: a link in a sentence must not be identifiable by colour alone.
   These two are not in a sentence. Each is the entire content of its element,
   with no prose around it to be distinguished from: a table-of-contents entry,
   and the single call to action under a FAQ answer. Position and context
   already say "link", so the underline is noise rather than information. */
.legal-toc-inner a,
p.faq-link > a { text-decoration: none; }

/* ==========================================================================
   Claim outreach · the page an operator lands on from an invitation link
   (src/views/outreach.js), plus the one control on the admin side of it.

   This page is the first thing a server owner ever sees of this site, arriving
   from a message they did not ask for about a listing they did not create. The
   styling has one job: make "we listed this, you did not, and here is how to
   delete it" impossible to skim past. Hence the amber rule down the hero and
   the two action cards side by side at equal weight · the way out has to look
   exactly as available as the way in, because we created the listing unasked.
   ========================================================================== */

.invite-hero {
  border-left: 3px solid var(--gold);
  padding: 4px 0 4px 20px;
  margin: 8px 0 var(--gap);
}
.invite-kicker {
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: .06em;
  color: var(--gold);
  margin: 0 0 6px;
  font-size: .82rem;
}
.invite-hero h1 { margin: 0 0 12px; }
.invite-lead { margin: 0 0 10px; max-width: 68ch; }
/* Not a flash or an error · a statement of fact that must not be mistaken for
   one, so it is emphasised without borrowing the danger colour. */
.invite-warn {
  margin: 10px 0 0;
  max-width: 68ch;
  font-weight: 600;
  color: var(--text);
}

/* Every field we hold, blanks included. Two columns, label first, and the
   label column is fixed so a row of "nothing on file" values lines up · that
   column of blanks IS the argument the page is making. */
.invite-facts { width: 100%; border-collapse: collapse; }
.invite-facts th,
.invite-facts td {
  text-align: left;
  padding: 8px 12px 8px 0;
  border-bottom: 1px solid var(--border-soft);
  vertical-align: top;
  font-weight: 400;
}
.invite-facts th {
  width: 11rem;
  color: var(--muted);
  font-family: var(--font-display);
  font-size: .82rem;
  text-transform: uppercase;
  letter-spacing: .04em;
}
.invite-facts tr:last-child th,
.invite-facts tr:last-child td { border-bottom: 0; }

.invite-actions {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: var(--gap);
  align-items: start;
}
.invite-privacy { max-width: 78ch; font-size: .9rem; }

@media (max-width: 560px) {
  .invite-facts th, .invite-facts td { display: block; padding-right: 0; }
  .invite-facts th { width: auto; border-bottom: 0; padding-bottom: 2px; }
}

/* The generated link in the admin table. It is long, it is the thing an admin
   copies, and it must wrap rather than stretch the table off the page. */
.outreach-link {
  font-family: var(--font-mono);
  font-size: .78rem;
  word-break: break-all;
  text-align: left;
  max-width: 22rem;
}

/* One more page-level sideways scroll, same rule, different page. At 375px
   /stats had a 1004px document. The offender is the screen-reader data table
   `lineChart()` emits beside each chart: `.sr-only` is the standard
   visually-hidden recipe, but `width: 1px` is only a MINIMUM for a table, so
   the one with 24 hourly columns laid out at 974px. It is absolutely
   positioned against the initial containing block, so that width became the
   document's. Hiding `.sr-only` took the page from 1004px to 375px, which is
   the whole of it.
   `max-width` does not clamp a table and `contain` is ignored on one, but a
   fixed box contributes nothing to document scroll. The element is already
   clipped to nothing by `clip-path`, its semantics are untouched, and screen
   readers work from the accessibility tree rather than the box. Scoped to the
   tables because they are the only `.sr-only` content that can be wide. */
table.sr-only { position: fixed; }

/* At 320px in Czech the server page still ran to 352px. The offender is the
   "Full statistics for <name>" button under the stat strip: `.btn` is
   `white-space: nowrap`, which is right for "Vote" and wrong for a label that
   is a whole sentence and grows with the server name and the translation. It
   measured 336px inside a 288px content box. Nowrap stays the default for
   every other button; this one is allowed to wrap, and is clamped so it can
   never be wider than the column whatever a translator does to it. */
.stat-row-more .btn {
  white-space: normal;
  max-width: 100%;
  text-align: center;
}

/* ------------------------------------------- 4. server names are never underlined --
   Global rule, deliberately last in the file so nothing can put it back.

   `.srv-name` is the server's name wherever it appears: the listing table, the
   swipe deck, sponsor heads, discovery cards, the stats board and the similar
   rail. It is a title, never a run of prose, so the WCAG 1.4.1 argument for
   underlining links inside sentences does not apply · there is no surrounding
   text it needs to be distinguished from, and colour is not carrying the meaning
   alone because position and weight already do.

   Underlines kept reappearing on it one component at a time: the base
   `a:hover { text-decoration: underline }` caught it inside every wrapper link,
   and each fix only guarded the one place that had been reported. In the pixel
   display face the result is worse than a normal underline, because the glyphs
   never reach the baseline · the rule lands as a detached hard bar under the
   name and reads as a border nobody designed.

   Hover affordance belongs to the container (a panel lift, a border colour),
   not to a line under the title. */
.srv-name,
.srv-name:hover,
.srv-name:focus,
.srv-name:focus-visible,
.srv-name:active,
a:hover .srv-name,
a:focus-visible .srv-name { text-decoration: none; }

/* ==========================================================================
   Sponsored placement · the address

   The strip is sold to put a player on that server, and until now it showed the
   tier, the name and the MOTD but not the one thing that gets anybody there.
   The copy control is the site's usual `.srv-ip.copy-ip`, so it inherits the
   clipboard behaviour, the "Copied!" swap and the join-intent ping from
   public/js/app.js with nothing new to maintain, and joinButton() decides what
   the address IS · an IP, a realms.gg invite or a browser client page.

   Two rules matter here:
   1. It sits in `.home-banner-body`, a sibling of the wrapper <a>, never inside
      it. Interactive content nested in a link is invalid and, in practice,
      unclickable in the middle of it.
   2. `.home-banner-tag` is untouched. It is above this row, it is never
      `position: absolute`, and nothing here can push it off screen or under
      anything else · "paid to be here" has to survive every width.
   ========================================================================== */
.home-banner-body {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
}
/* The link takes the slack, the address keeps its natural width, and min-width:0
   lets a long server name shrink instead of pushing the row wider than the page
   (the .srv-name ellipsis rule above then has something to work with). */
.home-banner-body > .home-banner-link { flex: 1 1 12rem; min-width: 0; }
.home-banner-ip {
  flex: 0 0 auto;
  /* `.srv-ip` carries a 6px bottom margin for the stacked layouts it was built
     for; in a centred flex row that reads as the chip sitting high. */
  margin-bottom: 0;
  max-width: 100%;
  cursor: pointer;
}
.home-banner-ip:hover { border-color: var(--accent); color: var(--text); }
.home-banner-ip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* At 320px the strip's content box measures 264px, which a real address plus
   the name and the MOTD cannot share. Below 420px the row simply stacks: the
   link takes a full line, the chip takes the next one at the full 264px, and
   the document stays 320px wide with nothing to scroll sideways. */
@media (max-width: 420px) {
  .home-banner-body > .home-banner-link { flex: 1 1 100%; }
  .home-banner-ip { width: 100%; text-align: left; }
}
