:root {
  /* Tells Chromium which native palette to paint form controls with
     (select dropdown popups, scrollbars) — without this, a <select>'s
     native popup renders with the OS-default light chrome for its first
     paint and only picks up the page's own colors a frame later, which is
     exactly the white-then-black flash. This makes the popup dark from
     its very first frame instead of needing author CSS to override it
     after the fact. */
  color-scheme: dark;
  --bg: #100e17;
  --bg-gradient-inner: #241f33;
  --panel: #1a1723;
  --panel-2: #221e2d;
  --border: #2c2836;
  --text: #f4f1fa;
  --muted: #948da3;
  --accent: #ff5d73;
  --accent-2: #6a5cff;
  --ok: #4fd68a;
  --off: #555063;
  --input-bg: #0d0b13;
  --secondary-btn-bg: linear-gradient(135deg, #3a4358, #354a63);
}

/* Light theme — closer to a "light gray" Photoshop-style UI than stark
   white, so glow/border accents (chat-glow, hover panels, etc.) still read
   against the surface instead of washing out. Applies when the OS prefers
   light and nothing's been explicitly chosen, or when Light is explicitly
   picked in Settings regardless of the OS. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) {
    color-scheme: light;
    --bg: #d8d5de;
    --bg-gradient-inner: #eae7f0;
    --panel: #f1eff5;
    --panel-2: #ffffff;
    --border: #cac6d3;
    --text: #211d2b;
    --muted: #6b6577;
    --accent: #e0435c;
    --accent-2: #5847e0;
    --ok: #2f9e64;
    --off: #b5b0bf;
    --input-bg: #ffffff;
    --secondary-btn-bg: linear-gradient(135deg, #e3e1ea, #d3d8e3);
  }
}
:root[data-theme='light'] {
  color-scheme: light;
  --bg: #d8d5de;
  --bg-gradient-inner: #eae7f0;
  --panel: #f1eff5;
  --panel-2: #ffffff;
  --border: #cac6d3;
  --text: #211d2b;
  --muted: #6b6577;
  --accent: #e0435c;
  --accent-2: #5847e0;
  --ok: #2f9e64;
  --off: #b5b0bf;
  --input-bg: #ffffff;
  --secondary-btn-bg: linear-gradient(135deg, #e3e1ea, #d3d8e3);
}

* { box-sizing: border-box; }

/* Form controls don't inherit font-family by default in every engine. */
button, input, textarea, select {
  font-family: inherit;
}

html, body {
  margin: 0;
  height: 100%;
  background: radial-gradient(circle at 15% 0%, var(--bg-gradient-inner) 0%, var(--bg) 55%);
  color: var(--text);
  font-family: 'Segoe UI', system-ui, sans-serif;
  /* The app is a single fixed-height view (.screen tracks 100vh/--app-vh) —
     every scrollable area (chat log, modal bodies, etc) already scrolls
     internally, so the page itself is never meant to scroll. Letting it
     scroll anyway is what made mobile browsers animate their address bar
     in and out on every touch-scroll, and let anything a few pixels too
     wide (e.g. the scoreboard on a narrow phone) create a horizontal
     scrollbar instead of just getting clipped. */
  overflow: hidden;
  overscroll-behavior: none;
}

.hidden { display: none !important; }

.emoji-img {
  height: 1.2em;
  width: 1.2em;
  vertical-align: -0.2em;
  object-fit: contain;
}

/* A handful of avatar source images have a sliver of black left over from
   an imperfect crop, visible right at the edge. Re-cropping every asset
   isn't worth it — clip-path trims a couple pixels off each avatar's own
   edge instead, hiding exactly that sliver without touching the files.
   Grouped by the border-radius each shape actually uses. */
.avatar-pick-btn img,
.profile-chip-avatar,
.scoreboard-avatar,
.chat-avatar,
.mention-suggestion-avatar {
  clip-path: inset(2px round 50%);
}
.entry-picker-tile-avatar,
.entry-pin-avatar {
  clip-path: inset(2px round 14px);
}
.history-row-avatar {
  clip-path: inset(2px round 6px);
}
.avatar-option img {
  clip-path: inset(2px round 10px);
}

/* ---------- Entry screen ---------- */

.screen { height: 100vh; width: 100%; }

#entry-screen { display: flex; align-items: center; justify-content: center; }

.entry-card {
  width: 380px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 18px;
  padding: 32px;
  text-align: center;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}

.avatar-pick-btn {
  position: relative;
  width: 72px;
  height: 72px;
  border-radius: 50%;
  border: 2px solid var(--border);
  background: var(--panel-2);
  padding: 0;
  cursor: pointer;
  margin: 0 auto 20px;
  display: block;
}
.avatar-pick-btn:hover { border-color: var(--accent); }
.avatar-pick-btn:hover .avatar-change-badge { background: var(--accent); color: #fff; }
.avatar-pick-btn img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }
.avatar-change-badge {
  position: absolute;
  left: 50%;
  bottom: -8px;
  transform: translateX(-50%);
  background: var(--panel);
  border: 1px solid var(--border);
  color: var(--muted);
  font-size: 9px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  padding: 2px 9px;
  border-radius: 999px;
  white-space: nowrap;
  pointer-events: none;
}

.entry-card h1 { margin: 0 0 4px; font-size: 22px; }

.tagline { color: var(--muted); font-size: 13px; margin: 0 0 22px; line-height: 1.5; }

/* ---------- Entry screen: Netflix-style profile picker (guest) ---------- */

.entry-picker-label { font-size: 15px; font-weight: 600; margin: 0 0 18px; }
.entry-picker-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin-bottom: 22px;
}
.entry-picker-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  border: none;
  background: transparent;
  color: var(--text);
  cursor: pointer;
}
.entry-picker-tile-avatar {
  width: 64px;
  height: 64px;
  border-radius: 14px;
  object-fit: cover;
  border: 2px solid transparent;
  transition: border-color 0.15s ease, transform 0.15s ease;
}
.entry-picker-tile:hover .entry-picker-tile-avatar {
  border-color: var(--accent);
  transform: scale(1.05);
}
.entry-picker-tile-name {
  font-size: 12px;
  color: var(--muted);
  max-width: 72px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.entry-picker-tile:hover .entry-picker-tile-name { color: var(--text); }
.entry-picker-tile-add .entry-picker-tile-avatar {
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  background: var(--panel-2);
  border: 2px dashed var(--border);
  font-size: 26px;
  color: var(--muted);
}
.entry-picker-tile-add:hover .entry-picker-tile-avatar { border-color: var(--accent); color: var(--accent); }
.entry-picker-tile-guest .entry-picker-tile-avatar { border: 2px dashed var(--border); }
.entry-picker-tile-guest:hover .entry-picker-tile-avatar { border-style: dashed; }

/* Rows here (unlike the shared .rage-quit-reason-row's other use, the
   rage-quit reasons admin list) open the account detail view on click. */
#accounts-body .rage-quit-reason-row { cursor: pointer; }
.account-row-avatar {
  width: 28px;
  height: 28px;
  border-radius: 8px;
  object-fit: cover;
  flex-shrink: 0;
}
/* Account detail view (tap a row above) — special permissions, PIN
   reset, Guest's avatar. */
.account-detail-header { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.account-detail-avatar { width: 48px; height: 48px; border-radius: 12px; object-fit: cover; flex-shrink: 0; }
#account-detail-name { margin: 0; font-size: 18px; }
#account-detail-avatar-btn,
#account-detail-reset-pin-btn { width: 100%; margin-top: 10px; justify-content: center; }

.entry-pin-form,
.entry-create-form {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.entry-pin-avatar {
  width: 64px;
  height: 64px;
  border-radius: 14px;
  object-fit: cover;
  margin-bottom: 10px;
}
.entry-pin-name { font-size: 15px; font-weight: 600; margin: 0 0 16px; }
.entry-pin-form input,
.entry-create-form input {
  width: 100%;
  text-align: center;
  letter-spacing: 0.3em;
  font-size: 18px;
  padding: 11px 12px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  margin-bottom: 14px;
}
.entry-create-form label { align-self: flex-start; }
.entry-create-form input { letter-spacing: normal; text-align: left; font-size: 14px; }
.entry-create-form input#entry-create-pin,
.entry-create-form input#entry-create-pin-confirm {
  letter-spacing: 0.3em;
  text-align: center;
  font-size: 18px;
}
.entry-form-actions {
  display: flex;
  align-items: stretch;
  gap: 10px;
  width: 100%;
  margin-bottom: 8px;
}
.entry-form-actions .primary-btn { flex: 1; width: auto; margin-bottom: 0; }
.entry-form-actions .ghost-btn { flex: 0 0 auto; width: auto; white-space: nowrap; margin-bottom: 0; }

.entry-card label { display: block; text-align: left; font-size: 12px; color: var(--muted); margin-bottom: 6px; }

.entry-card input[type='text'],
.entry-card input:not([type]) {
  width: 100%;
  padding: 11px 12px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 14px;
  margin-bottom: 16px;
}

.primary-btn {
  width: 100%;
  padding: 12px 16px;
  border: none;
  border-radius: 10px;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  color: white;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
.primary-btn:hover { filter: brightness(1.08); }
.primary-btn:disabled { opacity: 0.5; cursor: default; }

.error-text { color: var(--accent); font-size: 13px; min-height: 18px; margin: 12px 0 0; }

.entry-app-icon { width: 64px; height: 64px; border-radius: 16px; object-fit: cover; margin-bottom: 4px; }

.entry-card input.input-error,
.modal-card input.input-error {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

/* ---------- Game screen ---------- */

#game-screen { display: flex; flex-direction: column; position: relative; }

.connection-banner {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translate(-50%, -6px);
  z-index: 50;
  padding: 5px 14px;
  border-radius: 0 0 10px 10px;
  background: #ff5d73;
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.connection-banner.hidden { display: none; }

.topbar {
  position: relative;
  /* alignScoreboardHud() sets a transform on .scoreboard-wrap, and a
     transform creates its own stacking context — that trapped
     .scoreboard-panel's z-index inside it instead of letting it compete at
     the page level, so <main>'s later-in-DOM content (Now Playing) painted
     over the popup. Giving the whole header its own stacking context above
     <main> fixes it regardless of what happens inside. */
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 10px 20px;
  border-bottom: 1px solid var(--border);
  background: var(--panel);
}

.brand { display: flex; align-items: center; gap: 6px; font-weight: 700; font-size: 15px; flex-shrink: 0; }
.brand-icon { width: 20px; height: 20px; border-radius: 6px; object-fit: cover; clip-path: inset(1px round 6px); }

/* Flex-centers within the topbar as a baseline; app.js nudges it with a
   translateX so it lines up with the stage's actual center instead
   (the topbar spans the full window, but the stage doesn't — the chat
   panel, resizable, eats into the width on the right). */
.topbar .scoreboard-wrap { flex: 1; display: flex; align-items: center; gap: 8px; justify-content: center; min-width: 0; position: relative; }
/* Rough MVP profanity detector toggle — see app.js. Deliberately quiet
   styling (just the existing .icon-btn look, tinted red while active)
   rather than a loud always-visible control, since this is very much an
   opt-in, work-in-progress feature. */
#profanity-mic-btn.active {
  border-color: #ff5d73;
  color: #ff8c96;
}
/* "You currently hold the gun" — not a warning state like the debug
   toggle above, so the app's normal accent color rather than red. */
#zapper-toggle-btn.active {
  border-color: var(--accent);
  color: var(--accent);
}
/* "You currently control the SCUMM mouse" — same treatment as the Zapper
   toggle just above, not a warning state. */
#scumm-mouse-toggle-btn.active {
  border-color: var(--accent);
  color: var(--accent);
}
#debug-log-btn.active {
  border-color: #ff5d73;
  color: #ff8c96;
}

.topbar-right { position: relative; display: flex; align-items: center; gap: 10px; margin-left: auto; flex-shrink: 0; }

.main-menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  display: flex;
  flex-direction: column;
  min-width: 170px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 5px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
  z-index: 60;
}
/* Material Symbols Outlined (see index.html's Google Fonts <link>) — the
   font ligature system means the element's *text content* is the icon
   name (e.g. "logout"), rendered as a glyph instead of literal text.
   16px roughly matches the existing 16x16 inline-SVG menu icons this
   sits alongside, so the two conventions don't visibly clash. */
.material-icon {
  font-family: 'Material Symbols Outlined';
  font-weight: normal;
  font-style: normal;
  font-size: 16px;
  line-height: 1;
  display: inline-block;
  vertical-align: middle;
  -webkit-font-smoothing: antialiased;
}
.main-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  border: none;
  background: transparent;
  color: var(--text);
  padding: 9px 11px;
  border-radius: 8px;
  font-size: 13px;
  cursor: pointer;
}
.main-menu-item-icon { flex-shrink: 0; color: var(--muted); }
.main-menu-item:hover { background: var(--panel-2); }

.history-body {
  max-height: 50vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 4px;
}
.history-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 20px 0; }
/* Load game's own loading state — a plain "Loading…" text was easy to
   miss/looked stalled on a library with a lot of ROMs (each one costs a
   Wikipedia lookup + thumbnail fetch server-side the first time it's
   ever scanned). Generic enough (no rom-library-specific colors/sizes)
   to reuse anywhere else a fetch-in-progress state is needed later. */
.rom-library-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 30px 0;
  color: var(--muted);
  font-size: 13px;
}
.rom-library-spinner {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 3px solid var(--border);
  border-top-color: var(--accent);
  animation: rom-library-spin 0.8s linear infinite;
}
@keyframes rom-library-spin {
  to { transform: rotate(360deg); }
}

/* The three maintenance actions inside the Refresh hub modal — a plain
   row, same shape as .stream-mode-buttons' row but without that one's
   thumbnail-picker styling (these are ordinary ghost-btns, not a
   this-or-that choice). */
.rom-refresh-action-row { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.rom-refresh-action-row .ghost-btn { flex: 1 1 auto; }

/* Scan/Find duplicates/Search metadata's shared live-progress readout
   (see app.js's runRomMaintenance) — a real percentage bar plus the
   current filename, deliberately updated for every single file each
   action visits (cache hits included) rather than only "real work" ones,
   so it visibly keeps moving instead of looking stalled during a fast
   pass — a plain "Loading…" spinner (rom-library-loading above) already
   covers the *no useful detail available* case elsewhere; this one has
   real per-file detail to show, so it does. */
.rom-library-progress {
  margin-bottom: 10px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--panel-2);
}
.rom-library-progress-top {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  font-size: 12.5px;
  margin-bottom: 6px;
}
#rom-library-progress-label { font-weight: 600; }
#rom-library-progress-count { color: var(--muted); font-variant-numeric: tabular-nums; }
.rom-library-progress-track {
  height: 6px;
  border-radius: 999px;
  background: var(--input-bg);
  overflow: hidden;
}
.rom-library-progress-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  border-radius: inherit;
  /* Short transition, not instant — files can flip by within single-digit
     milliseconds on a cache-hit pass, and a hard jump per file reads as
     flicker rather than motion at that rate. */
  transition: width 0.12s linear;
}
.rom-library-progress-file {
  margin-top: 6px;
  font-size: 11px;
  font-family: 'Consolas', monospace;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Done state — the bar itself is pointless at 100%, the label/count keep
   the final tally up instead (see finishRomProgress in app.js). */
.rom-library-progress.is-done .rom-library-progress-track { display: none; }
.rom-library-progress.is-error #rom-library-progress-label { color: var(--accent); }
/* Live running tally while Scan/Search metadata are still in progress
   (new covers found / games matched so far) — see runRomMaintenance's
   own onProgress in app.js. Small and separate from the filename line
   above it so both can update independently without competing for the
   same line of text. */
.rom-library-progress-stat { margin-top: 3px; font-size: 11px; color: var(--accent-2); }
.history-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 10px 14px;
}
.history-row-info { min-width: 0; }
.history-delete-btn {
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  border-radius: 6px;
  width: 24px;
  height: 24px;
  font-size: 12px;
  cursor: pointer;
  flex-shrink: 0;
}
.history-delete-btn:hover { border-color: var(--accent); color: var(--accent); }
.history-row-game {
  font-weight: 600;
  font-size: 13px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.history-row-date { font-size: 11px; color: var(--muted); margin-top: 2px; }
.history-row-matchup { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.history-row-side { display: flex; align-items: center; gap: 6px; min-width: 0; max-width: 110px; }
.history-row-side.guest-side { flex-direction: row-reverse; text-align: right; }
.history-row-avatar { width: 24px; height: 24px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
.history-row-name {
  font-size: 12px;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.history-row-side.is-winner .history-row-name { color: #ffd166; font-weight: 700; }
.history-row-score { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; }

.states-body {
  max-height: 55vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 4px;
}
.states-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 20px 0; }
/* Subtle nudge that there's something to load for the game currently
   playing — not an alert, so no pulsing, just a soft standing glow. */
#load-state-btn.has-states-glow {
  border-color: #4cc9f0;
  box-shadow: 0 0 8px rgba(76, 201, 240, 0.55);
}

/* Save button's hover-reveal quick-save panel — 4 fixed slots, click one
   to overwrite it. Anchored to the button itself (not the whole toolbar)
   so it always lands directly under Save regardless of where that sits
   in the layout. */
.quicksave-anchor { position: relative; display: inline-flex; }
.taunt-cheatlist-anchor { position: relative; display: inline-flex; }
/* A plain reminder list, not another settings surface — just the phrase
   text, small, no category/action explained (see app.js's own comment on
   why: it's for remembering what you set up, not re-explaining it). */
.taunt-cheatlist-panel {
  position: absolute;
  top: 100%;
  right: 0;
  z-index: 40;
  /* A fixed width, not max-width — a flex column with only max-width
     shrink-to-fits around its widest child's *unwrapped* text first and
     only clamps after, which left long phrases rendering at their full
     natural width (however many hundred px that took) before anything
     ever got a chance to ellipsize — confirmed live, reported as "text
     looks cut/garbled" with several longer phrases in the list. A plain
     fixed width sidesteps that shrink-to-fit step entirely. */
  width: 220px;
  box-sizing: border-box;
  max-height: 260px;
  overflow-y: auto;
  overflow-x: hidden;
  padding: 8px 10px;
  background: color-mix(in srgb, var(--panel-2) 96%, transparent);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
  font-size: 11.5px;
  color: var(--text);
}
.taunt-cheatlist-panel div {
  padding: 2px 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.quicksave-panel {
  position: absolute;
  /* Flush against the button, no gap — a gap is dead space that belongs
     to whatever's rendered behind it, so crossing it on the way down
     counted as leaving the hoverable area entirely and closed the panel
     before the mouse ever reached it. */
  top: 100%;
  right: 0;
  z-index: 40;
  display: flex;
  gap: 8px;
  padding: 10px;
  background: color-mix(in srgb, var(--panel-2) 96%, transparent);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
}
/* Load's own quick-load panel reuses .quicksave-panel's whole layout —
   just recolored blue (the same one load-state-btn's own glow already
   uses) so it's never mistaken for Save's panel one button over.
   Clicking the wrong one here throws away unsaved progress, so the two
   need to read as different at a glance, not just by their label. */
.quickload-panel {
  border-color: #4cc9f0;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38), 0 0 14px rgba(76, 201, 240, 0.4);
}
.quicksave-panel-card {
  width: 60px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  cursor: pointer;
  border-radius: 8px;
  padding: 4px;
}
.quicksave-panel-card:hover { background: rgba(255, 255, 255, 0.08); }
/* Load-only — Save always has somewhere valid to write, but an empty
   slot has nothing to load. */
.quicksave-panel-card.is-empty { opacity: 0.45; cursor: default; }
.quicksave-panel-card.is-empty:hover { background: none; }
.quicksave-panel-number { font-size: 11px; font-weight: 700; color: var(--text); }
.quicksave-panel-thumb,
.quicksave-panel-thumb-fallback {
  width: 56px;
  height: 52px;
  border-radius: 6px;
  object-fit: cover;
  background: var(--input-bg);
}
.quicksave-panel-thumb-fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
  color: var(--muted);
}
/* An actual heading now (h3 in the markup) — a real game title reads as
   more than a section label, so this is the game's name at a proper
   heading size, not the small muted uppercase caption style. */
.state-group-label {
  font-size: 17px;
  font-weight: 700;
  color: var(--text);
  margin: 18px 0 8px;
}
/* Each group's label is now the first child of its own .state-group-
   section wrapper (added for the jump-chips' scrollIntoView target), not
   a direct sibling of every other group's label the way it used to be —
   a plain :first-child on the label itself would zero out every single
   group's top margin instead of just the very first one in the list. */
.state-group-section:first-child .state-group-label { margin-top: 0; }
.state-row {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px 10px;
  cursor: pointer;
  text-align: left;
  width: 100%;
  color: var(--text);
  font: inherit;
}
.state-row:hover { border-color: var(--accent); }
.state-thumb {
  width: 64px;
  height: 60px;
  border-radius: 6px;
  object-fit: cover;
  flex-shrink: 0;
  background: var(--input-bg);
}
.state-thumb-fallback {
  width: 64px;
  height: 60px;
  border-radius: 6px;
  flex-shrink: 0;
  background: var(--input-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 22px;
}

/* [1][2][3][4] under each game's own group in the Load States picker —
   just a screenshot, slot number and a short date/time, no repeated game
   name (the group label right above already says which game this is). */
.quicksave-strip { display: flex; gap: 8px; margin: 4px 0 12px; }
.quicksave-strip-card {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  cursor: pointer;
  border-radius: 8px;
  padding: 6px 4px;
  background: var(--panel-2);
  border: 1px solid var(--border);
}
.quicksave-strip-card:hover { border-color: var(--accent); }
.quicksave-strip-card.is-empty { opacity: 0.5; cursor: default; }
.quicksave-strip-card.is-empty:hover { border-color: var(--border); }
.quicksave-strip-number { font-size: 10px; font-weight: 700; color: var(--muted); }
.quicksave-strip-thumb,
.quicksave-strip-thumb-fallback {
  width: 100%;
  aspect-ratio: 4 / 3;
  border-radius: 6px;
  object-fit: cover;
  background: var(--input-bg);
}
.quicksave-strip-thumb-fallback { display: flex; align-items: center; justify-content: center; font-size: 16px; }
.quicksave-strip-date { font-size: 9px; color: var(--muted); text-align: center; }
.quicksave-backups { display: flex; gap: 4px; }
.quicksave-backup-btn {
  width: 16px;
  height: 16px;
  padding: 0;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--muted);
  border-radius: 4px;
  font-size: 9px;
  line-height: 1;
  cursor: pointer;
}
.quicksave-backup-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.quicksave-backup-btn:disabled { opacity: 0.35; cursor: default; }

/* The group for whatever's actually loaded right now — subtle, not a
   redesign, just enough to spot it among several games' worth of saves.
   Blue (same one load-state-btn's own glow uses), not var(--accent)'s
   red — red reads as a warning/error here, not "this one's active". */
.state-group-current-tag {
  font-size: 9px;
  font-weight: 700;
  text-transform: none;
  letter-spacing: 0;
  color: #4cc9f0;
  background: rgba(76, 201, 240, 0.18);
  padding: 1px 6px;
  border-radius: 999px;
  vertical-align: middle;
}
.state-row.is-current-game { background: rgba(76, 201, 240, 0.08); }

.state-group-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.state-mgmt-btn {
  font-size: 10.5px;
  padding: 4px 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}
.state-mgmt-btn:hover { border-color: var(--accent); color: var(--accent); }

/* Alphabetical "jump to this game" row — sits right under whatever's
   currently playing (or at the very top when nothing is), so it's
   reachable without scrolling first. */
/* Clearly separated from whatever's right above it (the current game's
   last save row, most often — but also sometimes nothing at all, when
   no game's currently loaded, where a divider line would float with
   nothing above it to divide from) — just generous spacing on both
   sides, no border. */
.state-jump-heading {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  margin: 26px 0 10px;
}
.state-jump-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 14px; }
.state-jump-chip {
  font-size: 11.5px;
  padding: 5px 10px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text);
  cursor: pointer;
}
.state-jump-chip:hover { border-color: var(--accent); color: var(--accent); }

.reactions-body {
  max-height: 50vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 4px;
}
.reaction-row {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px 10px;
}
.reaction-row.dragging { opacity: 0.4; }
.reaction-row-handle { color: var(--muted); cursor: grab; flex-shrink: 0; font-size: 16px; }
.reaction-row-emoji { font-size: 22px; flex-shrink: 0; }
.reaction-row-info { flex: 1; min-width: 0; }
.reaction-row-label { font-weight: 600; font-size: 13px; }
.reaction-row-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
.reaction-row-meta .material-icon { font-size: 13px; }
.reaction-effect-params { display: flex; flex-direction: column; gap: 2px; }
.reaction-effect-params label { margin-top: 6px; }
.reaction-sound-name { font-size: 12px; color: var(--muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reaction-sounds-list { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 8px; max-height: 40vh; overflow-y: auto; }
.reaction-sound-row {
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 8px;
}
.reaction-sound-row-name { flex: 1; min-width: 0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.reaction-edit-basics-row { display: flex; gap: 14px; }
.reaction-edit-basics-row > div { flex: 1; min-width: 0; }
/* Name/Sound/Effects as three clearly separated panels — previously one
   long, ungrouped list of fields that read as sparse/disconnected
   whenever a reaction had few sounds or effects. */
.reaction-edit-section {
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px 14px;
  margin-bottom: 12px;
}
.reaction-edit-section-title {
  margin: 0 0 10px;
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}

.reaction-effects-list { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 8px; max-height: 40vh; overflow-y: auto; }
.reaction-effect-card {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px 10px;
}
.reaction-effect-card.dragging { opacity: 0.4; }
.reaction-effect-card-header { display: flex; align-items: center; gap: 8px; }
.reaction-effect-card-handle { color: var(--muted); cursor: grab; flex-shrink: 0; font-size: 16px; }
.reaction-effect-card-header select { flex: 1; margin-bottom: 0 !important; }
.reaction-effect-card-remove {
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.25);
  color: var(--text);
  font-size: 11px;
  cursor: pointer;
}
.reaction-effect-card-remove:hover { background: var(--accent); color: #fff; }
.reaction-effect-card-params:not(:empty) { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
.color-param-row { display: flex; align-items: center; gap: 10px; }
.color-param-row input[type='color'] { width: 40px; height: 28px; padding: 2px; border-radius: 6px; border: 1px solid var(--border); background: none; cursor: pointer; }
.color-param-row input[type='range'] { flex: 1; }
.color-param-alpha-label { font-size: 11px; color: var(--muted); flex-shrink: 0; }

.rage-quits-body {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: 10px;
  max-height: 50vh;
  overflow-y: auto;
  margin-bottom: 4px;
}
.rage-quit-row {
  position: relative;
  border-radius: 8px;
  overflow: hidden;
  background: var(--input-bg);
  border: 1px solid var(--border);
}
.rage-quit-row-thumb { width: 100%; height: 80px; object-fit: cover; display: block; }
.rage-quit-row-delete {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  font-size: 11px;
  cursor: pointer;
  line-height: 1;
}
.rage-quit-row-delete:hover { background: var(--accent); }
.rage-quits-add-row { display: flex; gap: 8px; margin-bottom: 4px; }
.rage-quits-add-row input { flex: 1; margin-bottom: 0 !important; }

.rage-quit-reasons-body {
  max-height: 50vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin-bottom: 4px;
}
.rage-quit-reason-row {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px 10px;
}
.rage-quit-reason-row.dragging { opacity: 0.4; }
.rage-quit-reason-row-handle { color: var(--muted); cursor: grab; flex-shrink: 0; font-size: 16px; }
.rage-quit-reason-row-text { flex: 1; min-width: 0; font-size: 13px; overflow-wrap: break-word; }
.rage-quit-reason-row-delete {
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.25);
  color: var(--text);
  font-size: 11px;
  cursor: pointer;
  line-height: 1;
}
.rage-quit-reason-row-delete:hover { background: var(--accent); color: #fff; }

.rage-quit-reason-list { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 16px; max-height: 50vh; overflow-y: auto; }
.rage-quit-reason-list .ghost-btn { width: 100%; text-align: left; }
.rage-quit-reason-custom-row { display: flex; gap: 8px; margin-bottom: 12px; }
.rage-quit-reason-custom-row input { flex: 1; margin-bottom: 0 !important; }
#rage-quit-reason-calm {
  border-color: var(--accent) !important;
  color: var(--accent) !important;
  font-weight: 700;
  margin-top: 4px;
}

.state-row-info { flex: 1; min-width: 0; }
.state-row-game { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.state-row-date { font-size: 11px; color: var(--muted); margin-top: 2px; }
.state-delete-btn {
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  border-radius: 6px;
  width: 24px;
  height: 24px;
  font-size: 12px;
  cursor: pointer;
  flex-shrink: 0;
}
.state-delete-btn:hover { border-color: var(--accent); color: var(--accent); }

.ghost-btn {
  padding: 9px 14px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  font-size: 13px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
.ghost-btn:hover { border-color: var(--accent); }
.ghost-btn:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
/* Only ever used by rage-quit's "It's ok, I calmed down" — had no actual
   CSS behind the class at all, so the button was sizing to its own text
   instead of spanning the modal like the reason buttons above it (its
   text only looked left-aligned because the button itself was narrow,
   not because .ghost-btn's own centering — display:inline-flex,
   justify-content:center — wasn't already working). */
.ghost-btn.full { width: 100%; }
/* Highlighted variant — layered on top of .ghost-btn (same padding/shape/
   icon-gap already there), just swaps the fill. Shared by "Load file"
   (ROM library) and the avatar template upload button. */
.ghost-btn.btn-accent { background: var(--accent-2); border-color: var(--accent-2); color: #fff; }
.ghost-btn.btn-accent:hover { filter: brightness(1.12); border-color: var(--accent-2); }
.ghost-btn.small { padding: 5px 10px; font-size: 12px; }
.ghost-btn.active {
  border-color: var(--accent);
  color: var(--accent);
  background: rgba(255, 93, 115, 0.12);
}

.profile-chip {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 12px 5px 5px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  font-size: 13px;
  cursor: pointer;
}
.profile-chip:hover { border-color: var(--accent); }
.profile-chip-avatar { width: 26px; height: 26px; border-radius: 50%; object-fit: cover; display: block; }

/* Its own button next to the avatar chip (see index.html) — hidden
   entirely until there's ever been at least one notification at all
   (JS toggles .hidden off the first time the list isn't empty), lit via
   .has-unread only while something's actually pending. */
.notif-bell-wrap { position: relative; display: flex; }
.notif-bell-btn { position: relative; }
.notif-bell-btn.has-unread { color: var(--accent); border-color: var(--accent); }
/* A plain lit dot, not a count — simpler, and there's nothing else in
   the app showing an unread number for the badge to stay in sync with. */
.notif-badge {
  position: absolute;
  top: 0;
  right: 0;
  width: 9px;
  height: 9px;
  border-radius: 999px;
  background: var(--accent);
  border: 2px solid var(--panel);
}
.notif-bell-menu {
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 6px;
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 240px;
  max-width: 320px;
  max-height: 60vh;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 6px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
}
.notif-bell-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  border: none;
  background: transparent;
  color: var(--text);
  padding: 9px 11px;
  border-radius: 8px;
  font-size: 13px;
  cursor: pointer;
}
.notif-bell-item:hover { background: var(--panel-2); }
.notif-bell-item-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.notif-bell-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ---------- Scoreboard HUD (over the game screen) ---------- */
/* A single mirrored line, fits inline in the topbar, football-scoreboard
   style: P1  avatar Name [– 0 +]   –   [– 0 +] Name avatar  P2 */

.scoreboard-hud {
  display: flex;
  align-items: center;
}

/* Host-only score +/-  and End game live in a hover-revealed panel below
   the scoreboard instead of permanently inline — same pattern as
   .now-playing-panel. Only .has-panel (set via JS once isHost is known)
   actually reveals it; for the guest, .scoreboard-panel just stays empty
   and inert. */
.scoreboard-panel {
  position: absolute;
  /* top:100% with no gap — .scoreboard-panel needs to touch .scoreboard-hud
     with no dead space between them, or the mouse crosses out of the
     hoverable area entirely while moving from one to the other and the
     hover state (and the panel) drops before it's reachable. */
  top: 100%;
  left: 50%;
  transform: translateX(-50%) translateY(6px);
  z-index: 30;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 11px;
  background: var(--panel-2);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  white-space: nowrap;
  transition: opacity 150ms ease, transform 150ms ease, visibility 150ms;
}
/* The +/- pair for each player, and the swap button between them, on
   their own row — symmetric (equal-width P1/P2 groups either side of a
   centered swap button) so they land under roughly the same spot as the
   score numbers in .scoreboard-hud above. End game sits below this row,
   full width, instead of being squeezed in at the end of it. */
.scoreboard-panel-top { display: flex; align-items: center; justify-content: center; gap: 10px; }
#end-game-btn { width: 100%; }
.scoreboard-wrap.has-panel:hover .scoreboard-panel,
.scoreboard-wrap.has-panel:focus-within .scoreboard-panel {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
  pointer-events: auto;
}
.scoreboard-panel-group { display: flex; align-items: center; gap: 4px; }
.scoreboard-panel-label { font-size: 11px; color: var(--muted); }
.scoreboard-panel-divider { width: 1px; align-self: stretch; background: var(--border); }

.scoreboard-line {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 6px 14px;
  max-width: 100%;
}

.scoreboard-tag { font-weight: 700; color: var(--muted); font-size: 11px; letter-spacing: 0.03em; }
.scoreboard-avatar { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.scoreboard-avatar-placeholder { background: var(--off); }
.scoreboard-name { font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }
.scoreboard-name.leader { color: #ffd166; }
.scoreboard-name.scoreboard-waiting { color: var(--muted); font-weight: 500; font-style: italic; }

.scoreboard-score {
  font-weight: 700;
  min-width: 10px;
  text-align: center;
  color: var(--text);
  background: rgba(0, 0, 0, 0.25);
  border-radius: 6px;
  padding: 1px 8px;
}

.scoreboard-vs { color: var(--muted); font-weight: 700; padding: 0 2px; }

.score-btn {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.score-btn:hover { border-color: var(--accent); }

/* ---------- Layout ---------- */

.layout { flex: 1; display: flex; min-height: 0; padding: 20px; gap: 20px; }

.stage-area { flex: 1; display: flex; flex-direction: column; min-width: 0; gap: 12px; }

/* ---------- Now playing ---------- */

.now-playing {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 8px 14px;
}

.now-playing-art,
.now-playing-art-fallback {
  width: 36px;
  height: 36px;
  border-radius: 6px;
  object-fit: cover;
  flex-shrink: 0;
}
.now-playing-art-fallback {
  background: var(--panel-2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
}
.now-playing-fallback-icon { width: 22px; height: 22px; object-fit: contain; }

.now-playing-text { display: flex; flex-direction: column; gap: 2px; }
.now-playing-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.now-playing-name { font-size: 13px; font-weight: 600; }

/* Rough MVP profanity detector's alert (see app.js) — sits over the real
   art/text rather than replacing them, matching .now-playing's own
   background/border/radius/padding so it reads as the same bar, just
   with different content for a few seconds. */
/* --alert-color/--alert-glow are set inline per taunt (see app.js's
   showProfanityAlert) to match whichever category actually fired — the
   fallback here is just the old insult-pink, for the split second
   before JS ever sets them (or if it somehow doesn't). */
.now-playing.alert {
  border-color: var(--alert-color, #ff5d73);
  box-shadow: 0 0 0 1px var(--alert-color, #ff5d73), 0 0 14px 2px var(--alert-glow, rgba(255, 93, 115, 0.45));
}
.now-playing-insult {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 8px 14px;
  background: var(--panel);
  border-radius: 12px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 500ms ease;
}
.now-playing-insult.show { opacity: 1; }
.now-playing-insult .now-playing-name { font-size: 17px; }
/* Only ever relevant on mobile (see the media query below where it's
   switched to display: flex) — on desktop .now-playing-insult (the
   original, inside .now-playing) is what shows instead, and this stays
   fully inert. Deliberately its own self-contained rule, not sharing
   .now-playing-insult's positioning — that one is position:absolute
   inset:0 relative to a *specific* parent (.now-playing); this one is a
   plain fixed banner over the whole topbar instead, living outside any
   of the topbar's own flex children so showing it never reflows/resizes
   the mic button, avatar chip, etc. sitting in there. */
.now-playing-insult-mobile {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 60;
  align-items: center;
  justify-content: center;
  padding: 10px 14px;
  background: var(--panel);
  border-bottom: 2px solid var(--border);
  opacity: 0;
  pointer-events: none;
  transition: opacity 500ms ease;
}
/* --alert-color set inline per taunt, same as the desktop bar's own
   glow (see .now-playing.alert) — no room for a box-shadow glow on a
   full-width fixed banner, so a colored border stands in for it here. */
.now-playing-insult-mobile.show { opacity: 1; border-bottom-color: var(--alert-color, #ff5d73); }
.now-playing-insult-mobile .now-playing-name { font-size: 13px; }

/* Taunts broadcaster's phrase list — one card per phrase (trigger +
   category on one row, an extra "say instead" row that only shows up
   for the 'say' category), not a single cramped multi-column row.
   Deliberately no max-height/overflow of its own any more — that used to
   be a fixed 220px guess to keep the whole modal from running taller than
   some guests' screens; #taunt-tab-texts (its own scroll container now,
   see the "Taunts broadcaster — keep the footer visible" rule further
   down) does that properly, actually fluid to whatever height is really
   available instead of a hardcoded number. */
.profanity-words-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 10px;
}
.profanity-word-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-left: 3px solid var(--border);
  border-radius: 10px;
  background: var(--panel-2);
}
.profanity-word-main-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
/* The actual root cause of "input and dropdowns don't line up": this
   input only ever had .modal-card input's generic padding/font-size
   (10px 12px / 13px), while the dropdown buttons below got their OWN
   smaller override — nothing ever brought the input in line with them.
   Fixed here with an explicit, identical, box-sizing:border-box height
   on both this input AND .custom-select-btn (below) instead of relying
   on padding/line-height/font-size happening to add up the same way
   across an <input> and a <button> — those have different UA-stylesheet
   defaults to begin with, so matching padding alone was never reliable.
   margin-bottom needs !important specifically: .modal-card input:not(…
   ):not(…):not(…) (further down) racks up 4 class-level selectors from
   its three :not() args alone, so a plain class selector here — even a
   more specific one — still loses that fight on the cascade. Confirmed
   live in devtools: unchecking that generic rule's margin-bottom is
   what actually closed the gap, a weaker override here never did. */
.profanity-word-text {
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  height: 32px;
  padding: 0 8px;
  font-size: 12px;
  margin-bottom: 0 !important;
}
/* Every <select> in the app (including this one) gets swapped for a
   styled dropdown by custom-select.js — the real <select> ends up
   hidden (.custom-select-native), so what's actually visible and needs
   sizing is the .custom-select-wrap it gets moved into, not the select
   itself. Gets its own scoping class from app.js right after
   enhancement (plain .custom-select-wrap is shared by every select in
   the app, with no way to size just this one on its own). Specificity
   matters here: .modal-card select forces width:100% at (0,1,1);
   anything scoping the select itself needs to beat that, which is why
   this targets the wrap instead. */
.profanity-cat-select-wrap { flex: 0 0 auto; margin-bottom: 0 !important; width: 148px; }
.profanity-cat-select-wrap .custom-select-btn {
  box-sizing: border-box;
  height: 32px;
  padding: 0 8px;
  font-size: 12px;
}
.profanity-word-remove {
  flex: 0 0 auto;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  border-radius: 6px;
  width: 28px;
  height: 28px;
  cursor: pointer;
}
.profanity-word-remove:hover { border-color: var(--accent); color: var(--accent); }
/* 'Say instead' row — only relevant for the 'say' category, toggled via
   the plain shared .hidden utility from the category select's own
   change handler (see app.js). */
.profanity-word-extra-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding-top: 6px;
  border-top: 1px dashed var(--border);
}
.profanity-word-extra-label { flex: 0 0 auto; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
.profanity-word-say {
  flex: 1;
  min-width: 0;
  box-sizing: border-box;
  height: 30px;
  padding: 0 8px;
  font-size: 12px;
  margin-bottom: 0 !important;
}
.profanity-list-actions { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.profanity-list-actions .ghost-btn { width: auto; flex: 1; }

/* Taunts broadcaster — keep the footer visible. Reported on a laptop
   screen: a long Reactions list (17 rows) pushed Enable/Save/Close below
   the viewport with no way to scroll to them, since .modal-card's own
   max-height:90vh caps the box but (with the default overflow:visible)
   never actually clips/scrolls oversized content — it just spills out
   past the bottom edge. Fixed the same way #keys-body already solves this
   for Change Controls, just via flexbox instead of a fixed vh guess: the
   card becomes a column flex container, so h2/the tab row/the Test button/
   both status lines/the action buttons all keep their natural size, and
   only the active tab body flexes to fill whatever's left, scrolling
   internally once it runs out of room — the footer can never end up
   outside the card's own (already-clamped) height.
   min-height:0 is the actual fix, not a formality: a flex item's default
   min-height is 'auto' (its content's intrinsic height), which silently
   defeats overflow-y:auto — without this the tab body would just keep
   growing past its flex-basis exactly like it does today. */
#profanity-modal .modal-card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
#taunt-tab-texts,
#taunt-tab-reactions {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* Reaction detector master on/off — now its own row at the top of the
   Reactions tab (see #taunt-tab-reactions in index.html), full-width like
   every other on/off control in this app (#profanity-mic-btn,
   #zapper-toggle-btn, ...): a real *button* toggling .active, not a
   checkbox, a real repeated ask not to go back to. */
.taunt-toggle-btn { box-sizing: border-box; width: 100%; height: 38px; }
.taunt-toggle-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(255, 93, 115, 0.12); }
/* Each detector's own "what am I hearing" line, side by side under the
   phrase list instead of stacked full-width — same idea as the top row.
   Both only ever render anything while the one shared #profanity-test-btn
   (see its click handler in app.js) is actually running, not during
   normal play. */
.taunt-modal-status-row { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 10px; }
.taunt-modal-status-row > * { flex: 1; min-width: 0; margin: 0; }
#profanity-test-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(255, 93, 115, 0.12); }

/* Category colors — same everywhere a taunt shows: the phrase card's
   left accent + its own select, the Now Playing alert text, and the
   Test button's glow. */
.taunt-cat-select-insult { border-color: #ff5d73; }
.taunt-cat-select-taunt { border-color: #a78bfa; }
.taunt-cat-select-encouragement { border-color: #4fd68a; }
.taunt-cat-select-other { border-color: #f5c518; }
.taunt-cat-select-say { border-color: #5aa9ff; }
.profanity-word-card.taunt-cat-card-insult { border-left-color: #ff5d73; }
.profanity-word-card.taunt-cat-card-taunt { border-left-color: #a78bfa; }
.profanity-word-card.taunt-cat-card-encouragement { border-left-color: #4fd68a; }
.profanity-word-card.taunt-cat-card-other { border-left-color: #f5c518; }
.profanity-word-card.taunt-cat-card-say { border-left-color: #5aa9ff; }
.now-playing-name.taunt-cat-insult { color: #ff5d73; }
.now-playing-name.taunt-cat-taunt { color: #a78bfa; }
.now-playing-name.taunt-cat-encouragement { color: #4fd68a; }
.now-playing-name.taunt-cat-other { color: #f5c518; }
.now-playing-name.taunt-cat-say { color: #5aa9ff; }
/* Laugh detector (see laugh-detector.js) — not a phrase type, so it has
   no taunt-cat-select or taunt-glow counterpart, just this. */
.now-playing-name.taunt-cat-laugh { color: #ff9d3d; }
.now-playing-name.taunt-cat-frustration { color: #ff3b3b; }
.now-playing-name.taunt-cat-sad { color: #7ea6d1; }
.now-playing-name.taunt-cat-surprise { color: #4dd9e8; }
@keyframes taunt-glow-pulse {
  0% { box-shadow: 0 0 0 0 var(--glow-color); }
  30% { box-shadow: 0 0 0 4px var(--glow-color); }
  100% { box-shadow: 0 0 0 0 transparent; }
}
.profanity-word-text.taunt-glow-insult { --glow-color: rgba(255, 93, 115, 0.6); animation: taunt-glow-pulse 1s ease-out; }
.profanity-word-text.taunt-glow-taunt { --glow-color: rgba(167, 139, 250, 0.6); animation: taunt-glow-pulse 1s ease-out; }
.profanity-word-text.taunt-glow-encouragement { --glow-color: rgba(79, 214, 138, 0.6); animation: taunt-glow-pulse 1s ease-out; }
.profanity-word-text.taunt-glow-other { --glow-color: rgba(245, 197, 24, 0.6); animation: taunt-glow-pulse 1s ease-out; }
.profanity-word-text.taunt-glow-say { --glow-color: rgba(90, 169, 255, 0.6); animation: taunt-glow-pulse 1s ease-out; }

/* Reactions tab of the Taunts broadcaster (see renderReactionTuneRows in
   app.js) — one compact row per LaughDetector.REACTIONS entry, same visual
   language as .profanity-word-card's own .profanity-word-main-row (label
   flex:1, fixed-width controls after it) but a single line, no left accent
   border — there's no per-category color story here the way phrase types
   have. Scrolling itself happens one level up, on #taunt-tab-texts/
   #taunt-tab-reactions (see the "Taunts broadcaster — keep the footer
   visible" rule further down) — not here, to avoid a confusing nested
   scrollbar. */
.reaction-tune-list { display: flex; flex-direction: column; gap: 6px; }
.reaction-tune-row {
  padding: 6px 10px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--panel-2);
}
.reaction-tune-main { display: flex; align-items: center; gap: 8px; }
.reaction-tune-label { flex: 1; min-width: 0; font-size: 12.5px; }
/* width/margin-bottom need !important — same reason .profanity-word-text's
   own identical fix does (see its comment): .modal-card input:not(...):not(
   ...):not(...) racks up 4 class-level selectors from its :not() args
   alone, beating a plain class here on specificity regardless of source
   order. */
.reaction-tune-sensitivity {
  flex: 0 0 auto;
  box-sizing: border-box;
  width: 56px !important;
  height: 30px;
  padding: 0 6px;
  font-size: 12px;
  text-align: center;
  margin-bottom: 0 !important;
}
.reaction-tune-toggle { flex: 0 0 auto; box-sizing: border-box; width: 48px; height: 30px; }
.reaction-tune-text-toggle {
  flex: 0 0 auto;
  height: 30px;
  min-width: 68px;
  display: inline-flex;
  align-items: center;
  gap: 3px;
}
.reaction-tune-text-toggle .material-icon { font-size: 16px; }
.reaction-tune-text {
  box-sizing: border-box;
  width: 100% !important;
  height: 32px;
  margin: 7px 0 0 !important;
  padding: 0 9px;
  font-size: 12px;
}
.reaction-reset-row { display: flex; justify-content: flex-end; margin-top: 10px; }

/* Training popup ("Auto" button) — a small focused dialog, not .wide. */
.reaction-train-subtitle { font-size: 15px; font-weight: 700; margin: 0 0 6px; }
.reaction-train-progress { font-size: 22px; font-weight: 700; text-align: center; margin: 10px 0; }

.now-playing-insult-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }

/* Plain inline group by default (room permitting) — Reset/Save/Load/Load
   game just sit right-aligned in the Now Playing bar, like before this ever
   became a hover panel. Only compact mode (below) turns it into a
   hover-revealed floating panel, once there genuinely isn't room to keep it
   inline. */
.now-playing-panel {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-left: auto;
  flex-shrink: 0;
}
.now-playing-panel .primary-btn { width: auto; padding: 8px 14px; font-size: 12px; }
.now-playing-panel .icon-btn { width: 34px; height: 34px; }
.now-playing-panel .icon-text-btn { height: 34px; padding: 0 12px; font-size: 12px; }

.stage {
  flex: 1;
  background: #000;
  border: 1px solid var(--border);
  border-radius: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
  /* Pings (Ctrl+Click — see app.js) land here as absolutely-positioned
     .ping-ripple elements. */
  min-height: 0;
}
/* NES Zapper — only while it's actually this player's turn to aim (see
   app.js's updateZapperButton) */
.stage.zapper-aiming { cursor: crosshair; }

/* Ctrl+Click ping — a water-drop ripple (three staggered expanding-and-
   fading rings plus a quick center dot) at the clicked spot, color-coded
   by who sent it. `color: currentColor` on the rings/dot ties them to the
   role class below instead of repeating the color in three places. */
.ping-ripple {
  position: absolute;
  z-index: 15;
  width: 0;
  height: 0;
  pointer-events: none;
}
.ping-ripple::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  border-radius: 50%;
  background: currentColor;
  opacity: 0.9;
  animation: ping-dot-fade 1.5s ease-out forwards;
}
.ping-ring {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  height: 16px;
  margin: -8px 0 0 -8px;
  border-radius: 50%;
  border: 2px solid currentColor;
  opacity: 0;
  animation: ping-ring-expand 1.5s ease-out forwards;
}
.ping-ring:nth-child(2) { animation-delay: 0.25s; }
.ping-ring:nth-child(3) { animation-delay: 0.5s; }
.ping-ripple-host { color: #f5c518; }
.ping-ripple-guest { color: #4da3ff; }
@keyframes ping-ring-expand {
  0% { opacity: 0.85; transform: scale(0.3); }
  70% { opacity: 0.3; }
  100% { opacity: 0; transform: scale(3.4); }
}
@keyframes ping-dot-fade {
  0%, 35% { opacity: 0.9; }
  100% { opacity: 0; }
}

.stage-placeholder { color: var(--muted); font-size: 14px; text-align: center; padding: 0 30px; }

#emu-canvas, #remote-video, #pixelstream-preview-canvas {
  max-width: 100%;
  max-height: 100%;
  /* Explicit, not left to width:auto deriving it from the element's
     "intrinsic" ratio — <video> reports one reliably, but <canvas>'s
     intrinsic-ratio handling for this kind of replaced-element sizing is
     inconsistent across browsers/versions. #emu-canvas had only ever
     actually been *sized* on desktop Chrome (host's own native view)
     before PixelStream started showing it on the guest's mobile browser
     too — width:auto silently derived the wrong width there, leaving the
     picture squeezed into the left portion of the box instead of filling
     it. Stating the ratio outright sidesteps the ambiguity everywhere.
     Safe for the two elements that already worked (redundant, not
     conflicting, with whatever they were already resolving to). */
  aspect-ratio: 256 / 240;
  width: auto;
  height: 100%;
  image-rendering: pixelated;
  object-fit: contain;
}

/* SCUMM's own canvas — real 320x200 aspect ratio (AGS's native
   resolution, distinct from NES/SNES's 256/240), sized in JS at load
   time (see loadScummGame in app.js) since the canvas element's own
   width/height attributes get set dynamically rather than being fixed. */
.scumm-pointer-lock-hint {
  position: absolute; z-index: 7;
  top: 50%; left: 50%; transform: translate(-50%, -50%);
  padding: 8px 16px; border-radius: 10px;
  background: color-mix(in srgb, var(--panel-2) 88%, transparent);
  border: 1px solid var(--border);
  color: var(--text);
  font-size: 13px; font-weight: 600;
  pointer-events: none;
}
#scumm-canvas {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: 100%;
  image-rendering: pixelated;
  object-fit: contain;
  cursor: default;
}

.rage-quit-gif { width: 100%; height: 100%; object-fit: contain; border-radius: 8px; }
/* Control hand-off announcement (Zapper/SCUMM mouse/P1-P2 swap) — see
   showControlToast in app.js. Film-subtitle styling on purpose (solid
   black bar, plain white text) so it reads as a system announcement,
   distinct from .rage-quit-caption's rounded pill chat-style bubble.
   z-index above every other .stage overlay (.fx-layer's 25 is the
   highest of the rest) so a hand-off toast is never hidden behind one. */
.control-toast {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  background: #000;
  color: #fff;
  padding: 6px 16px;
  border-radius: 3px;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
  pointer-events: none;
  z-index: 30;
  opacity: 0;
  transition: opacity 0.25s ease;
}
.control-toast.visible { opacity: 1; }

.rage-quit-caption {
  position: absolute;
  top: 18px;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(0, 0, 0, 0.65);
  color: #fff;
  padding: 7px 18px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 700;
  white-space: nowrap;
}

/* #stage-controls is just a column layout wrapper now (see the overlay
   rule further down) — .stage-controls-bar (mute/volume + Nerd Stuff) and
   #admin-bar (host-only: Kick, Benchmark) are the actual visible pills,
   each with their own chrome so they can fade independently. */
.stage-controls { display: flex; flex-direction: column; gap: 6px; }
.stage-controls-bar { display: flex; align-items: center; gap: 12px; }
.stage-controls-divider {
  width: 1px;
  align-self: stretch;
  background: var(--border);
  flex-shrink: 0;
}

.icon-svg { display: block; }

.icon-btn {
  width: 38px;
  height: 38px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  cursor: pointer;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.icon-btn:hover { border-color: var(--accent); }

.icon-text-btn {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 14px;
  height: 38px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  font-size: 13px;
  cursor: pointer;
  flex-shrink: 0;
}
.icon-text-btn:hover { border-color: var(--accent); }

.volume-slider {
  width: 110px;
  height: 6px;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  border-radius: 4px;
  outline: none;
}
.volume-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 10px;
  height: 18px;
  border-radius: 3px;
  background: #fff;
  border: 2px solid var(--accent-2);
  cursor: pointer;
}
.volume-slider::-moz-range-track {
  width: 100%;
  height: 6px;
  border-radius: 4px;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.volume-slider::-moz-range-thumb {
  width: 10px;
  height: 18px;
  border-radius: 3px;
  background: #fff;
  border: 2px solid var(--accent-2);
  cursor: pointer;
}

/* Divider + live stats + toggle button, grouped so they move as one unit
   pinned to the right edge — margin-left: auto on the group itself
   (not on the toggle button alone) is what keeps them clustered together
   instead of the divider/stats drifting left with a big gap before the
   button. */
.stage-controls-media {
  display: flex;
  align-items: center;
  gap: 10px;
}
.stage-controls-nerd-group {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-left: auto;
}

/* Host-only tools — Kick/Kick+Ban, Benchmark/Super Benchmark. Prefers one
   thin row (see .stage-controls-media/.stage-controls-nerd-group's own
   min-height for the matching pill height); wraps to two only once it
   genuinely doesn't fit, same reasoning as Nerd Stats' own field row. */
.admin-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
}
#kick-p2-btn.armed {
  border-color: #ff5d73;
  color: #ff8c96;
}

/* Mirrors .stage-controls-nerd-group exactly: margin-left: auto on the
   GROUP (not the button alone) is what keeps the results clustered right
   next to the button instead of drifting left with a big gap before it —
   same left-data/right-toggle convention as Nerd Stuff. */
.admin-bar-benchmark-group {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-left: auto;
}
#nerd-benchmark-btn {
  gap: 6px;
}
/* Labeled distinctly from the live connection stats elsewhere on the bar
   so a finished run's numbers don't read as more of that same live data. */
#benchmark-result-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  padding-left: 10px;
  border-left: 1px solid var(--border);
}

/* No background/border/padding of its own on purpose — it used to be a
   rounded card nested inside the already-rounded stage-controls bar,
   a redundant "panel in a panel" that just ate up space for no reason.
   The content now sits directly on the bar's own background. */
.nerd-stats-pill {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 10px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* Flex row that wraps only when it actually runs out of width — on a
   wide-enough window the full field set (up to 10 cells for WebRTC) fits
   on one line; it only drops to a second line once it genuinely doesn't,
   rather than being artificially capped at a fixed column count. */
.nerd-stats-grid {
  display: flex;
  flex-wrap: wrap;
  row-gap: 6px;
  column-gap: 14px;
}
.nerd-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  min-width: 44px;
}
.nerd-stat-label {
  font-size: 8px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  opacity: 0.75;
  white-space: nowrap;
}
.nerd-stat-value { font-size: 11px; color: var(--text); font-weight: 600; white-space: nowrap; }

/* channels.js's per-channel status — separate from (above) the
   performance-metrics grid: this row answers "is it connected at all",
   not "how well". 'idle' is deliberately just dim, not colored — a
   channel correctly unused for the current mode (e.g. Audio in NetPlay)
   must read as neutral, not as a problem. */
.nerd-channel-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.nerd-channel-chip {
  font-size: 10px;
  padding: 2px 7px;
  border-radius: 6px;
  border: 1px solid var(--border);
  color: var(--muted);
}
.nerd-channel-chip.nerd-channel-active { border-color: #3ba55c; color: #3ba55c; }
.nerd-channel-chip.nerd-channel-connecting { border-color: #f4b942; color: #f4b942; }
.nerd-channel-chip.nerd-channel-down { border-color: #ff5d73; color: #ff8c96; }
.nerd-channel-chip.nerd-channel-idle { opacity: 0.55; }
/* The Streaming-mode quick-switch chip (host only — see
   cycleStreamMode) — a plain hover/cursor cue, same idea as any other
   clickable chip/pill elsewhere in this app. */
.nerd-channel-chip.nerd-channel-clickable { cursor: pointer; }
.nerd-channel-chip.nerd-channel-clickable:hover { background: color-mix(in srgb, #3ba55c 15%, transparent); }

#nerd-toggle-btn.nerd-alert {
  border-color: #ff5d73;
  color: #ff8c96;
  animation: nerd-alert-pulse 1.6s ease-in-out infinite;
}
@keyframes nerd-alert-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(255, 93, 115, 0.45); }
  50% { box-shadow: 0 0 0 5px rgba(255, 93, 115, 0); }
}

.pause-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  background: rgba(0, 0, 0, 0.55);
  color: var(--text);
  font-size: 18px;
  font-weight: 600;
  letter-spacing: 0.04em;
  z-index: 5;
}

.pause-overlay-icon { display: block; }

#pause-overlay-text.countdown-number {
  font-size: 88px;
  line-height: 1;
  letter-spacing: 0;
  text-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
}

/* ---------- Chat ---------- */

.chat-panel {
  width: var(--chat-width, 320px);
  min-width: 300px;
  max-width: 640px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 16px;
  overflow: hidden;
  position: relative;
  transition: box-shadow 0.25s ease, border-color 0.25s ease;
}

/* Subtle attention cue while a game's in progress and the other player is
   typing — box-shadow only, so the panel never changes size/position. */
.chat-panel.chat-glow {
  border-color: rgba(106, 92, 255, 0.55);
  box-shadow: 0 0 0 1px rgba(106, 92, 255, 0.35), 0 0 22px rgba(106, 92, 255, 0.35);
}
/* Weaker signal than chat-glow: the other player has the box focused but
   hasn't (yet, or ever) typed anything — a heads-up that they might be
   composing something before you continue the match. */
.chat-panel.chat-glow-focus {
  border-color: rgba(76, 201, 240, 0.5);
  box-shadow: 0 0 0 1px rgba(76, 201, 240, 0.3), 0 0 18px rgba(76, 201, 240, 0.3);
}

.chat-resize-handle {
  flex-shrink: 0;
  width: 10px;
  margin: 0 -5px;
  cursor: col-resize;
  position: relative;
  z-index: 10;
}
.chat-resize-handle::after {
  content: '';
  position: absolute;
  top: 8%;
  bottom: 8%;
  left: 50%;
  width: 3px;
  transform: translateX(-50%);
  border-radius: 3px;
  background: var(--border);
}
.chat-resize-handle:hover::after,
.chat-resize-handle.dragging::after { background: var(--accent); }

/* ---------- Stage controls: YouTube-style hover reveal ---------- */
/* #stage-controls itself is just a positioned column layout — one shared
   card (.stage-controls-bar, mute/volume + Nerd Stuff together) plus the
   separate #admin-bar card below it (host only). Both fade together as
   one synchronized block, via the single shared trigger below — visually
   two boxes, but always shown/hidden in lockstep.

   Reveal is driven by #stage-controls' own :hover, not a separately-sized
   sibling "hover zone" element (removed) — that zone was a fixed 100px,
   which comfortably covered just .stage-controls-bar (guest — nothing
   else ever renders for them) but not the *actual* combined height once
   #admin-bar exists and is visible (host only), especially once it wraps
   to two lines (Kick P2 + a Benchmark result row that doesn't fit on
   one). That mismatch — the zone no longer covering where the cards
   actually rendered — was the real "works for guest, broken/erratic for
   host" bug. #stage-controls' own box already reserves exactly the right
   space for its content even while hidden (visibility, not display, is
   what hides the cards), so hovering it directly scales correctly with
   whatever's actually in it, host or guest, one line or two. */
.stage-controls {
  position: absolute; z-index: 8; right: 8px; bottom: 8px; left: 8px;
  display: flex; flex-direction: column; gap: 6px;
}
/* Bottom, next to where #stage-controls' own bars actually appear (the
   user's own preference — top felt disconnected from what it opens).
   z-index above the bars themselves so it stays clickable to close them
   again even where the two visually overlap. */
.scumm-controls-toggle {
  position: absolute; z-index: 9; right: 8px; bottom: 8px;
  width: 38px; height: 38px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--border);
  background: color-mix(in srgb, var(--panel-2) 90%, transparent);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
  color: var(--muted);
  cursor: pointer;
}
.scumm-controls-toggle:hover { color: var(--text); border-color: var(--accent-2); }
.stage-controls-bar,
.admin-bar {
  min-height: 46px; padding: 7px 9px;
  border: 1px solid var(--border); border-radius: 11px;
  background: color-mix(in srgb, var(--panel-2) 78%, transparent);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
  opacity: 0; visibility: hidden; transform: translateY(8px); pointer-events: none;
  transition: opacity 150ms ease, transform 150ms ease, visibility 150ms;
}
/* Deliberately no :focus-within here — a button that happens to keep
   keyboard focus after being clicked (Nerd stuff) was enough to keep the
   *whole* bar pinned open indefinitely, needing an unrelated click
   elsewhere just to clear focus before it would fade again. */
.stage-controls:hover .stage-controls-bar,
.stage-controls:hover .admin-bar,
.stage-controls.touch-open .stage-controls-bar,
.stage-controls.touch-open .admin-bar {
  opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto;
}
/* SCUMM's own bottom edge is exactly where #stage-controls sits — with a
   relative/captured mouse, brushing that edge while actually trying to
   reach the bottom of the game screen kept popping these bars open by
   accident (real feedback). Hover alone no longer opens them here (the
   .scumm-active marker is set/cleared directly on #stage-controls itself
   in loadScummGame/stopScummGame — it isn't a descendant of .stage, so a
   .stage-scumm-active-on-an-ancestor selector wouldn't reach it); the
   floating #scumm-controls-toggle button drives the same .touch-open
   class touch devices already use instead. */
.stage-controls.scumm-active:hover .stage-controls-bar,
.stage-controls.scumm-active:hover .admin-bar {
  opacity: 0; visibility: hidden; transform: translateY(8px); pointer-events: none;
}
.stage-controls.scumm-active.touch-open .stage-controls-bar,
.stage-controls.scumm-active.touch-open .admin-bar {
  opacity: 1; visibility: visible; transform: translateY(0); pointer-events: auto;
}
/* Nerd Stats used to fade out with the rest of the bar on mouse-leave,
   even while still toggled on — it has no hover/focus of its own to keep
   things open (it's just live-updating text). The shared card stays open
   independent of hover for exactly as long as the pill itself is showing,
   but *only* the Nerd Stuff toggle + its data actually need to be visible
   then — the mute/volume portion collapses out of the shared card
   (display, not just opacity, so it doesn't leave a blank gap) until the
   mouse is actually back over the bar. */
.stage-controls-bar:has(#nerd-stats-pill:not(.hidden)) {
  opacity: 1; visibility: visible; transform: none; pointer-events: auto;
}
.stage-controls-bar:has(#nerd-stats-pill:not(.hidden)) .stage-controls-media {
  display: none;
}
.stage-controls:hover .stage-controls-bar:has(#nerd-stats-pill:not(.hidden)) .stage-controls-media,
.stage-controls.touch-open .stage-controls-bar:has(#nerd-stats-pill:not(.hidden)) .stage-controls-media {
  display: flex;
}

/* ---------- Compact laptop layout ---------- */

@media (max-height: 900px), (max-width: 1300px) {
  /* Only shrinks when space is actually tight — otherwise the long
     slider just costs width on an already-wide bar for no real reason. */
  .volume-slider { width: 70px; }
  .compact-layout .topbar { min-height: 50px; gap: 10px; padding: 6px 12px; }
  .compact-layout .layout { padding: 6px; gap: 6px; }
  .compact-layout .stage-area { gap: 0; }
  /* Brand takes the space Now Playing needs more in compact mode; the
     scoreboard doesn't need to be perfectly centered on the stage either
     once things are this tight, so let it sit naturally after Now Playing
     instead of fighting for the middle of the bar. */
  .compact-layout .brand { display: none; }
  .compact-layout .topbar .scoreboard-wrap { flex: 0 0 auto; }
  /* now-playing grows to fill the topbar's leftover width, keeping its text
     on the left (next to the scoreboard); topbar-right (avatar) gets a
     small fixed gap to sit right after, at the window's actual edge. */
  .compact-layout .topbar .now-playing {
    flex: 1 1 auto; min-width: 0; gap: 8px; padding: 0;
    border: 0; border-radius: 0; background: transparent;
  }
  .compact-layout .now-playing-art,
  .compact-layout .now-playing-art-fallback { width: 32px; height: 32px; }
  .compact-layout .now-playing-text { min-width: 0; }
  .compact-layout .now-playing-label { font-size: 9px; }
  .compact-layout .now-playing-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .compact-layout .topbar-right { margin-left: 14px; }
  /* Only compact mode turns the button group into a hover-revealed floating
     panel — there's room to keep it inline otherwise (see the base
     .now-playing-panel rule). No gap between trigger and panel (top:100%,
     no margin) — a gap there is a dead zone the mouse crosses without
     hovering anything, which drops :hover and closes the panel early. */
  .compact-layout .now-playing-panel {
    position: absolute;
    top: 100%;
    left: 0;
    margin-left: 0;
    z-index: 30;
    padding: 8px 10px;
    border: 1px solid var(--border);
    border-radius: 11px;
    background: color-mix(in srgb, var(--panel-2) 92%, transparent);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
    opacity: 0;
    visibility: hidden;
    transform: translateY(6px);
    pointer-events: none;
    transition: opacity 150ms ease, transform 150ms ease, visibility 150ms;
  }
  .compact-layout .now-playing:hover .now-playing-panel,
  .compact-layout .now-playing:focus-within .now-playing-panel {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
    pointer-events: auto;
  }
  .compact-layout .stage { border-radius: 12px; }
  .compact-layout .chat-panel { min-width: 220px; border-radius: 12px; }
  .compact-layout .chat-resize-handle { width: 6px; margin: 0 -3px; }
}

@media (max-width: 1050px) {
  .compact-layout .profile-chip span { display: none; }
  .compact-layout .profile-chip { padding: 4px; }
}

/* ---------- Mobile portrait layout ---------- */

.chat-mode-bar { display: none; }
/* Narrow + portrait only — landscape mobile falls back to the existing
   compact-layout above instead of this (a virtual pad genuinely needs the
   width portrait doesn't have, and side-by-side already works reasonably
   once there's enough width). --app-vh (see app.js, tracks
   visualViewport) stands in for 100vh so this actually shrinks when the
   on-screen keyboard opens instead of staying sized for the space behind
   it. */
@media (max-width: 640px) and (orientation: portrait) {
  .screen { height: var(--app-vh, 100vh); }

  .topbar { padding: 8px 10px; gap: 8px; min-height: 44px; }
  .topbar .brand { display: none; }
  .topbar .now-playing { display: none; }
  .now-playing-insult-mobile { display: flex; }
  /* .compact-layout .topbar-right's own margin-left: 14px (a small fixed
     gap, 2 classes' worth of specificity — this needs the same prefix to
     actually win) relies on .now-playing growing to fill the leftover
     width and do the real pushing-to-the-edge; right above, that element
     is hidden entirely on mobile, so nothing did that anymore and
     topbar-right (the mic button + avatar chip) was left stranded with
     just that small gap instead of flush against the real right edge.
     Back to auto here, same as the non-compact base rule. */
  .compact-layout .topbar-right { margin-left: auto; }
  /* Avatar + score only — an already-tight phone topbar has no room for
     "P1"/"P2" tags plus two variable-length player names on top of the
     menu button that also has to fit in there. */
  .scoreboard-tag, .scoreboard-name { display: none; }

  .layout { flex-direction: column; padding: 6px; gap: 6px; }
  .stage-area { flex: 0 0 auto; gap: 6px; }
  .stage {
    width: 100%;
    /* Matches the emulator's actual 256x240 frame (see #emu-canvas'
       width:auto/height:100% below) so the picture fills the full width
       with no letterboxing — 4:3 was the TV aspect ratio, not this raw
       pixel ratio, and left visible gaps on both sides. */
    aspect-ratio: 256 / 240;
    /* Derived from --app-vh (tracks visualViewport, see app.js), not a raw
       vh unit — vh doesn't shrink when the on-screen keyboard opens, so the
       stage kept claiming pre-keyboard height while .screen itself (already
       on --app-vh) correctly shrank, squeezing the chat input off-screen
       behind the keyboard with nothing visible above it. */
    max-height: calc(var(--app-vh, 100vh) * 0.46);
    border-radius: 12px;
  }
  /* The chat input/mode-bar/player-actions rows around the stage are a
     fixed height that doesn't shrink with the viewport — 46% of a normal
     --app-vh leaves them enough room, but 46% of the keyboard-shrunk
     --app-vh doesn't, clipping the input box by a few px. See
     .keyboard-open in app.js. */
  html.keyboard-open .stage { max-height: calc(var(--app-vh, 100vh) * 0.28); }
  .chat-resize-handle { display: none; }
  .chat-panel { flex: 1; min-width: 0; width: 100%; min-height: 0; border-radius: 12px; }

  /* Stage controls are tap-toggled on touch devices (see .touch-open above
     and app.js) rather than shown unconditionally — just tightened up here
     to fit a phone width. */
  .stage-controls { flex-wrap: wrap; gap: 6px; padding: 6px; }
  .stage-controls-nerd-group { margin-left: 0; }

  /* chat-glow/chat-glow-focus's soft blur normally bleeds ~18-22px past
     the panel's own edges — on desktop there's enough surrounding margin
     that never reaches the viewport edge, but mobile's tight 6px .layout
     padding put the panel edge close enough to it that html/body's
     overflow: hidden (see above) was clipping most of the glow away.
     Tightened so it fits inside that margin instead of needing more of it
     (which would cost layout space we already fought hard to reclaim). */
  .chat-panel.chat-glow { box-shadow: 0 0 0 1px rgba(106, 92, 255, 0.5), 0 0 6px rgba(106, 92, 255, 0.5); }
  .chat-panel.chat-glow-focus { box-shadow: 0 0 0 1px rgba(76, 201, 240, 0.45), 0 0 6px rgba(76, 201, 240, 0.45); }

  /* Chat/Reactions/More/Pad mode row — top of the chat panel. Chat is the
     normal, fully interactive panel; the other three each take over the
     whole panel body for just their one thing (reactions grid, Ready/BRB/
     Dance/Rage-quit, or the virtual pad over a locked chat log — see
     .chat-panel.pad-mode/.reactions-mode/.more-mode) instead of all
     competing for space permanently the way they used to. Pad is bigger —
     it's the primary way to play. */
  .chat-mode-bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 6px;
    padding: 8px;
    flex-shrink: 0;
    position: relative;
    z-index: 40;
  }
  .chat-mode-btn {
    border: 1px solid var(--border);
    border-radius: 10px;
    background: var(--panel-2);
    color: var(--text);
    font-size: 12px;
    font-weight: 600;
    padding: 7px 10px;
    cursor: pointer;
    flex-shrink: 0;
  }
  .chat-mode-btn.active {
    border-color: var(--accent);
    color: var(--accent);
    background: color-mix(in srgb, var(--accent) 16%, var(--panel-2));
  }
  .chat-mode-pad-btn { font-size: 13px; padding: 9px 16px; font-weight: 700; }

  /* The reactions grid and Ready/BRB/Dance/Rage-quit row are both tall
     enough to eat a good chunk of a phone's chat panel, so both are
     hidden by default here and only shown in their own mode below —
     unlike desktop, where there's room to leave them on permanently.
     Scoped to .chat-panel (not just the bare class) so this reliably
     beats the base always-on-desktop rule regardless of the two rules'
     order in the file — same specificity would otherwise let source
     order decide, which is fragile. */
  .chat-panel .fun-actions,
  .chat-panel .player-actions { display: none; }

  /* Flat, minimal message rows on phones instead of desktop's bubble
     look — narrow-width bubbles read poorly and weren't the ask here;
     grouping (see .chat-row.grouped/.chat-avatar-spacer, unchanged) does
     the real work of keeping consecutive same-sender messages compact,
     this just strips the chrome around each line. */
  .bubble {
    background: none;
    box-shadow: none;
    border-radius: 0;
    padding: 1px 0;
  }
  .chat-row.mine .bubble { background: none; }
  .chat-row.grouped { margin-top: 1px; }
}

/* Chat mode: normal, fully interactive panel, just reactions/More hidden
   (see above) — that's most of what this whole mode-switch is for, giving
   the actual conversation the full phone screen instead of a sliver.
   Pad mode: only real player-sent messages are visible in the chat log
   behind the pad — system lines, game/state cards, the typing indicator —
   and it goes inert and slightly dimmed; everything else that could be
   tapped by accident while playing is hidden outright rather than just
   disabled.
   Reactions mode: only the reactions grid, centered in the freed-up
   space — no chat, no pad, nothing else tappable.
   More mode: Ready/BRB/Dance/Rage-quit, same idea — own row, own space,
   nothing else competing for it. */
.chat-panel.pad-mode .chat-form,
.chat-panel.pad-mode .player-actions,
.chat-panel.pad-mode #reply-preview,
.chat-panel.reactions-mode .chat-form,
.chat-panel.reactions-mode .player-actions,
.chat-panel.reactions-mode #reply-preview,
.chat-panel.more-mode .chat-form,
.chat-panel.more-mode .fun-actions,
.chat-panel.more-mode #reply-preview {
  display: none;
}
.chat-panel.pad-mode .chat-log > *:not(.chat-row) { display: none; }
.chat-panel.pad-mode .chat-log { pointer-events: none; }
.chat-panel.pad-mode .chat-log::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.28);
}
.chat-panel.reactions-mode .chat-log,
.chat-panel.more-mode .chat-log {
  display: none;
}
/* A vertical list, not a row — each button its own full-width line,
   stacked and centered as a group in the freed-up space. */
/* 2x2, not a single column of 4 — 4 stacked full-height rows plus their
   gaps didn't quite fit the available height on every phone, clipping
   Rage quit off the bottom. 2 columns halves the height needed. */
.chat-panel.more-mode .player-actions {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  flex: 1;
  align-content: center;
  gap: 10px;
  padding: 16px;
}
.chat-panel.more-mode .player-actions .fun-action-btn {
  height: 52px;
  font-size: 15px;
}
/* A grid of tappable tiles fills the whole freed-up space instead of the
   small, cramped single-row-that-wraps used everywhere else (there's
   plenty of room here — reactions-mode hides the chat log entirely just
   to make room for this). 5 columns, not 4 — at 4 the tiles were big
   enough that the full reactions list needed its own scroll to see them
   all; 5 fits everything without one. */
.chat-panel.reactions-mode .fun-actions {
  display: grid;
  flex: 1;
  grid-template-columns: repeat(5, 1fr);
  align-content: center;
  gap: 10px;
  padding: 12px;
  overflow-y: auto;
}
.chat-panel.reactions-mode .fun-action-btn {
  aspect-ratio: 1;
  width: 100%;
  font-size: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 16px;
}
.chat-panel.reactions-mode .fun-action-btn:active { background: var(--panel); }

.virtual-gamepad {
  display: none;
}
@media (max-width: 640px) and (orientation: portrait) {
  .virtual-gamepad {
    display: flex;
    position: absolute;
    inset: 0;
    z-index: 30;
    /* Centered in the available space, not pinned to the bottom — now that
       reactions/input/player-actions are hidden entirely in Pad mode (see
       .chat-panel.pad-mode above) rather than just sitting below it, the
       pad's "available space" is the whole panel body, and flex-end left
       it stranded near the very bottom with a lot of empty room above. */
    align-items: center;
    justify-content: space-between;
    padding: 10px;
    pointer-events: none;
    opacity: 1;
    transition: opacity 200ms ease;
  }
  .virtual-gamepad.hidden { display: flex; opacity: 0; pointer-events: none; }
  .virtual-gamepad:not(.hidden) .vgp-btn { pointer-events: auto; }
  /* #vgp-dpad itself is the actual interactive element now (see
     virtual-gamepad.js) — but it isn't a .vgp-btn, so the rule above
     never touched it, and it just inherited pointer-events: none
     straight from .virtual-gamepad above. Nothing in the whole zone was
     clickable as a result — not the icons (deliberately pointer-events:
     none, see their own rule), and not the one element that was
     actually supposed to catch the touch either. */
  .virtual-gamepad:not(.hidden) #vgp-dpad { pointer-events: auto; }

  /* The spotlight sweep is 8 giant (2.6x viewport diagonal) conic-gradient
     layers with mix-blend-mode, continuously rotating — fine on a desktop
     GPU, but the compositing cost of that many huge blended layers is what
     was causing visibly janky repaints on a phone. Confetti volume is
     turned down separately in initDance() (app.js); this is cut outright
     rather than just scaled down since it's the single most expensive part
     and barely readable on a small screen edge-to-edge anyway. */
  .dance-spotlights, .dance-spotlights-intro { display: none; }
}

.vgp-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(255, 255, 255, 0.25);
  background: rgba(20, 18, 28, 0.55);
  color: #fff;
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  touch-action: none;
}
.vgp-btn:active, .vgp-btn.vgp-pressed { background: rgba(255, 255, 255, 0.25); }

.vgp-dpad {
  position: relative;
  width: 132px;
  height: 132px;
  flex-shrink: 0;
  display: grid;
  grid-template-columns: 44px 44px 44px;
  grid-template-rows: 44px 44px 44px;
  touch-action: none;
}
/* Purely decorative now (see virtual-gamepad.js) — #vgp-dpad itself
   handles every pointer event, so these must never be the actual
   hit-test target or a touch landing on one of them (as opposed to the
   empty space between them) would never reach the dpad's own listener at
   all, which is exactly what "only activates if you touch a button
   icon" turned out to be. Scoped with the #vgp-dpad ancestor (not just
   the bare class names) specifically to outrank
   .virtual-gamepad:not(.hidden) .vgp-btn's own pointer-events: auto
   above (3 classes' worth of specificity) — plain .vgp-up etc. selectors
   lost to it outright regardless of source order. */
#vgp-dpad .vgp-up,
#vgp-dpad .vgp-down,
#vgp-dpad .vgp-left,
#vgp-dpad .vgp-right,
#vgp-dpad .vgp-dpad-center {
  pointer-events: none;
}
.vgp-up { grid-column: 2; grid-row: 1; border-radius: 8px 8px 0 0; }
.vgp-down { grid-column: 2; grid-row: 3; border-radius: 0 0 8px 8px; }
.vgp-left { grid-column: 1; grid-row: 2; border-radius: 8px 0 0 8px; }
.vgp-right { grid-column: 3; grid-row: 2; border-radius: 0 8px 8px 0; }
.vgp-dpad-center { grid-column: 2; grid-row: 2; background: rgba(20, 18, 28, 0.55); border: 1px solid rgba(255, 255, 255, 0.25); }

/* The floating stick knob — positioned via inline transform from
   virtual-gamepad.js, clamped to a fixed radius around wherever the
   touch anchored (not necessarily this box's own center). Shown only
   while a finger is actually down. */
.vgp-stick {
  position: absolute;
  top: 0;
  left: 0;
  width: 56px;
  height: 56px;
  margin: -28px 0 0 -28px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.3);
  border: 1px solid rgba(255, 255, 255, 0.55);
  pointer-events: none;
  z-index: 1;
}
.vgp-stick.hidden { display: none; }

.vgp-meta {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 0 auto 8px;
}
.vgp-pill {
  border-radius: 999px;
  padding: 6px 10px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.04em;
}

.vgp-face {
  position: relative;
  width: 116px;
  height: 90px;
  flex-shrink: 0;
}
.vgp-round {
  position: absolute;
  width: 54px;
  height: 54px;
  border-radius: 50%;
  font-weight: 700;
  font-size: 16px;
}
.vgp-b { left: 0; bottom: 0; }
.vgp-a { right: 0; top: 0; }

/* ---------- Fun action effects ---------- */

.fx-layer {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 25;
  border-radius: 16px;
}

.fx-particle {
  position: absolute;
  top: -40px;
  left: 0;
  border-radius: 2px;
  color: #fff;
  line-height: 1;
  text-align: center;
  pointer-events: none;
  animation-name: fx-fall;
  animation-timing-function: ease-in;
  /* "both" (not just "forwards") applies the 0% keyframe during the
     random per-particle delay too — otherwise the particle briefly
     renders at its un-animated CSS position and is visible before its
     fall actually starts. */
  animation-fill-mode: both;
  will-change: transform, opacity;
}

.fx-particle.fx-shape-circle { border-radius: 50%; }
.fx-particle.fx-shape-triangle {
  width: 0 !important;
  height: 0 !important;
  background: transparent !important;
  border-left: var(--tri-size, 6px) solid transparent;
  border-right: var(--tri-size, 6px) solid transparent;
  border-bottom: calc(var(--tri-size, 6px) * 1.7) solid var(--tri-color, #fff);
  border-radius: 0;
}
.fx-particle.fx-shape-spiral {
  border-radius: 50%;
  border: 2px solid currentColor;
  background: transparent !important;
  color: inherit;
}

@keyframes fx-fall {
  0% { transform: translate(0, -40px) rotate(0deg); opacity: 1; }
  100% { transform: translate(var(--sway), 440px) rotate(340deg); opacity: 0; }
}

/* Same fall, just viewport-height instead of a short stage-box drop — for
   confetti aimed at a fixed full-screen layer (Dance!) instead of the
   game box. */
.fx-particle-full { animation-name: fx-fall-full; }
@keyframes fx-fall-full {
  0% { transform: translate(0, -40px) rotate(0deg); opacity: 1; }
  100% { transform: translate(var(--sway), 100vh) rotate(340deg); opacity: 0; }
}

.fx-angry {
  animation: fx-angry-pulse var(--fx-pulse-duration, 0.4s) ease-in-out var(--fx-pulse-count, 6);
}
@keyframes fx-angry-pulse {
  0%, 100% { background-color: transparent; }
  50% { background-color: var(--fx-pulse-color, rgba(255, 40, 40, 0.34)); }
}

/* Same idea as the "angry" fun-effect pulse, but scoped to the chat panel
   and silent — the rage-quit moment already has its own sound cue (or
   lack thereof, deliberately, per request), this is just a visual beat. */
/* Dance! — one shared 6-stop rainbow, reused at different speeds for each
   glowing edge (chat border ~1 color/sec, stage border ~1/2sec, chat input
   ~1/4sec — "transición ligera"). IMPORTANT: two separate rules that both
   set the `animation` shorthand on the *same* element don't combine — the
   later one in source order wins outright and silently drops the first
   animation entirely. dance-glow + dance-panel both target .chat-panel, so
   they're combined into a single rule with a comma-separated animation
   list instead of two rules (this was the actual bug behind "no glow" the
   first time around: dance-panel's rule was quietly cancelling
   dance-glow's).
   Colors are solid (not translucent) — box-shadow/border-color animate as
   number interpolation and don't reliably enable smoothly with alpha near
   0 the way opacity keyframes do, so used-once translucency lives in the
   blur spread instead. */
@keyframes dance-glow-shadow {
  0%      { box-shadow: 0 0 0 2px #ff5050, 0 0 26px #ff5050; }
  16.66%  { box-shadow: 0 0 0 2px #ffaa3c, 0 0 26px #ffaa3c; }
  33.33%  { box-shadow: 0 0 0 2px #ffe63c, 0 0 26px #ffe63c; }
  50%     { box-shadow: 0 0 0 2px #5ae678, 0 0 26px #5ae678; }
  66.66%  { box-shadow: 0 0 0 2px #4cc9f0, 0 0 26px #4cc9f0; }
  83.33%  { box-shadow: 0 0 0 2px #6a5cff, 0 0 26px #6a5cff; }
  100%    { box-shadow: 0 0 0 2px #ff5050, 0 0 26px #ff5050; }
}
@keyframes dance-panel-cycle {
  0%      { background-color: color-mix(in srgb, var(--panel) 85%, #ff5050 15%); }
  16.66%  { background-color: color-mix(in srgb, var(--panel) 85%, #ffaa3c 15%); }
  33.33%  { background-color: color-mix(in srgb, var(--panel) 85%, #ffe63c 15%); }
  50%     { background-color: color-mix(in srgb, var(--panel) 85%, #5ae678 15%); }
  66.66%  { background-color: color-mix(in srgb, var(--panel) 85%, #4cc9f0 15%); }
  83.33%  { background-color: color-mix(in srgb, var(--panel) 85%, #6a5cff 15%); }
  100%    { background-color: color-mix(in srgb, var(--panel) 85%, #ff5050 15%); }
}
/* Requires BOTH classes at once (app.js always toggles them as a pair) —
   see the comment above for why this can't be two separate rules. */
.chat-panel.dance-glow.dance-panel {
  animation: dance-glow-shadow 1s linear infinite, dance-panel-cycle 2s linear infinite;
}

@keyframes dance-stage-border {
  0%      { border-color: #ff5050; box-shadow: 0 0 22px rgba(255, 80, 80, 0.7); }
  16.66%  { border-color: #ffaa3c; box-shadow: 0 0 22px rgba(255, 170, 60, 0.7); }
  33.33%  { border-color: #ffe63c; box-shadow: 0 0 22px rgba(255, 230, 60, 0.7); }
  50%     { border-color: #5ae678; box-shadow: 0 0 22px rgba(90, 230, 120, 0.7); }
  66.66%  { border-color: #4cc9f0; box-shadow: 0 0 22px rgba(76, 201, 240, 0.7); }
  83.33%  { border-color: #6a5cff; box-shadow: 0 0 22px rgba(106, 92, 255, 0.7); }
  100%    { border-color: #ff5050; box-shadow: 0 0 22px rgba(255, 80, 80, 0.7); }
}
.stage.dance-stage-glow { animation: dance-stage-border 2s linear infinite; }

/* Lighter than the other two on purpose ("transición ligera") — just the
   border color drifting, no outer blur. */
@keyframes dance-input-border {
  0%      { border-color: #ff5050; }
  16.66%  { border-color: #ffaa3c; }
  33.33%  { border-color: #ffe63c; }
  50%     { border-color: #5ae678; }
  66.66%  { border-color: #4cc9f0; }
  83.33%  { border-color: #6a5cff; }
  100%    { border-color: #ff5050; }
}
.chat-input-wrap.dance-input-glow { animation: dance-input-border 24s linear infinite; }

@keyframes dance-now-playing-border {
  0%      { border-color: #ff5050; box-shadow: 0 0 16px rgba(255, 80, 80, 0.6); }
  16.66%  { border-color: #ffaa3c; box-shadow: 0 0 16px rgba(255, 170, 60, 0.6); }
  33.33%  { border-color: #ffe63c; box-shadow: 0 0 16px rgba(255, 230, 60, 0.6); }
  50%     { border-color: #5ae678; box-shadow: 0 0 16px rgba(90, 230, 120, 0.6); }
  66.66%  { border-color: #4cc9f0; box-shadow: 0 0 16px rgba(76, 201, 240, 0.6); }
  83.33%  { border-color: #6a5cff; box-shadow: 0 0 16px rgba(106, 92, 255, 0.6); }
  100%    { border-color: #ff5050; box-shadow: 0 0 16px rgba(255, 80, 80, 0.6); }
}
.now-playing.dance-now-playing-glow { animation: dance-now-playing-border 8s linear infinite; }

/* 12s-mark fadeout (1s) — box-shadow/background-color/border-color
   transition smoothly from wherever the color-cycle animation left off
   back to their resting values, instead of snapping the instant the
   dance-* classes are removed. Both confetti spawners (side-cannon and
   top-rain) stop at the same moment (see fadeOutDanceEffects in app.js),
   letting whatever's already in flight/falling die out naturally rather
   than continuing indefinitely or being force-faded itself. */
.chat-panel.dance-fade-out,
.stage.dance-fade-out,
.chat-input-wrap.dance-fade-out,
.now-playing.dance-fade-out {
  transition: box-shadow 1s linear, background-color 1s linear, border-color 1s linear;
}
.dance-spotlights.dance-fade-out {
  transition: opacity 1s linear;
  opacity: 0;
}

/* Spotlights: a conic-gradient wedge, anchored at the exact center of its
   own (huge) element, with that element positioned so its center lands on
   the intended screen point — rotating around transform-origin:50% 50%
   then just spins the whole thing around that fixed point. The element is
   ~2.6x the viewport diagonal, so no edge of the box (where the conic
   gradient's wedge necessarily terminates) is ever within the visible
   area at any rotation angle. Earlier versions sized the box to exactly
   the viewport, which meant the box's *own far edge* swept into view as a
   flat, hard-edged cutoff partway through the rotation — that visible
   "end of the beam" was the actual bug, not just a tuning issue. */
.dance-spotlights {
  position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 90;
}
.dance-spotlight {
  position: absolute;
  width: 260vmax;
  height: 260vmax;
  opacity: 0.5;
  mix-blend-mode: screen;
  transform-origin: 50% 50%;
}
.dance-spotlight-tl {
  left: calc(0% - 130vmax);
  top: calc(0% - 130vmax);
  background: conic-gradient(from 25deg at 50% 50%, transparent 0deg, #ff5050 10deg, transparent 24deg 360deg);
  animation: dance-spot-sweep-a 3.4s ease-in-out infinite;
}
.dance-spotlight-tr {
  left: calc(100% - 130vmax);
  top: calc(0% - 130vmax);
  background: conic-gradient(from 205deg at 50% 50%, transparent 0deg, #4cc9f0 10deg, transparent 24deg 360deg);
  animation: dance-spot-sweep-b 2.9s ease-in-out infinite;
}
.dance-spotlight-bl {
  left: calc(0% - 130vmax);
  top: calc(100% - 130vmax);
  background: conic-gradient(from -25deg at 50% 50%, transparent 0deg, #ffd166 10deg, transparent 24deg 360deg);
  animation: dance-spot-sweep-b 3.1s ease-in-out infinite;
}
.dance-spotlight-br {
  left: calc(100% - 130vmax);
  top: calc(100% - 130vmax);
  background: conic-gradient(from 155deg at 50% 50%, transparent 0deg, #6a5cff 10deg, transparent 24deg 360deg);
  animation: dance-spot-sweep-a 2.6s ease-in-out infinite;
}
/* Center-left/center-right pair — a distinct mirrored pair from the tl/tr/
   bl/br corners, anchored at the vertical mid-point of each edge. */
.dance-spotlight-left {
  left: calc(0% - 130vmax);
  top: calc(50% - 130vmax);
  background: conic-gradient(from -5deg at 50% 50%, transparent 0deg, #ff9f4c 10deg, transparent 24deg 360deg);
  animation: dance-spot-sweep-a 3s ease-in-out infinite;
}
.dance-spotlight-right {
  left: calc(100% - 130vmax);
  top: calc(50% - 130vmax);
  background: conic-gradient(from 185deg at 50% 50%, transparent 0deg, #2ee6a6 10deg, transparent 24deg 360deg);
  animation: dance-spot-sweep-b 3s ease-in-out infinite;
}
/* The two central ones: fixed white, pulsing on/off every second AND
   sweeping — at double the speed of the side spotlights (~3s -> 1.5s),
   14 sweep repetitions (the pulse itself keeps going, infinite). Each
   selector needs its own rule (rather than a shared grouped one) so they
   can sweep in mirrored directions via different keyframes — combined
   into one `animation` shorthand per rule, since separate rules setting
   `animation` on the same element cancel rather than combine. */
.dance-spotlight-top1 {
  top: calc(0% - 130vmax);
  left: calc(38% - 130vmax);
  background: conic-gradient(from 170deg at 50% 50%, transparent 0deg, #ffffff 7deg, transparent 16deg 360deg);
  animation: dance-spot-pulse 1s ease-in-out infinite, dance-spot-sweep-a 1.5s ease-in-out 14;
}
.dance-spotlight-top2 {
  top: calc(0% - 130vmax);
  left: calc(62% - 130vmax);
  background: conic-gradient(from 190deg at 50% 50%, transparent 0deg, #ffffff 7deg, transparent 16deg 360deg);
  animation: dance-spot-pulse 1s ease-in-out infinite, dance-spot-sweep-b 1.5s ease-in-out 14;
}
@keyframes dance-spot-sweep-a { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(40deg); } }
@keyframes dance-spot-sweep-b { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-40deg); } }
@keyframes dance-spot-pulse { 0%, 100% { opacity: 0.1; } 50% { opacity: 0.8; } }

/* One-time intro: 5 beams fan out from dead-center-bottom like a concert
   rig powering on, then fade out once fully spread — not part of the
   looping steady-state spotlights above. */
.dance-spotlights-intro {
  position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 91;
}
.dance-intro-beam {
  position: absolute;
  left: calc(50% - 130vmax);
  top: calc(100% - 260vmax);
  width: 260vmax;
  height: 260vmax;
  opacity: 1;
  mix-blend-mode: screen;
  transform-origin: 50% 100%;
  background: conic-gradient(from 180deg at 50% 100%, transparent 0deg, #ffffff 8deg, transparent 20deg 360deg);
  animation: dance-intro-fan 2s cubic-bezier(0.2, 0.7, 0.3, 1) both;
  animation-delay: var(--intro-delay, 0s);
}
@keyframes dance-intro-fan {
  0%   { transform: rotate(0deg); opacity: 1; }
  65%  { transform: rotate(var(--intro-angle)); opacity: 0.9; }
  100% { transform: rotate(var(--intro-angle)); opacity: 0; }
}

.dance-confetti-layer {
  position: fixed;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 95;
}
/* Side-cannon launches (every 3s, from left-center/right-center) — starts
   pinned to the edge and flies inward/outward via --dx/--dy set per
   particle in JS, distinct from the falling confetti's fx-fall keyframe. */
.fx-particle.fx-particle-launch {
  top: 50%;
  animation-name: fx-launch;
  animation-timing-function: cubic-bezier(0.15, 0.6, 0.35, 1);
}
@keyframes fx-launch {
  0%   { transform: translate(0, 0) rotate(0deg); opacity: 1; }
  100% { transform: translate(var(--dx), var(--dy)) rotate(400deg); opacity: 0; }
}

.chat-panel.rage-glow {
  animation: chat-rage-pulse 0.4s ease-in-out 6;
}
@keyframes chat-rage-pulse {
  0%, 100% { background-color: var(--panel); }
  50% { background-color: rgba(255, 40, 40, 0.28); }
}

.fx-flash {
  animation: fx-flash-pulse var(--fx-flash-duration, 0.4s) ease-out;
}
@keyframes fx-flash-pulse {
  0% { background-color: var(--fx-flash-color, rgba(255, 255, 255, 0.9)); }
  100% { background-color: transparent; }
}

@keyframes fx-shake {
  10%, 90% { transform: translateX(-6px); }
  20%, 80% { transform: translateX(6px); }
  30%, 50%, 70% { transform: translateX(-10px); }
  40%, 60% { transform: translateX(10px); }
}
/* One fixed-speed cycle, repeated --fx-shake-iterations times (computed
   in effects.js's runShake from duration/speed) — this is what lets
   duration and wobble speed vary independently instead of duration just
   stretching a single cycle into slow motion. */
.fx-buzz {
  animation: fx-shake var(--fx-shake-cycle-duration, 0.25s) linear var(--fx-shake-iterations, 2);
}

/* Edge glow (see effects.js's runEdgeGlow) — glows #chat-panel's own
   border/shadow instead of anything inside it. --fx-edge-color is set
   (and, for Color party, repeatedly updated) from JS; a transition on the
   plain .fx-edge-party state lets those updates cross-fade smoothly
   instead of hard-cutting between colors. */
.fx-edge-pulse,
.fx-edge-flash,
.fx-edge-party {
  --fx-edge-color: transparent;
}
@keyframes fx-edge-pulse-kf {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  50% { box-shadow: 0 0 18px 4px var(--fx-edge-color); }
}
.fx-edge-pulse {
  animation: fx-edge-pulse-kf var(--fx-edge-cycle, 0.6s) ease-in-out var(--fx-edge-iterations, 3);
}
@keyframes fx-edge-flash-kf {
  0% { box-shadow: 0 0 26px 6px var(--fx-edge-color); }
  100% { box-shadow: 0 0 0 0 transparent; }
}
.fx-edge-flash {
  animation: fx-edge-flash-kf var(--fx-edge-duration, 0.4s) ease-out;
}
.fx-edge-party {
  box-shadow: 0 0 18px 4px var(--fx-edge-color);
  transition: box-shadow 0.3s ease;
}

.fun-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  padding: 4px 10px;
  border-top: 1px solid var(--border);
}
.fun-action-btn {
  border: none;
  background: transparent;
  font-size: 17px;
  padding: 4px 8px;
  border-radius: 8px;
  cursor: pointer;
}
.fun-action-btn:hover { background: var(--panel-2); }
.fun-action-btn:disabled { opacity: 0.4; cursor: default; }

.secondary-btn {
  background: var(--secondary-btn-bg);
  border: 1px solid var(--border);
  color: var(--text);
}
.secondary-btn:hover { filter: brightness(1.15); }

.player-actions {
  display: flex;
  gap: 8px;
  padding: 8px 10px;
  border-top: 1px solid var(--border);
}

.vote-panel {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 8px 10px;
  border-top: 1px solid var(--border);
}
.vote-panel .ghost-btn { width: 100%; }
#vote-enough-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(255, 93, 115, 0.12); }

.player-actions .fun-action-btn {
  font-size: 12px;
  font-weight: 600;
  border-radius: 8px;
  padding: 7px 12px;
  display: flex;
  align-items: center;
  gap: 4px;
}

.afk-btn.active {
  background: rgba(255, 209, 102, 0.15);
  border-color: rgba(255, 209, 102, 0.5);
  color: #ffd166;
}

.rage-quit-btn:hover {
  background: rgba(255, 93, 115, 0.15);
  border-color: rgba(255, 93, 115, 0.5);
  color: #ff8c96;
}


.chat-log {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  padding: 14px;
  display: flex;
  flex-direction: column;
  position: relative;
  background:
    radial-gradient(circle at 20% 10%, rgba(106, 92, 255, 0.06), transparent 40%),
    radial-gradient(circle at 80% 80%, rgba(255, 93, 115, 0.05), transparent 40%);
}

.chat-system {
  align-self: center;
  color: var(--muted);
  font-size: 11px;
  background: var(--panel-2);
  border-radius: 999px;
  padding: 4px 12px;
  margin: 4px 0;
  text-align: center;
  max-width: 90%;
}
.chat-me { font-style: italic; }
.chat-me b { font-style: normal; color: var(--text); }

/* Host-only "delete this game?" prompt (see chat.js's appendDeleteGamePrompt)
   — a real card, not .chat-system's own pill shape, since that's built
   for a single short line and this needs room for 2-3 buttons below the
   question. Never broadcast — only ever exists in the host's own chat
   log, so no role/mine styling needed. */
.chat-delete-game-prompt {
  align-self: center;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 10px 14px;
  margin: 4px 0;
  text-align: center;
  max-width: 90%;
  font-size: 12.5px;
  color: var(--text);
}
.chat-delete-game-actions { display: flex; gap: 8px; justify-content: center; margin-top: 8px; flex-wrap: wrap; }

.chat-game-card {
  align-self: stretch;
  width: 100%;
  /* Only the challenge card (.chat-system + .chat-game-card together,
     see chat.js's appendChallenge) also carries .chat-system's own
     max-width:90% (meant for a short one-line pill like "X joined"),
     which was quietly capping this card's real width below what
     align-self:stretch + width:100% were actually asking for. */
  max-width: 100%;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 8px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 12px;
  margin: 8px 0;
}
/* The original single row (thumbnail + text) — its own row now that the
   rating badge list (see .rating-badges-list) can add a second one
   underneath. */
.chat-game-card-top { display: flex; align-items: center; gap: 14px; }
/* Wraps just the thumbnail (not the whole row) so the rating corner —
   and its hover target — lands on the image itself, not wherever
   "top-right of the row" happens to fall next to the title text. */
.chat-game-card-art-wrap { position: relative; width: 76px; height: 76px; flex-shrink: 0; }
.chat-game-card-art {
  width: 76px;
  height: 76px;
  border-radius: 10px;
  object-fit: cover;
  object-position: top; /* same "crop the bottom, not the title" reasoning as the grid card's own art */
  background: var(--panel);
}
.chat-game-card-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.chat-game-card-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.chat-game-card-title { font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-game-card-date { font-size: 11px; color: var(--muted); }
/* Challenge card's own text (see chat.js's renderChallengeBody) — reused
   .chat-game-card-label/-title at first, but those were built for a
   short/muted label + a short/bold, single-line-truncated title (the
   game name), backwards from what this needs: a short bold HEADLINE up
   top and a longer sentence below it that should wrap, never truncate. */
.chat-challenge-headline { font-size: 15px; font-weight: 700; color: var(--text); }
.chat-challenge-body { font-size: 13px; color: var(--muted); line-height: 1.4; }
/* "X saved a game" — see chat.js's appendStateCard. About half the
   footprint of the full loaded-game card, no title/date lines. */
.chat-game-card-compact { padding: 8px 12px; gap: 10px; margin: 4px 0; }
.chat-game-card-compact .chat-game-card-art { width: 40px; height: 40px; border-radius: 8px; }
.chat-game-card-compact .chat-game-card-label {
  font-size: 12.5px;
  text-transform: none;
  letter-spacing: normal;
  color: var(--text);
}
.chat-game-card-players { font-size: 11px; color: var(--muted); }
/* Pending-challenge card's Accept/Reject — same thumbs pill as a loaded
   game's own like/hate vote (see .rating-pills-row/.rating-pill), just
   centered and naturally sized instead of stretched edge to edge. */
.chat-challenge-pills { justify-content: center; }
.chat-challenge-pills .rating-pill { flex: 0 0 auto; padding: 7px 18px; }

/* Screenshot card (see the camera button next to volume, app.js/chat.js) —
   a real chat bubble now (see appendPhotoCard in chat.js), same row/
   avatar/grouping structure as a text message, just with the image (and
   its action buttons) as the bubble's content instead of text. */
.bubble.chat-photo-card { width: 240px; max-width: 100%; }
.chat-photo-card-img { width: 100%; display: block; border-radius: 8px; image-rendering: pixelated; margin-bottom: 6px; }
.chat-photo-card-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; margin-bottom: 4px; }
/* Save/Download/Share stay always-visible (no hover-to-reveal) even
   though they now sit inside a normal bubble — they're not the React
   button, which still follows .chat-msg-actions' own hover rule. */
.chat-photo-card-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}
.chat-photo-card-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Set once the "Save to my gallery" click actually succeeds — a lasting
   confirmation instead of a toast, since the button stays on screen for
   as long as the chat card itself does. */
.chat-photo-card-btn.saved { border-color: var(--accent); color: var(--accent); background: rgba(255, 93, 115, 0.12); }

/* Gallery modal (see the menu-gallery item + openGalleryModal in app.js) —
   thumbnails reuse the avatars-admin-tile "image with an overlay on top"
   shape rather than rom-library-card's column layout, since a screenshot
   has no title/meta text under it, just actions. */
.gallery-body {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 12px;
  max-height: 60vh;
  overflow-y: auto;
  margin-bottom: 4px;
}
.gallery-empty { color: var(--muted); font-size: 13px; text-align: center; padding: 20px 0; grid-column: 1 / -1; }
.gallery-tile {
  position: relative;
  border-radius: 10px;
  overflow: hidden;
  aspect-ratio: 4 / 3;
  background: var(--panel-2);
  border: 1px solid var(--border);
}
.gallery-tile img { width: 100%; height: 100%; object-fit: cover; display: block; image-rendering: pixelated; }
.gallery-tile-actions {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  transition: opacity 0.12s ease;
}
.gallery-tile:hover .gallery-tile-actions,
.gallery-tile:focus-within .gallery-tile-actions {
  opacity: 1;
}
.gallery-tile-actions button {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.35);
  background: rgba(0, 0, 0, 0.35);
  color: #fff;
  cursor: pointer;
}
.gallery-tile-actions button:hover { border-color: var(--accent); color: var(--accent); }
.gallery-tile-actions button.danger-text:hover { border-color: #ff8c96; color: #ff8c96; }

/* Which system's own games the grid below shows — same thumbnail-button
   shape as .stream-mode-buttons/.stream-mode-btn (a proven "pick one of a
   few clearly-illustrated options" pattern already used for the streaming
   mode picker), just 3 columns instead of that one's fixed-3 too, purely
   by coincidence of NES/SNES/ALL also being 3 options. */
.rom-library-system-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  margin-bottom: 12px;
}
.rom-library-system-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--panel-2);
  color: var(--muted);
  cursor: pointer;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.03em;
}
.rom-library-system-btn img { width: 20px; height: 20px; border-radius: 5px; object-fit: cover; }
.rom-library-system-btn-icon { font-size: 18px; }
.rom-library-system-btn:hover { border-color: var(--accent-2); color: var(--text); }
.rom-library-system-btn.active {
  border-color: var(--accent-2);
  color: var(--text);
  background: color-mix(in srgb, var(--accent-2) 14%, var(--panel-2));
  box-shadow: 0 0 0 1px var(--accent-2);
}

/* The modal card becomes a flex column here (not the fixed-vh guess the
   grid/list used alone before) so #rom-library-grid/#rom-library-list
   always exactly fill whatever's left after the system tabs/toolbars
   above and the count/Load file/Refresh/Close row below — that bottom
   row was getting pushed off the bottom of the window on shorter
   viewports once a 4th system tab (SCUMM) added another row of chrome
   the old fixed 60vh grid height never accounted for. */
#rom-library-modal .modal-card {
  display: flex;
  flex-direction: column;
  max-height: 90vh;
}
#rom-library-modal .rom-library-grid,
#rom-library-modal .rom-library-list {
  flex: 1 1 auto;
  min-height: 0;
}
.rom-library-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
  gap: 12px;
  max-height: 60vh;
  overflow-y: auto;
  margin-bottom: 4px;
}
.rom-library-view-toggle {
  padding: 0;
  width: 34px;
  height: 34px;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* List view (see renderRomLibraryList in app.js) — a compact alphabetical
   alternative to the card grid, with a sticky per-letter header and a
   side A-Z strip you can click or drag along to jump, same shape as any
   contacts-app/media-library list view. */
.rom-library-list { display: flex; gap: 4px; max-height: 60vh; margin-bottom: 4px; }
.rom-library-list-rows { flex: 1; min-width: 0; overflow-y: auto; }
.rom-library-list-letter-header {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--panel);
  color: var(--muted);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  padding: 4px 8px;
  border-bottom: 1px solid var(--border);
}
.rom-library-list-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 6px 8px;
  border: none;
  border-bottom: 1px solid var(--border);
  background: none;
  color: var(--text);
  text-align: left;
  font: inherit;
  cursor: pointer;
}
.rom-library-list-row:hover { background: var(--panel-2); }
.rom-library-list-thumb {
  width: 30px;
  height: 30px;
  border-radius: 5px;
  object-fit: cover;
  flex-shrink: 0;
  background: var(--panel-2);
}
.rom-library-list-row-main { flex: 1; min-width: 0; }
.rom-library-list-row-name {
  font-size: 13px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rom-library-list-row-meta { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rom-library-list-row-rating { flex-shrink: 0; font-size: 15px; line-height: 1; }
.rom-library-list-row-rating.kind-like { color: #f5c518; }
.rom-library-list-row-rating.kind-dislike { color: var(--accent); }

.rom-library-letter-index {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 16px;
  -webkit-user-select: none;
  user-select: none;
  touch-action: none;
  cursor: default;
}
.rom-library-letter-index span {
  font-size: 9px;
  font-weight: 700;
  color: var(--muted);
  line-height: 1;
  padding: 1px 0;
  cursor: pointer;
}
.rom-library-letter-index span.active,
.rom-library-letter-index span:hover { color: var(--accent); }
.rom-library-letter-index span.disabled { color: var(--off); cursor: default; }
.rom-library-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
}
.rom-library-search {
  /* Grows to fill whatever space the two dropdowns (now sized to their
     own content, see .custom-select-wrap below) don't need — the search
     box is the one field worth giving the row's slack to. */
  flex: 1 1 auto;
  min-width: 120px;
  height: 34px;
  align-self: center;
  box-sizing: border-box;
  background: var(--input-bg);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  padding: 7px 10px;
  font-size: 12.5px;
}
/* .modal-card input:not([type='checkbox']):not([type='radio']):not([type='range'])
   (see that rule, further down) carries three :not() clauses — each one's
   argument counts toward specificity, so that selector actually outranks
   a single class like .rom-library-search above despite reading "more
   generic". A class-level override there was silently losing and doing
   nothing at all; only an ID selector (or matching/exceeding that same
   specificity) actually wins. margin-bottom was the first casualty of
   this; width: 100% from that same rule turned out to be a second one —
   with flex-basis: auto (from the search box's own "flex: 1 1 auto"
   above), an explicit width is exactly what auto falls back to, so the
   search box's effective flex-basis became "100% of the row" instead of
   its own content size, forcing it onto its own line with the two
   dropdowns wrapping to a second one instead of sitting beside it. */
#rom-library-search {
  margin-bottom: 0;
  width: auto;
}
/* The actual visible dropdown isn't this element — custom-select.js
   replaces every <select> with a hidden native one plus a sibling
   .custom-select-wrap containing the real button (see its own comment).
   .rom-library-select survives on the now-hidden original purely so
   app.js's plain selectEl.value reads/writes still work; it renders
   nothing and this rule affects nothing visible. The scoped
   .custom-select-wrap rule below is what actually sizes the dropdown. */
.rom-library-select {
  width: 150px;
}
.rom-library-toolbar .custom-select-wrap {
  /* Shrinks to fit whichever option is currently selected, instead of a
     fixed width sized for the worst case — the genre list's own worst
     case ("Construction and Management Simulation") is shortened to a
     display-only "Construction" specifically so this doesn't need to
     accommodate it (see populateGenreOptions in app.js). */
  width: max-content;
  flex-shrink: 0;
  align-self: center;
  margin-bottom: 0;
}
.rom-library-toolbar .custom-select-btn {
  height: 34px;
  box-sizing: border-box;
  padding: 7px 10px;
  font-size: 12.5px;
}
/* custom-select.js's positionList() now sizes every popup to its own
   content (min-width from the button, width: max-content) and positions
   it via position:fixed regardless of which select this is, so the
   narrow-button-wide-popup case this used to handle specially (a narrow
   current selection like "Casino" next to a much longer other option)
   just works the same way everywhere now. */
/* Sits in .modal-actions now, alongside Load file/Refresh/Close — the
   same margin-right: auto trick .danger-text uses there to stay pinned
   to the opposite (left) end while those buttons stay right-aligned. */
.rom-library-count {
  align-self: center;
  margin-right: auto;
  font-size: 12.5px;
  color: var(--muted);
  white-space: nowrap;
}
.rom-library-chip {
  display: inline-flex;
  align-items: center;
  align-self: center;
  gap: 5px;
  height: 34px;
  box-sizing: border-box;
  margin-left: auto;
  font-size: 12.5px;
  color: var(--muted);
  white-space: nowrap;
}
/* The filters row is its own toolbar (search+sort no longer share it) —
   margin-left: auto above was only ever there to push a lone chip to the
   right end of that other row; two chips sharing this row should just
   sit left-aligned with a normal gap between them instead. */
.rom-library-toolbar-filters .rom-library-chip {
  margin-left: 0;
}
.rom-library-card {
  display: flex;
  flex-direction: column;
  position: relative;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
}
.rom-library-card:hover { border-color: var(--accent); }
/* ROM library grid card only now — the chat "loaded a game" card dropped
   the corner entirely in favor of two pill vote buttons at the bottom
   (see .rating-pills-row further down) since long-press here collides
   with the card's own long-press-to-edit-metadata gesture; only a plain
   click (level 1/-1, no love/fucking-hate escalation) works from this
   corner, that's chat-only. Your rating only — the other player's (and,
   in chat, yours too) shows in the badge row below instead, see
   .rating-badges-list. */
/* Narrower than it is tall (44x74, not a square) — a symmetric diagonal
   clipped the second (lower) icon before it fully cleared the cut, since
   the ribbon's hypotenuse runs at the box's own width:height ratio. A
   taller box pushes that diagonal further down for the same rightward
   reach, so the dislike icon — anchored to the right edge same as the
   star above it — clears it properly. */
.rating-corner {
  position: absolute;
  top: 0;
  right: 0;
  width: 44px;
  height: 74px;
  clip-path: polygon(100% 0, 100% 100%, 0 0);
  background: rgba(0, 0, 0, 0.75);
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  justify-content: flex-start;
  gap: 3px;
  padding: 6px 6px 0 0;
}
/* Unrated (is-empty): hidden until the card itself is hovered — a whole
   grid of always-on "vote me" ribbons was too noisy. Once a game has a
   vote, the corner stays visible always so you can see/change it without
   hovering. */
.rating-corner.is-empty { display: none; }
.rom-library-card:hover .rating-corner.is-empty { display: flex; }
.rating-corner-icon { display: flex; cursor: pointer; }
.rating-corner-icon .material-icon { font-variation-settings: 'FILL' 1; }
.rating-corner-icon.outline .material-icon { font-size: 15px; color: rgba(255, 255, 255, 0.55); font-variation-settings: 'FILL' 0; }
.rating-corner-icon.active .material-icon { font-size: 20px; }
.rating-corner-icon.active.kind-like .material-icon { color: #f5c518; }
.rating-corner-icon.active.kind-dislike .material-icon { color: var(--accent); }

/* Facebook-style "who thought what" list under the card — one row per
   player who's actually rated it (in the grid, that's only ever the
   OTHER player — your own is what the corner icon is already for; in
   chat there's no corner at all, so both your own and their row can
   show here, see chat.js's own isMine text). Eats a little height back
   from the cover art above (see .rom-library-card-art/
   .chat-game-card-art-wrap) so a rated card doesn't grow dramatically
   taller than an unrated one. */
.rating-badges-list { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
.rating-badge-row { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); line-height: 1.3; }
/* Plain weight, not bold — this is a sentence ("Ivan loves this"), the
   name shouldn't stand out as if it were its own label. */
/* Plain text, same muted color and weight as the rest of the row's
   sentence — no bold, no brighter color either, both read as emphasis. */
.rating-badge-name { color: inherit; font-weight: 400; }
.rating-badge-avatar { width: 16px; height: 16px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
/* Solid/filled, not the app-wide default outline stroke — a rating is a
   settled fact once shown here, not an inactive control inviting a
   click. */
.rating-badge-icon { font-size: 14px; flex-shrink: 0; font-variation-settings: 'FILL' 1; }
.rating-badge-icon.kind-like { color: #f5c518; }
.rating-badge-icon.kind-dislike { color: var(--accent); }
/* Love's heart is red, not the star's gold — same distinct-from-like
   treatment as the chat pills' .is-love. */
.rating-badge-icon.kind-like.is-love { color: var(--accent); }
.rating-badge-icon-emoji { font-size: 13px; flex-shrink: 0; }

/* Chat-only — the two vote buttons themselves, pinned to the very bottom
   of the card (see appendGameLoadCard in chat.js). */
.rating-pills-row { display: flex; gap: 8px; margin-top: 2px; }
.rating-pill {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  padding: 7px 10px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--muted);
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
}
.rating-pill .material-icon { font-size: 15px; font-variation-settings: 'FILL' 0; }
.rating-pill.active .material-icon { font-variation-settings: 'FILL' 1; }
.rating-pill:hover { border-color: var(--accent); }
.rating-pill.active.kind-like { border-color: #f5c518; color: #f5c518; background: rgba(245, 197, 24, 0.12); }
.rating-pill.active.kind-hate { border-color: var(--accent); color: var(--accent); background: rgba(255, 93, 115, 0.12); }
/* Love (level 2) swaps the thumbs-up for a heart — red, not the like
   pill's usual gold, so it reads as its own distinct level rather than
   a recolored like. */
.rating-pill.active.kind-like.is-love { border-color: var(--accent); color: var(--accent); background: rgba(255, 93, 115, 0.12); }
/* Opposite corner from the favorite star — always visible (not
   hover-only, like the star) since it's warning about something that'll
   actually fail, not a preference. */
.rom-library-unsupported {
  position: absolute;
  top: 6px;
  left: 6px;
  font-size: 20px;
  color: #ff5d73;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}

/* Only rendered in the ALL tab (see app.js's renderRomLibraryGrid) — in a
   single-console tab every card is obviously that console already, so the
   badge would be pure noise. A dedicated wrap (not the card itself) around
   the art is what lets this position reliably at the art's own bottom-left
   corner regardless of how tall the rest of the card grows (rating badges,
   a two-line name, ...) — the card's own position:relative already anchors
   .rom-library-unsupported/.rating-corner at fixed *top* offsets, which
   works for them since they're always flush with the card's top edge, but
   this pill needs to track the art's bottom edge specifically. */
.rom-library-card-art-wrap { position: relative; margin-bottom: 6px; }
.rom-library-card-art-wrap .rom-library-card-art { margin-bottom: 0; }
.rom-library-card-system-pill {
  position: absolute;
  bottom: 6px;
  left: 6px;
  padding: 2px 7px;
  border-radius: 999px;
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.03em;
  color: #fff;
  background: rgba(0, 0, 0, 0.72);
}
.rom-library-card-system-pill.system-snes { background: rgba(23, 169, 141, 0.88); }

/* ---------- Super Benchmark comparison ---------- */

/* grid-auto-flow: column (not auto-fit/minmax) deliberately — auto-fit
   recomputes the column *count* to fit the container, which on a narrow
   phone meant 3 legs collapsing down to 1 column and wrapping into 3
   stacked rows instead of staying side by side. This always lays out
   exactly as many columns as there are result cards (1 for a plain
   Benchmark run, 3 for Super Benchmark), each at least 150px and growing
   to fill available width when there's room — narrower than that, the
   container scrolls horizontally instead of squashing them unreadably. */
.super-benchmark-grid {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(150px, 1fr);
  gap: 12px;
  margin: 8px 0;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
.super-benchmark-col {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 6px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px;
}
.super-benchmark-col h3 { margin: 0; font-size: 13px; text-align: center; }
/* Best-overall badge — only ever one per comparison, so absolute
   top-right placement never needs to dodge another instance of itself. */
.super-benchmark-star {
  position: absolute;
  top: 6px;
  right: 6px;
  font-size: 20px;
  color: #f5c518;
  font-variation-settings: 'FILL' 1;
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
/* A 0-frame run means nothing actually got measured — flagged red rather
   than silently ranking (an all-zero run would otherwise "win" every
   lower-is-better metric outright). */
.super-benchmark-col.super-benchmark-failed {
  border-color: #ff5d73;
}
.super-benchmark-row-error span:last-child { color: #ff8c96; font-weight: 700; }
.super-benchmark-error-note {
  margin: 0;
  font-size: 11px;
  color: #ff8c96;
  text-align: center;
}
.super-benchmark-shot {
  width: 100%;
  aspect-ratio: 256 / 240;
  object-fit: contain;
  background: #000;
  border-radius: 6px;
  cursor: zoom-in;
}
.super-benchmark-shot-empty {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: 12px;
  cursor: default;
}
.super-benchmark-rows { display: flex; flex-direction: column; gap: 2px; }
.super-benchmark-row {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  font-size: 11.5px;
  padding: 3px 6px;
  border-radius: 5px;
  color: var(--muted);
}
.super-benchmark-row span:last-child { color: var(--text); font-variant-numeric: tabular-nums; }
.super-benchmark-winner {
  background: color-mix(in srgb, var(--accent-2) 18%, transparent);
}
.super-benchmark-winner span:last-child { color: var(--accent-2); font-weight: 600; }

/* ---------- Screenshot comparator (opened from a Super Benchmark shot) ---------- */

.screenshot-compare-overlay {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(0, 0, 0, 0.94);
  display: flex;
  flex-direction: column;
}
.screenshot-compare-topbar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 14px;
}
.screenshot-compare-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.screenshot-compare-tab {
  background: var(--panel-2);
  border: 1px solid var(--border);
  color: var(--text);
  border-radius: 8px;
  padding: 6px 10px;
  font-size: 12px;
  cursor: pointer;
}
.screenshot-compare-tab.active { border-color: var(--accent-2); color: var(--accent-2); }
.screenshot-compare-label { color: var(--muted); font-size: 12px; margin-left: auto; }
.screenshot-compare-viewport {
  flex: 1;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  touch-action: none;
}
.screenshot-compare-viewport:active { cursor: grabbing; }
/* pixelated, not smoothed — the whole point is spotting each mode's own
   compression artifacts at the pixel level once zoomed in; the browser's
   default bilinear upscale would blur exactly what this is meant to show. */
.screenshot-compare-img {
  max-width: 90%;
  max-height: 90%;
  image-rendering: pixelated;
  transform-origin: center center;
  user-select: none;
  pointer-events: none;
}
.screenshot-compare-slider { width: min(500px, 90vw); margin: 4px auto 18px; }
.rom-library-card-art {
  width: 100%;
  /* Real box-art scans run ~0.70-0.73 width/height — close to but not
     exactly 5:7. contain guaranteed nothing ever got cut off, but a
     cover with a meaningfully different ratio (e.g. a wide box, or a
     cart label instead of a box) then only filled part of the card's
     width, letterboxed on top/bottom against the fixed panel background
     — reported as looking broken/undersized next to a normal cover in
     the same grid. cover fills the width every time (matching the
     regular case's own look) at the cost of cropping the excess on an
     odd-ratio cover — an acceptable trade since most covers are close
     enough to 5:7 that the crop is barely noticeable anyway. Shortened
     from the real 5:7 to 5:6 — the new rating badge row underneath (see
     .rating-badges-list) adds height back in, so the cover gives a bit
     back to keep a rated card from growing dramatically taller than an
     unrated one. */
  aspect-ratio: 5 / 6;
  border-radius: 6px;
  object-fit: cover;
  /* Crops off the bottom, not the top, when a cover doesn't fit — box
     art almost always has its title logo up top, the bottom (a
     screenshot strip, blank space) is the part that's actually fine to
     lose. */
  object-position: top;
  background: var(--panel);
  margin-bottom: 6px;
}
/* Per-tab card shape (see setRomLibrarySystem in app.js, which sets
   #rom-library-grid's own data-system) — the base 5/6 rule above stays
   NES's shape (its real box-art ratio, ~0.70-0.73, see that rule's own
   comment), since the NES tab's cards are all NES and already suit it
   well. SNES's own real cover scans lean landscape more often than not
   (measured against this app's actual cached covers: ~1.37-1.42 is the
   single largest cluster on both its cover sources — LaunchBox's imports
   and the Libretro auto-fetch this app's own Scan uses going forward) —
   sized to match that instead of forcing SNES's own tab into a box shaped
   for NES's ratio. ALL mixes both systems in one grid, where no single
   box shape suits either natively — split the difference with a plain
   square, per instruction.

   Both non-NES tabs stay on `cover`, not `contain` — an earlier pass
   here used `contain` specifically to never crop a SNES cover whose
   orientation didn't match the box, but that traded cropping for visible
   blank letterbox/pillarbox margin instead, which the user explicitly
   preferred to avoid: better to fill the card and lose part of a
   mismatched cover than show empty space next to a normal one. object-
   position: top left (not NES's own top-center) crops from the bottom-
   right first, since a box's title treatment is more often top-left than
   dead-center on these scans. */
.rom-library-grid[data-system='snes'] .rom-library-card-art,
.rom-library-grid[data-system='all'] .rom-library-card-art {
  object-position: top left;
}
.rom-library-grid[data-system='snes'] .rom-library-card-art { aspect-ratio: 7 / 5; }
.rom-library-grid[data-system='all'] .rom-library-card-art { aspect-ratio: 1 / 1; }
.rom-library-card-name {
  font-size: 12.5px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rom-library-card-meta { font-size: 10.5px; color: var(--muted); margin-top: 2px; }
.avatars-admin-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
  gap: 10px;
  margin-bottom: 12px;
  max-height: 45vh;
  overflow-y: auto;
}
.avatars-admin-tile {
  position: relative;
  border-radius: 10px;
  overflow: hidden;
  cursor: grab;
  aspect-ratio: 1;
}
.avatars-admin-tile.dragging { opacity: 0.4; }
.avatars-admin-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatars-admin-tile-delete {
  position: absolute;
  top: 3px;
  right: 3px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.65);
  color: #fff;
  font-size: 10px;
  cursor: pointer;
  line-height: 1;
}
.avatars-admin-tile-delete:hover { background: var(--accent); }
.avatars-template-row { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.avatars-template-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 12px;
  color: var(--muted);
}
.avatars-template-field input { width: 70px; margin-bottom: 0 !important; }

.rom-library-card-file {
  font-size: 10px;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  margin-top: 1px;
}
/* Grouped-versions picker (see app.js/#rom-versions-modal) */
.rom-versions-art {
  display: block;
  width: 96px;
  height: 96px;
  object-fit: cover;
  border-radius: 8px;
  margin: 0 auto 14px;
}
.rom-versions-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; max-height: 40vh; overflow-y: auto; }

.rom-refresh-duplicates-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 4px; max-height: 40vh; overflow-y: auto; }
.rom-refresh-duplicate-group {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 9px 10px;
  background: var(--panel-2);
}
.rom-refresh-duplicate-keep { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.rom-refresh-duplicate-keep strong { color: var(--text); font-weight: 600; }
.rom-refresh-duplicate-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
.rom-refresh-duplicate-row input[type='checkbox'] { flex-shrink: 0; }
.rom-refresh-duplicate-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Clean Up (see runCleanupAnalysis in app.js, server/rom-cleanup.js) —
   one card per suggested category, collapsible file list with a
   checkbox per file. Mirrors .rom-refresh-duplicate-group's own visual
   language (bordered panel-2 boxes) rather than inventing a new look. */
#rom-cleanup-categories { display: flex; flex-direction: column; gap: 10px; max-height: 45vh; overflow-y: auto; margin-bottom: 4px; }
.rom-cleanup-category { border: 1px solid var(--border); border-radius: 8px; background: var(--panel-2); overflow: hidden; }
.rom-cleanup-category-head { padding: 10px 12px; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; cursor: pointer; }
.rom-cleanup-category-head:hover { background: var(--panel); }
.rom-cleanup-category-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 3px; }
.rom-cleanup-category-title { font-size: 13.5px; font-weight: 600; }
.rom-cleanup-category-count { font-size: 11.5px; color: var(--muted); }
.rom-cleanup-category-note { font-size: 12px; color: var(--muted); margin: 0; max-width: 60ch; }
.rom-cleanup-chip {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: 999px;
  white-space: nowrap;
}
.rom-cleanup-chip.danger { color: var(--accent); border: 1px solid var(--accent); }
.rom-cleanup-chip.warn { color: var(--muted); border: 1px solid var(--border); }
.rom-cleanup-category-actions { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
.rom-cleanup-mark-btn {
  font-size: 11px;
  border-radius: 6px;
  padding: 4px 8px;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--muted);
  cursor: pointer;
  white-space: nowrap;
}
.rom-cleanup-mark-btn.all-marked { background: var(--accent); border-color: var(--accent); color: var(--bg); }
.rom-cleanup-file-list { border-top: 1px solid var(--border); max-height: 220px; overflow-y: auto; display: none; }
.rom-cleanup-category.open .rom-cleanup-file-list { display: block; }
.rom-cleanup-file-row { display: flex; align-items: center; gap: 8px; padding: 5px 12px; font-size: 12px; }
.rom-cleanup-file-row:nth-child(odd) { background: var(--panel); }
.rom-cleanup-file-row input[type='checkbox'] { flex-shrink: 0; }
.rom-cleanup-file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rom-cleanup-file-row.checked .rom-cleanup-file-name { text-decoration: line-through; color: var(--muted); }
.rom-cleanup-file-genre { font-size: 10.5px; color: var(--muted); flex-shrink: 0; }
.rom-cleanup-summary-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 9px 12px;
  background: var(--panel-2);
  font-size: 13px;
}
.rom-cleanup-summary-bar b { font-variant-numeric: tabular-nums; }
.rom-versions-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel-2);
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: var(--text);
  width: 100%;
}
.rom-versions-row:hover { border-color: var(--accent-2); }
.rom-versions-row-file {
  flex: 1;
  min-width: 0;
  font-size: 12.5px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.rom-versions-row-delete {
  flex-shrink: 0;
  color: var(--muted);
  border-radius: 6px;
  padding: 4px;
}
.rom-versions-row-delete:hover { color: #ff8c96; background: rgba(255, 93, 115, 0.12); }
.rom-versions-row-warning { flex-shrink: 0; color: #ff5d73; font-size: 18px; }

/* Long-press-to-edit modal (see app.js/#rom-meta-modal) */
.rom-meta-row { display: flex; gap: 12px; }
.rom-meta-col { flex: 1; min-width: 0; }
.rom-meta-cover-row { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.rom-meta-cover-preview {
  width: 64px;
  height: 64px;
  object-fit: cover;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--panel-2);
  flex-shrink: 0;
}
.chat-game-card-desc {
  font-size: 11.5px;
  color: var(--muted);
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.chat-suggestion {
  border-radius: 14px;
  padding: 8px 14px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}
.chat-suggestion-actions { display: flex; gap: 8px; }

.chat-typing-row { display: flex; align-items: flex-end; gap: 6px; margin-top: 8px; }
.typing-bubble {
  display: flex;
  align-items: center;
  gap: 3px;
  background: var(--panel-2);
  border-radius: 14px;
  border-bottom-left-radius: 4px;
  padding: 9px 12px;
}
.typing-bubble span {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--muted);
  animation: typing-bounce 1.2s infinite ease-in-out;
}
.typing-bubble span:nth-child(2) { animation-delay: 0.15s; }
.typing-bubble span:nth-child(3) { animation-delay: 0.3s; }
@keyframes typing-bounce {
  0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
  30% { transform: translateY(-4px); opacity: 1; }
}

/* Top-aligned, not bottom — the avatar (shown only on a group's first
   row, see .chat-avatar-spacer below) should sit level with the start of
   its group's first line, not the bubble it happens to be drawn next to. */
.chat-row { display: flex; align-items: flex-start; gap: 6px; max-width: 100%; margin-top: 8px; }
.chat-row.mine { justify-content: flex-end; }
.chat-row.theirs { justify-content: flex-start; }
.chat-row.grouped { margin-top: 1px; }
.chat-row.chat-me-row { justify-content: center; }
.chat-row.chat-me-row .bubble-col { align-items: center; }

.chat-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.chat-avatar-spacer { visibility: hidden; }

.bubble-col {
  display: flex;
  flex-direction: column;
  gap: 3px;
  max-width: 80%;
  position: relative;
}
.chat-row.mine .bubble-col { align-items: flex-end; }
.chat-row.theirs .bubble-col { align-items: flex-start; }

.bubble {
  /* A purple tint (echoing the cool half of "mine"'s own gradient below)
     over the plain panel color — flat panel-2 alone barely read as
     different from the page background at all. */
  background: linear-gradient(135deg, rgba(106, 92, 255, 0.3), rgba(106, 92, 255, 0.12)), var(--panel-2);
  border-radius: 14px;
  border-top-left-radius: 4px;
  padding: 7px 10px 6px;
  font-size: 13px;
  line-height: 1.4;
  position: relative;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.chat-row.mine .bubble {
  background: linear-gradient(135deg, rgba(255, 93, 115, 0.35), rgba(106, 92, 255, 0.35));
  border-radius: 14px;
  border-top-right-radius: 4px;
}
/* The pointed corner is the "tail" pointing at the sender's avatar — top
   corner, since the avatar sits level with the TOP of a group's first row
   (.chat-row uses align-items:flex-start), not the bottom. Only the first
   message of a consecutive run has one; grouped follow-ups are a plain
   pill (all four corners rounded), matching the standard messaging-app
   convention. */
.chat-row.theirs.grouped .bubble { border-top-left-radius: 14px; }
.chat-row.mine.grouped .bubble { border-top-right-radius: 14px; }

/* /me: a centered gray pill — narration, not something either side "said",
   but still a real message underneath (repliable/reactable like any
   other), so it reuses .bubble's structure rather than .chat-system's. */
.chat-me-bubble {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 6px 14px;
  text-align: center;
}
.chat-me-bubble .chat-text { color: var(--muted); font-style: italic; }
.chat-me-bubble .chat-text b { font-style: normal; color: var(--text); }
.chat-row.chat-me-row .reactions-row { left: 50%; transform: translateX(-50%); }

.bubble .chat-nick { font-weight: 700; font-size: 12px; display: block; margin-bottom: 1px; }
.chat-row.role-host .bubble .chat-nick { color: var(--accent); }
/* Was --accent-2 (purple) — barely readable once "theirs" bubbles got
   their own purple tint (same hue family). A bright green reads clearly
   against that tint in both themes, without introducing a whole new
   one-off color. */
.chat-row.role-guest .bubble .chat-nick { color: var(--ok); }

.chat-text { color: var(--text); word-wrap: break-word; white-space: pre-wrap; }
.chat-text .mention { color: var(--accent); font-weight: 700; }
.chat-shout-text { font-size: calc(1em * var(--shout-scale, 1)); font-weight: 700; line-height: 1.25; }

.mention-suggestions {
  /* position: fixed + left/top set inline by positionFloatingPanel() */
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 4px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
  z-index: 100;
}
.mention-suggestion-btn {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  border: none;
  background: transparent;
  border-radius: 7px;
  padding: 6px 10px;
  font-size: 13px;
  color: var(--text);
  cursor: pointer;
  text-align: left;
}
.mention-suggestion-btn:hover { background: var(--panel-2); }
.mention-suggestion-avatar { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
.chat-time {
  color: rgba(244, 241, 250, 0.55);
  font-size: 10px;
  float: right;
  margin: 4px 0 -2px 8px;
}

.chat-msg-quote {
  font-size: 11px;
  color: var(--muted);
  background: rgba(0, 0, 0, 0.2);
  border-left: 2px solid var(--accent-2);
  padding: 3px 6px;
  border-radius: 4px;
  margin-bottom: 4px;
}

/* A sibling of .bubble-col within .chat-row now (see appendMessage in
   chat.js), not nested inside it — sits beside the bubble, on whichever
   side has actual open space (right for theirs, left for mine — the
   `order: -1` below is what flips it without needing a different DOM
   append order), instead of stacked in its own row underneath. */
.chat-msg-actions {
  display: flex;
  align-self: center;
  gap: 4px;
  opacity: 0;
  transition: opacity 0.1s;
  flex-shrink: 0;
}
.chat-row.mine .chat-msg-actions { order: -1; }
.chat-row:hover .chat-msg-actions { opacity: 1; }

.msg-action-btn {
  border: none;
  background: var(--panel);
  color: var(--text);
  border-radius: 50%;
  width: 26px;
  height: 26px;
  padding: 0;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.msg-action-btn .material-icon { font-size: 16px; }
.msg-action-btn:hover { background: var(--panel-2); }

.chat-edited-marker { color: rgba(244, 241, 250, 0.4); font-size: 10px; font-style: italic; margin-left: 4px; }

/* WhatsApp-style: a small arrow that only appears on hover, overlapping
   the same bottom-right corner .chat-time sits in — opens a small menu
   (Reply always, Edit/Delete for your own messages) instead of a row of
   separate always-adjacent buttons like .chat-msg-actions above. */
.chat-msg-arrow-wrap {
  /* Also carries .popover-anchor (for positionFloatingPanel to anchor the
     Reply/Edit/Delete menu to) — that class sets position:relative and,
     defined later in this file, was winning the cascade over the
     position:absolute below (same specificity, source order breaks the
     tie). That put this back in normal flow as a real block, adding a
     whole extra line of height to every bubble instead of overlaying the
     timestamp with zero footprint. The compound selector below outranks
     the single-class .popover-anchor rule regardless of source order. */
  position: absolute;
  bottom: 3px;
  right: 6px;
  opacity: 0;
  transition: opacity 0.1s;
  z-index: 2;
}
.chat-msg-arrow-wrap.popover-anchor { position: absolute; }
.chat-row:hover .chat-msg-arrow-wrap { opacity: 1; }
.chat-msg-arrow {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  padding: 0;
  border: none;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.4);
  color: #fff;
  cursor: pointer;
}
.chat-msg-arrow:hover { background: rgba(0, 0, 0, 0.65); }

.chat-msg-menu {
  /* position: fixed + left/top are set inline by positionFloatingPanel() */
  display: flex;
  flex-direction: column;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 4px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
  z-index: 100;
  min-width: 120px;
}
.chat-msg-menu-item {
  border: none;
  background: none;
  color: var(--text);
  text-align: left;
  font-size: 13px;
  padding: 7px 10px;
  border-radius: 6px;
  cursor: pointer;
}
.chat-msg-menu-item:hover { background: var(--panel-2); }
.chat-msg-menu-item-danger { color: var(--accent); }

/* WhatsApp-style: a small circular badge tucked into the bubble's bottom
   corner instead of a full-width row that pushes the bubble taller. Hangs
   mostly *below* the bubble (larger negative bottom) rather than deeply
   overlapping its text — important for short, single-line messages where
   the bubble is barely taller than the badge itself. Also offset toward
   the corner (both axes) and toward the center of the chat, not the outer
   screen edge, so it's never clipped by the panel edge. */
.reactions-row {
  position: absolute;
  bottom: -14px;
  display: flex;
  gap: 3px;
  z-index: 1;
}
.chat-row.theirs .reactions-row { right: -6px; }
.chat-row.mine .reactions-row { left: -6px; }
/* The badge above hangs ~14px below the bubble regardless of grouping —
   with grouped rows now only 1px apart, a message that actually has
   reactions would otherwise get its own badge overlapped by whatever
   comes right after it. Only applied when there's something in the row
   to make room for, so the common (no reactions) case stays as tightly
   grouped as intended. */
.chat-row:has(.reactions-row:not(:empty)) { margin-bottom: 16px; }

.reaction-chip {
  min-width: 19px;
  height: 19px;
  padding: 0 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: var(--panel);
  border-radius: 999px;
  font-size: 11px;
  cursor: pointer;
  color: var(--text);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
/* Ring via box-shadow (not border) so it doesn't nudge the chip's size. */

.reply-preview {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 12px;
  border-top: 1px solid var(--border);
  font-size: 12px;
  color: var(--muted);
}
.reply-preview button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 13px; }

.chat-form { border-top: 1px solid var(--border); padding: 10px; }

.chat-input-wrap {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  background: var(--input-bg);
  border: 1px solid var(--border);
  border-radius: 22px;
  padding: 4px 4px 4px 10px;
  transition: border-color 0.15s ease;
}
.chat-input-wrap:focus-within { border-color: var(--accent); }

.chat-input-wrap textarea {
  flex: 1;
  resize: none;
  padding: 8px 4px;
  border: none;
  background: transparent;
  color: var(--text);
  font-size: 13px;
  font-family: inherit;
  line-height: 1.4;
  max-height: 140px;
  overflow-y: auto;
  outline: none;
  /* max-height only actually kicks in for unusually long messages — the
     scrollbar track itself shouldn't be a permanent fixture of the pill
     for normal short messages (visible as a thin bar on mobile). Still
     scrollable when it's genuinely needed, just not visible. */
  scrollbar-width: none;
}
.chat-input-wrap textarea::-webkit-scrollbar { display: none; }
/* Holding the send button grows this live (see chat.js) — no transition
   here on purpose, the growth itself is already animated frame-by-frame.
   height:auto (overriding the pinned inline px height autosizeInput set
   for the pre-hold font size) lets the box's natural single-row height
   track the growing font symmetrically instead of staying clamped to the
   old small size with the bigger text spilling out toward the bottom
   (chat-input-wrap's align-items: flex-end); centering the wrap itself
   while held keeps that growth visually centered in the field. */
.chat-input-wrap textarea.shout-growing {
  font-size: calc(13px * var(--shout-scale, 1));
  font-weight: 700;
  height: auto !important;
}
.chat-input-wrap:has(textarea.shout-growing) { align-items: center; }

.send-btn {
  width: 32px;
  height: 32px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  margin-bottom: 2px;
  border: none;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  color: white;
  cursor: pointer;
  /* Without this, a real finger's tiny natural drift during a long
     press-and-hold (see the shout effect in chat.js) can read as a scroll
     attempt on mobile, which cancels the touch early — capping how big the
     shout could grow well short of holding a mouse down on desktop. */
  touch-action: none;
}
.send-btn:hover { filter: brightness(1.08); }

.emoji-toggle-btn {
  border: none;
  background: transparent;
  font-size: 17px;
  padding: 6px;
  margin-bottom: 2px;
  cursor: pointer;
  flex-shrink: 0;
}
.emoji-toggle-btn:hover { transform: scale(1.1); }

.popover-anchor { position: relative; }

/* ---------- Emoji pickers & reaction popover ---------- */

.emoji-picker-full { width: 280px; }

.emoji-picker-search {
  width: 100%;
  padding: 7px 10px;
  margin-bottom: 6px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 12px;
}

.emoji-picker-tabs {
  display: flex;
  gap: 2px;
  margin-bottom: 6px;
  border-bottom: 1px solid var(--border);
  padding-bottom: 6px;
}
.emoji-picker-tab {
  flex: 1;
  min-width: 0;
  border: none;
  background: transparent;
  font-size: 16px;
  padding: 4px;
  border-radius: 6px;
  cursor: pointer;
  opacity: 0.5;
}
.emoji-picker-tab:hover { background: var(--panel-2); opacity: 0.8; }
.emoji-picker-tab.active { background: var(--panel-2); opacity: 1; }

/* Every category rendered at once, in document order, as a single
   normally-scrolling list — .emoji-picker-tabs above is a jump-to-
   section + scroll-spy affordance on top of that (see emoji.js), not a
   replacement for real scrolling the way the old per-category-swap
   version was. */
.emoji-picker-grid {
  max-height: 240px;
  overflow-y: auto;
  overflow-x: hidden;
  color: var(--muted);
  font-size: 12px;
}
.emoji-picker-section-label {
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  padding: 6px 2px 4px;
}
.emoji-picker-section:first-child .emoji-picker-section-label { padding-top: 2px; }
.emoji-picker-section-grid {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: 2px;
}

.emoji-picker-btn {
  min-width: 0;
  border: none;
  background: transparent;
  font-size: 18px;
  padding: 4px;
  cursor: pointer;
  border-radius: 6px;
}
.emoji-picker-btn:hover { background: var(--panel-2); }

.composer-emoji-picker,
.reaction-picker {
  /* position: fixed + left/top are set inline by positionFloatingPanel() */
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 8px;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
  z-index: 100;
}

.reaction-picker.expanded { padding: 10px; }

.quick-reactions { display: flex; gap: 2px; }
.quick-reaction-btn {
  border: none;
  background: transparent;
  border-radius: 8px;
  font-size: 19px;
  line-height: 1;
  padding: 5px 7px;
  cursor: pointer;
}
.quick-reaction-btn:hover { background: var(--panel-2); transform: scale(1.15); }
.quick-reaction-more {
  color: var(--muted);
  font-size: 16px;
  font-weight: 700;
  border-left: 1px solid var(--border);
  border-radius: 0 8px 8px 0;
  margin-left: 2px;
  padding-left: 9px;
}

.custom-emoji-row { margin-top: 6px; }
.custom-emoji-row input {
  width: 100%;
  padding: 6px 8px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 12px;
}

/* ---------- Modals ---------- */

.modal {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 50;
}

/* The card itself never scrolls — every list/grid it can contain
   (rom-library-grid, states-body, history-body, ...) already caps its own
   height and scrolls internally; a second scrollbar on the outer card was
   pure redundancy (and confusing — two nested scroll targets under the
   same mouse position). max-height is a loose outer ceiling for the
   pathological case, not the normal mechanism — the card is free to grow
   taller than its "usual" size to fit short content, it just never gets a
   scrollbar of its own. */
.modal-card {
  width: 420px;
  max-height: 90vh;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 24px;
}
.modal-card.wide { width: min(600px, 92vw); padding: 28px 32px; }
#keys-body { max-height: 55vh; overflow-y: auto; }

.modal-card h2 { margin: 0 0 16px; font-size: 17px; }

.modal-card label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
/* .modal-card label's element+class selector otherwise outranks
   .rom-library-chip's own single-class one, silently forcing it from
   inline-flex to block — the checkbox and its "2 Players" text ended up
   stacked/misaligned instead of sitting on one line together. font-size
   needs the same override (12px from .modal-card label was winning over
   .rom-library-chip's own 12.5px) — a small enough difference on its own,
   but combined with the checkbox's un-reset default UA margin (see
   below), it was enough to visibly throw off vertical alignment against
   the search box/dropdown next to it. */
.rom-library-toolbar .rom-library-chip { display: inline-flex; margin-bottom: 0; font-size: 12.5px; }
.rom-library-chip input[type='checkbox'] { margin: 0; flex-shrink: 0; }
/* Checkbox/radio/range excluded — this text-input box styling (full
   width, padding, a 14px bottom margin) was also landing on checkboxes
   like .rom-library-chip's, stretching/padding them into a taller box
   than their "2 Players" label text sat in, which is what actually broke
   the vertical centering between the two. */
.modal-card input:not([type='checkbox']):not([type='radio']):not([type='range']),
.modal-card select {
  width: 100%;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 14px;
}
/* Kept as a baseline even though every <select> is now replaced by
   CustomSelect (custom-select.js) — belt-and-suspenders in case a select
   ever ends up rendered before that script runs. */
.modal-card select option {
  background: var(--panel-2);
  color: var(--text);
}

.custom-select-wrap { position: relative; margin-bottom: 14px; }
select.custom-select-native { display: none; }
.custom-select-btn {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  font-family: inherit;
  cursor: pointer;
  text-align: left;
}
.custom-select-btn:hover { border-color: var(--accent); }
.custom-select-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.custom-select-btn-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.custom-select-btn svg { flex-shrink: 0; opacity: 0.7; }
/* position:fixed, sized/placed by custom-select.js's own positionList()
   at open time (top/left/min-width/max-height are all set inline) —
   appended straight to <body>, not nested under its .custom-select-wrap,
   so a select inside a scrolling container (a long list in a modal, say)
   can't get its popup clipped by that container's own overflow. */
.custom-select-list {
  position: fixed;
  z-index: 200;
  width: max-content;
  overflow-y: auto;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
  padding: 4px;
}
.custom-select-option {
  padding: 8px 10px;
  border-radius: 6px;
  font-size: 13px;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.custom-select-option:hover { background: var(--panel); }
.custom-select-option.selected { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }

.avatar-grid-label { margin-top: 4px; }

.theme-toggle-row { display: flex; gap: 8px; margin: 0 0 16px; }
.theme-toggle-row .theme-option-btn { flex: 1; }
.settings-menu-list { display: flex; flex-direction: column; gap: 8px; margin: 0 0 16px; }
/* justify-content: flex-start overrides .ghost-btn's own centered default
   — text-align: left alone doesn't do it, since the icon+label here are
   flex children (justify-content governs their distribution), not text
   content flowing inside the button. Without it the icon+label float as
   a centered group instead of sitting flush left like a normal menu row. */
.settings-menu-list .ghost-btn {
  width: 100%;
  text-align: left;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 10px;
}
.settings-menu-item-icon { flex-shrink: 0; }

.ghost-btn.danger-text { color: #ff8c96; border-color: rgba(255, 93, 115, 0.4); }
.ghost-btn.danger-text:hover { border-color: var(--accent); background: rgba(255, 93, 115, 0.1); }
.main-menu-item.danger-text { color: #ff8c96; }
.main-menu-item.danger-text:hover { background: rgba(255, 93, 115, 0.12); }

.avatar-carousel {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 14px;
}

.avatar-grid.avatar-grid-locked { opacity: 0.5; pointer-events: none; }

.avatar-grid {
  flex: 1;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
}
.avatar-option {
  border: 2px solid transparent;
  border-radius: 12px;
  padding: 3px;
  background: var(--panel-2);
  cursor: pointer;
  aspect-ratio: 1;
  /* Grid items default to min-width:auto, which for a cell containing an
     <img> resolves to the image's intrinsic size (256px) regardless of its
     width:100% — without this the track can't actually shrink to fit, so
     the grid silently overflows its own box (bleeding out to the right,
     crowding the right-hand carousel arrow). */
  min-width: 0;
}
.avatar-option img { width: 100%; height: 100%; border-radius: 10px; object-fit: cover; }
.avatar-option.selected { border-color: var(--accent); }
.avatar-option:hover { border-color: var(--accent-2); }

.avatar-nav-btn {
  flex-shrink: 0;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--text);
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.avatar-nav-btn:hover { border-color: var(--accent); }
.avatar-nav-btn.is-hidden { visibility: hidden; }
.avatar-nav-btn svg { display: block; }

.preset-row { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.keys-tabs { align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.keys-console-tab {
  min-width: 92px;
  min-height: 40px;
  font-size: 14px;
  font-weight: 700;
  border-color: color-mix(in srgb, var(--accent-2) 55%, var(--border));
  background: color-mix(in srgb, var(--accent-2) 8%, transparent);
}
.keys-console-tab.active {
  border-color: var(--accent-2);
  color: var(--text);
  background: color-mix(in srgb, var(--accent-2) 18%, var(--panel-2));
  box-shadow: 0 0 0 1px var(--accent-2);
}
.keys-secondary-tab { min-height: 36px; padding: 8px 12px; }
.keys-reset-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 14px; }
.keys-gamepad-toggle {
  width: 100%;
  min-height: 40px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.keys-gamepad-toggle:hover { border-color: var(--accent-2); }

/* ---------- Keys table ---------- */

.key-table { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.key-table-section-heading { font-size: 13px; color: var(--muted); margin: 18px 0 8px; font-weight: 600; }

.settings-code-block { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
/* Shared "info note" look for every settings hint/note paragraph in the
   app — a dashed box with a leading info icon instead of bare muted text
   floating right under whatever table/list precedes it. Purely a restyle
   of the existing class (all ~12 usages are plain <p class="settings-code-hint">
   text, no nested markup) — no HTML changes needed anywhere it's used.
   The icon is a ::before pseudo-element rather than a real DOM node for
   exactly that reason: one CSS change instead of touching every call site. */
.settings-code-hint {
  font-size: 11px;
  color: var(--muted);
  line-height: 1.5;
  margin: 14px 0 10px;
  padding: 10px 12px 10px 34px;
  border: 1px dashed var(--border);
  border-radius: 8px;
  position: relative;
}
.settings-code-hint::before {
  content: 'info';
  font-family: 'Material Symbols Outlined';
  font-size: 16px;
  line-height: 1;
  position: absolute;
  left: 10px;
  top: 10px;
  color: var(--muted);
}

.settings-bullets {
  font-size: 11px;
  color: var(--muted);
  line-height: 1.5;
  margin: 0 0 10px;
  padding-left: 16px;
}
.settings-bullets li { margin-bottom: 2px; }
.settings-bullets b { color: var(--text); }

/* Three-way streaming mode picker — thumbnail buttons instead of a plain
   dropdown, so the actual difference between engines (video call vs
   raw pixels vs two synced emulators) reads at a glance. The real state
   still lives on the hidden #stream-mode-select right below this (kept
   in sync by app.js) — every other piece of code that reads/writes the
   Streaming mode setting is completely unaware this exists. */
.stream-mode-buttons {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  margin-bottom: 10px;
}
.stream-mode-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 12px 8px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--panel-2);
  color: var(--muted);
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
}
.stream-mode-btn:hover { border-color: var(--accent-2); color: var(--text); }
.stream-mode-btn.active {
  border-color: var(--accent-2);
  color: var(--text);
  background: color-mix(in srgb, var(--accent-2) 14%, var(--panel-2));
  box-shadow: 0 0 0 1px var(--accent-2);
}
.stream-mode-icon { width: 32px; height: 32px; }
.settings-section-divider { height: 1px; background: var(--border); margin: 4px 0 14px; }
.pixelstream-settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
#streaming-modal .modal-card { max-height: 90vh; overflow-y: auto; }
@media (max-width: 560px) {
  .pixelstream-settings-grid { grid-template-columns: 1fr; }
}
/* custom-select.js enhances every <select> on the page unconditionally,
   wrapping it in a *visible* .custom-select-wrap sibling regardless of
   any class on the original element — #stream-mode-select's own .hidden
   only hid the native element it wraps, not that new sibling, which
   would otherwise show a redundant dropdown right next to the buttons
   above. Scoped to this one section; no other select lives in it. */
#stream-mode-section .custom-select-wrap { display: none; }

.modal-card .settings-toggle-row {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  color: var(--text);
  margin-bottom: 4px;
}
.settings-toggle-row input[type='checkbox'] {
  width: 16px;
  height: 16px;
  padding: 0;
  margin: 0;
  border: none;
  background: none;
  accent-color: var(--accent);
}
.settings-code-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.profile-nickname-gender-row { display: flex; gap: 12px; }
.profile-nickname-gender-col { flex: 1; min-width: 0; }
.settings-code-row input {
  flex: 1;
  margin-bottom: 0;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 12px;
}
.settings-export-status { font-size: 11px; color: var(--ok); }

.key-table-row {
  display: grid;
  grid-template-columns: minmax(170px, 1fr) 76px 148px;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border);
}
.key-table-row:last-child { border-bottom: none; }

.key-table-header {
  background: var(--panel-2);
  font-size: 11px;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 6px 12px;
}

.key-table-label { font-size: 13px; }
.reaction-key-table .key-table-label { white-space: normal; overflow-wrap: anywhere; }
.key-table-actions { display: flex; justify-content: flex-end; gap: 6px; }
.key-table-actions .ghost-btn { min-width: 68px; padding-left: 8px; padding-right: 8px; }

.key-chip {
  text-align: center;
  font-family: 'Consolas', monospace;
  font-size: 12px;
  color: var(--accent-2);
  background: var(--input-bg);
  border-radius: 6px;
  padding: 4px 8px;
}

/* A rejected rebind (see app.js's buildKeyTable/renderReactionsKeyTable
   collision check) — same pulse keyframe taunt-glow-insult already uses,
   just on the key chip itself instead of a taunt row, red for "no". */
.key-chip.key-chip-error { --glow-color: rgba(255, 93, 115, 0.6); animation: taunt-glow-pulse 1.2s ease-out; color: #ff5d73; }

.modal-actions { margin-top: 16px; display: flex; justify-content: flex-end; gap: 8px; }
.modal-actions .primary-btn,
.modal-actions .ghost-btn { width: auto; }
.modal-actions .danger-text { margin-right: auto; }
#profile-modal .modal-actions { justify-content: center; }

/* ---------- Scrollbars ---------- */

::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; }

/* A scrollbar renders flush against its own element's edge — for these
   modal-body lists that edge was the same as .modal-card's inner content
   edge, so the thumb sat right up against card/row content instead of
   out in the card's own 24-32px padding where there's nothing to
   collide with. Shifting each list outward by half its modal's padding
   (negative margin) and giving back the same amount as its own padding
   (so the content inside doesn't actually move) lands the scrollbar
   right in the middle of that gap — halfway between content and the
   modal's outer edge, same idea as .modal-card.wide's larger padding
   just getting a bigger offset to match. */
.modal-card.wide #keys-body,
.modal-card.wide .history-body,
.modal-card.wide .states-body,
.modal-card.wide .gallery-body,
.modal-card.wide .rom-library-grid,
.modal-card.wide .reactions-body,
.modal-card.wide .reaction-sounds-list,
.modal-card.wide .reaction-effects-list,
.modal-card.wide .rage-quits-body,
.modal-card.wide .rage-quit-reasons-body,
.modal-card.wide .avatars-admin-grid,
/* The actual scroll containers now (see the "Taunts broadcaster — keep the
   footer visible" rule), not .profanity-words-list/.reaction-tune-list
   themselves any more — same flush-scrollbar trick, just moved up one
   level to match where overflow-y:auto actually lives now. */
.modal-card.wide #taunt-tab-texts,
.modal-card.wide #taunt-tab-reactions {
  margin-right: -16px;
  padding-right: 16px;
}
.modal-card:not(.wide) .rom-versions-list,
.modal-card:not(.wide) .rage-quit-reason-list {
  margin-right: -12px;
  padding-right: 12px;
}

/* ---------- Player profile page (web/profile.html + web/js/profile.js) ----------
   A real, normally-scrolling page — the base html/body's overflow:hidden
   (an element-only selector) assumes the fixed-height single-screen game
   view, which doesn't apply here; a single class selector already
   outranks it without needing anything fancier. */
.profile-page { overflow: auto; overscroll-behavior: auto; }

.profile-root { max-width: 960px; margin: 0 auto; padding: 32px 24px 64px; }
.profile-loading { text-align: center; color: var(--muted); margin-top: 60px; font-size: 14px; }

.profile-hero {
  display: flex;
  align-items: center;
  gap: 20px;
  margin-bottom: 28px;
  padding-bottom: 24px;
  border-bottom: 1px solid var(--border);
}
.profile-hero-avatar {
  width: 120px;
  height: 120px;
  border-radius: 50%;
  object-fit: cover;
  border: 3px solid var(--border);
  background: var(--input-bg);
}
.profile-hero-name { margin: 0 0 6px; font-size: 28px; }
.profile-hero-meta { margin: 0; color: var(--muted); font-size: 13px; }

.profile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 16px;
  margin-bottom: 28px;
}
.profile-section {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 16px 18px;
  /* Column flex so a scrollable child (.profile-scroll-list) can use
     flex:1 to claim exactly whatever's left after the title — the grid
     row's own auto-stretch (every section in a row matches the tallest
     one) is what actually gives this a real height to divide up. */
  display: flex;
  flex-direction: column;
  /* Same fix as .profile-favorite-card, one level up: a grid item's
     default min-width is "auto" (its own content's intrinsic minimum),
     not the track's actual width. The embedded chat panel forces
     .profile-grid down to a single 1fr column (narrow), and without
     this the favorites card grid inside blew this section — and the
     whole panel — wider than the chat sidebar itself, live-measured:
     376px section in a 318px-wide panel body. */
  min-width: 0;
}
.profile-section-title {
  margin: 0 0 10px;
  font-size: 13px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}
.profile-empty-note { margin: 0; color: var(--muted); font-size: 12.5px; }

.profile-game-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  font-size: 13px;
  border-bottom: 1px solid var(--border);
}
.profile-game-row:last-child { border-bottom: none; }
/* flex:1 (not a fixed max-height) - claims whatever room the grid's own
   row-stretch actually gave this section, however many rows that turns
   out to be, then scrolls for the rest. min-height:0 overrides flex's
   default min-height:auto, which would otherwise refuse to shrink this
   below its content size and defeat the scroll entirely. */
/* max-height is the real cap — flex:1/min-height:0 alone only bounds this
   when CSS Grid's row-stretch happens to hand the row a real height (a
   taller sibling section in the same row), which isn't guaranteed (a lone
   or tallest-in-its-row section just grows to fit all its content,
   unbounded — confirmed live: "Worst games ever" scrolling forever on the
   full profile page). flex:1 still applies on top of this, so a row that
   IS shorter than this cap still shrinks the list to that row's real
   height instead of always sitting at the max. */
.profile-scroll-list { flex: 1; min-height: 0; max-height: 340px; overflow-y: auto; }
.profile-game-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Favorite games get an actual card grid (cover + name), same shape as
   the ROM library's own cards — everything else in this .profile-grid
   is a plain text row, but a rating is as much about a game's box art as
   its name. */
.profile-favorites-grid {
  display: grid;
  /* Fixed at 2 columns (not auto-fill) — 2x2 visible (2 wide, 2 tall),
     everything past that scrolls vertically instead of the whole card
     growing tall with a long favorites list. max-height is set in JS
     (see profile.js's capFavoritesGridRows) from the actual rendered
     card height, not hardcoded here — that height depends on how wide
     this section renders, which differs between the standalone tab and
     the narrower embedded chat-panel view. */
  grid-template-columns: repeat(2, 1fr);
  gap: 10px;
  overflow-y: auto;
}
.profile-favorite-card {
  position: relative;
  /* Without this, a grid item's default min-width is "auto" - its own
     content's intrinsic minimum, which for a nowrap-ellipsis name (see
     .profile-favorite-card-name) is the FULL unwrapped text width, not
     the visually clipped one. A long game name silently blew this
     column out way past its 1fr share (280px + 189px inside a 255px
     grid, measured live) while every short-named game happened to fit
     fine - the classic CSS grid/flexbox "long nowrap text ignores its
     track size" trap. */
  min-width: 0;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 6px;
}
.profile-favorite-card-art {
  width: 100%;
  aspect-ratio: 5 / 6;
  border-radius: 6px;
  object-fit: cover;
  /* Crops from the bottom, not the top — same reasoning as the ROM
     library's own cover art: box art nearly always has its title logo
     up top, the bottom is the more expendable part to lose. Also a
     blank fallback background until enhanceCardsWithCovers fills in the
     real box art (or forever, for a game with none) — never a
     broken-image icon. */
  object-position: top;
  background: var(--input-bg);
  margin-bottom: 6px;
  display: block;
}
.profile-favorite-card-name {
  font-size: 11.5px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Same corner-badge idea as the ROM library grid's own rating icon —
   just simpler (a plain circle, not the ribbon shape) since this is a
   read-only display, not a click target. */
.profile-favorite-card .profile-rating-icon {
  position: absolute;
  top: 10px;
  right: 10px;
  background: rgba(0, 0, 0, 0.65);
  border-radius: 50%;
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px !important;
}
.profile-game-meta { color: var(--muted); font-size: 11px; flex-shrink: 0; }
.profile-rating-icon { font-size: 16px; flex-shrink: 0; font-variation-settings: 'FILL' 1; }
.profile-rating-icon.is-love { color: var(--accent); }
.profile-rating-icon.is-like { color: #f5c518; }
.profile-rating-icon.is-hate,
.profile-rating-icon.is-fucking-hate { color: var(--accent); }

.profile-reaction-row,
.profile-insult-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 0;
  font-size: 13px;
  border-bottom: 1px solid var(--border);
}
.profile-reaction-row:last-child,
.profile-insult-row:last-child { border-bottom: none; }
.profile-reaction-emoji { font-size: 20px; }
.profile-reaction-count,
.profile-insult-count { color: var(--muted); font-size: 12px; }
.profile-insult-word { text-transform: lowercase; }

.profile-match-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 0;
  font-size: 13px;
  border-bottom: 1px solid var(--border);
  border-left: 3px solid transparent;
  padding-left: 8px;
}
.profile-match-row:last-child { border-bottom: none; }
.profile-match-row.is-win { border-left-color: var(--ok); }
.profile-match-row.is-loss { border-left-color: var(--accent); }
.profile-match-info { flex: 1; min-width: 0; }
.profile-match-game { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.profile-match-date { color: var(--muted); font-size: 11px; margin-top: 1px; }
.profile-match-score { font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.profile-match-vs { color: var(--muted); font-size: 11.5px; flex-shrink: 0; }

.profile-gallery-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
  gap: 10px;
}
.profile-gallery-tile {
  position: relative;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
  cursor: pointer;
  background: var(--input-bg);
  aspect-ratio: 4 / 3;
}
.profile-gallery-tile:hover { border-color: var(--accent); }
.profile-gallery-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
.profile-gallery-like-badge {
  position: absolute;
  bottom: 6px;
  right: 6px;
  display: flex;
  align-items: center;
  gap: 3px;
  background: rgba(0, 0, 0, 0.65);
  color: #fff;
  font-size: 11px;
  padding: 3px 7px;
  border-radius: 999px;
}
.profile-gallery-like-badge .material-icon { font-size: 13px; font-variation-settings: 'FILL' 1; color: var(--accent); }

.profile-lightbox {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.75);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 60;
  padding: 24px;
}
.profile-lightbox-inner {
  position: relative;
  display: flex;
  gap: 0;
  max-width: 1000px;
  width: 100%;
  max-height: 90vh;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 16px;
  overflow: hidden;
}
.profile-lightbox-close {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 2;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.profile-lightbox-close:hover { background: var(--accent); }
.profile-lightbox-img { flex: 1 1 60%; min-width: 0; object-fit: contain; background: #000; max-height: 90vh; }
.profile-lightbox-side {
  flex: 0 0 300px;
  display: flex;
  flex-direction: column;
  padding: 16px;
  border-left: 1px solid var(--border);
  min-height: 0;
}
.profile-like-btn {
  display: flex;
  align-items: center;
  gap: 6px;
  align-self: flex-start;
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  cursor: pointer;
  margin-bottom: 12px;
  flex-shrink: 0;
}
.profile-like-btn .material-icon { font-size: 16px; font-variation-settings: 'FILL' 0; }
.profile-like-btn.is-liked { border-color: var(--accent); color: var(--accent); }
.profile-like-btn.is-liked .material-icon { font-variation-settings: 'FILL' 1; }
.profile-comments-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.profile-comment-row { display: flex; align-items: flex-start; gap: 8px; font-size: 12.5px; }
.profile-comment-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.profile-comment-body { flex: 1; min-width: 0; overflow-wrap: break-word; }
.profile-comment-name { font-weight: 600; }
.profile-comment-form { display: flex; gap: 6px; flex-shrink: 0; }
.profile-comment-form input {
  flex: 1;
  min-width: 0;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 0;
}

@media (max-width: 640px) {
  .profile-lightbox-inner { flex-direction: column; max-height: 95vh; }
  .profile-lightbox-img { max-height: 45vh; }
  .profile-lightbox-side { flex: 1; border-left: none; border-top: 1px solid var(--border); }
}

/* Any username on the profile page (comment authors, match opponents,
   match-end players) — a plain inline link to that profile. */
.profile-name-link { color: var(--accent-2); cursor: pointer; font-weight: 600; }
.profile-name-link:hover { text-decoration: underline; }

/* ---------- Biography / activity feed ---------- */

.activity-feed { display: flex; flex-direction: column; gap: 14px; }
.activity-card {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 12px 14px;
  transition: box-shadow 0.3s, border-color 0.3s;
}
/* Landed here from a notification click (see profile.js's renderProfile
   scrollToPostId handling) — a brief glow so the right card is obvious in
   a long feed, not just scrolled to and left indistinguishable. */
.activity-card.activity-card-highlight {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent);
}
.activity-header { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 13.5px; }
.activity-header-label { display: flex; align-items: center; gap: 6px; }
.activity-header .material-icon { font-size: 17px; color: var(--accent-2); }
/* Facebook-style "..." menu — own posts only, see buildOwnPostMenu. */
.activity-post-menu { margin-left: auto; }
.activity-post-menu-btn {
  border: none;
  background: none;
  color: var(--muted);
  font-size: 16px;
  font-weight: 700;
  cursor: pointer;
  padding: 2px 6px;
  border-radius: 6px;
  line-height: 1;
}
.activity-post-menu-btn:hover { background: var(--panel); color: var(--text); }
.activity-post-menu-list {
  position: absolute;
  top: 100%;
  right: 0;
  z-index: 10;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
  padding: 4px;
  min-width: 100px;
}
.activity-post-menu-item {
  display: block;
  width: 100%;
  border: none;
  background: none;
  color: var(--accent);
  text-align: left;
  font-size: 13px;
  padding: 7px 10px;
  border-radius: 6px;
  cursor: pointer;
}
.activity-post-menu-item:hover { background: var(--panel-2); }
.activity-actions-row { display: flex; gap: 8px; margin-bottom: 4px; }
.activity-comments-toggle { border-top: 1px solid var(--border); padding-top: 8px; margin-top: 4px; }
.activity-meta { font-size: 11px; color: var(--muted); margin: 2px 0 8px; }
.activity-cover-art {
  width: 100%;
  max-height: 320px;
  object-fit: contain;
  border-radius: 8px;
  background: var(--input-bg);
  margin-bottom: 8px;
  display: block;
}
.activity-match-score { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 8px; }
.activity-match-players { display: flex; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.activity-match-player {
  display: flex;
  align-items: center;
  gap: 6px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 4px 10px 4px 4px;
  font-size: 12.5px;
}
.activity-match-player.is-winner { border-color: #f5c518; }
.activity-match-player-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
.activity-match-player-insults { color: var(--accent); font-size: 11px; }

.activity-like-btn {
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  border-radius: 8px;
  padding: 5px 10px;
  font-size: 12.5px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-bottom: 8px;
}
.activity-like-btn .material-icon { font-size: 15px; }
.activity-like-btn.is-liked { border-color: var(--accent); color: var(--accent); }
.activity-like-btn.is-liked .material-icon { font-variation-settings: 'FILL' 1; }

.activity-comments-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
.activity-comment-row { display: flex; align-items: flex-start; gap: 8px; font-size: 12.5px; }
.activity-comment-row.is-reply { margin-left: 30px; }
.activity-comment-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; flex-shrink: 0; margin-top: 2px; }
.activity-comment-body { flex: 1; min-width: 0; }
/* A real chat bubble, not a plain text row — same tail/tint idea as the
   actual chat's own "theirs" bubble (see .bubble in the chat section),
   scoped separately since this needs to work standalone (not inside a
   .chat-row) and never needs the mine/theirs variant. */
.activity-comment-bubble {
  display: inline-block;
  max-width: 100%;
  background: linear-gradient(135deg, rgba(106, 92, 255, 0.3), rgba(106, 92, 255, 0.12)), var(--panel-2);
  border-radius: 14px;
  border-top-left-radius: 4px;
  padding: 6px 10px;
  overflow-wrap: break-word;
}
.activity-comment-name { display: block; font-weight: 700; color: var(--ok); margin-bottom: 1px; }
.activity-comment-actions { display: flex; gap: 10px; margin-top: 2px; }
.activity-comment-like-btn,
.activity-comment-reply-btn {
  border: none;
  background: none;
  color: var(--muted);
  font-size: 11px;
  cursor: pointer;
  padding: 0;
}
.activity-comment-like-btn:hover,
.activity-comment-reply-btn:hover { color: var(--text); }
.activity-comment-like-btn.is-liked { color: var(--accent); }
.activity-comment-form { display: flex; gap: 6px; }
.activity-comment-form input {
  flex: 1;
  min-width: 0;
  padding: 7px 10px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 12.5px;
  margin-bottom: 0;
}

/* Clickable nickname/avatar in a chat row (see chat.js's wireProfileClick)
   — click opens the standalone profile tab, long-press opens it embedded
   below instead (see .profile-panel further down). */
.chat-profile-link { cursor: pointer; }
.chat-nick.chat-profile-link:hover { text-decoration: underline; }

/* Embedded profile view, toggled via .profile-mode on #chat-panel (see
   openProfilePanel/closeProfilePanel in app.js) — unlike
   .pad-mode/.reactions-mode/.more-mode (all mobile-portrait only, see
   the media query above), this works at any screen size. */
.profile-panel:not(.hidden) { display: flex; flex-direction: column; height: 100%; }
.chat-panel.profile-mode .chat-log,
.chat-panel.profile-mode .chat-form,
.chat-panel.profile-mode .fun-actions,
.chat-panel.profile-mode #reply-preview,
.chat-panel.profile-mode #virtual-gamepad {
  display: none !important;
}
.profile-panel-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.profile-panel-body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px; }
/* The chat panel is much narrower than a full page — the standalone
   web/profile.html tab's own layout (wide hero, multi-column grid)
   doesn't fit, so this squeezes it down to one column instead of
   duplicating profile.js's markup with panel-specific classes. */
.profile-panel-body .profile-hero { flex-wrap: wrap; gap: 12px; margin-bottom: 16px; padding-bottom: 16px; }
.profile-panel-body .profile-hero-avatar { width: 64px; height: 64px; }
.profile-panel-body .profile-hero-name { font-size: 18px; }
.profile-panel-body .profile-grid { grid-template-columns: 1fr; }
.profile-panel-body .profile-gallery-grid { grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); }
/* Icon-only Like/Comment in the narrow embedded view — the word ("Like"/
   "Comment") is redundant next to a heart/speech-bubble icon when space
   is this tight; a real count still shows (e.g. "❤ 5"), only the bare
   fallback word is hidden. */
.profile-panel-body .activity-like-btn-label.is-word { display: none; }
.profile-panel-body .activity-like-btn:has(.activity-like-btn-label.is-word) { padding: 5px; gap: 0; }
/* Single-column .profile-grid (above) means sections stack instead of
   sharing a grid row, so .profile-scroll-list's flex:1 (see .profile-
   section) has no row-stretch height to divide up and just grows to fit
   all its content — fine for a short list, but "Worst games ever" can be
   long, and letting it grow means scrolling the whole embedded panel a
   long way just to get past one section. Capped here to a few rows
   instead, with its own internal scroll for the rest. */
.profile-panel-body .profile-scroll-list { flex: none; max-height: 118px; }

/* SNES easter egg — see app.js's setEngineIsSnes. Overrides --accent-2
   (the "purple" highlight used across .ghost-btn.btn-accent, several
   active/hover states, and a few gradients — see every var(--accent-2)
   use elsewhere in this file) with a color in the same family as
   resources/snes_icon.png's own gradient, for as long as a SNES game is
   loaded. Placed at the very end of the file so it wins the cascade
   regardless of which theme is otherwise active.

   Darkened well below the icon's own #3adebe on purpose: --accent-2 pulls
   double duty as both a bright accent on dark panels AND a solid fill
   with hardcoded white text (.ghost-btn.btn-accent, the primary-btn
   gradient) — the icon's actual teal is light enough that white text on
   top of it was barely readable (first playtest feedback: "no tiene
   mucho contraste"). Split into a dark-mode and light-mode shade, same
   as the base theme's own --accent-2 already is (#6a5cff vs #5847e0
   above) — the light shade needs to be darker still, both to stay
   legible as text on light panels and to keep white fill-text readable
   against brighter ambient surroundings. Order matters here: general
   (dark) first, then the light-preference media query, then explicit
   data-theme='light' — later same-specificity rules win, so the light
   variants correctly override the general one whenever a light theme is
   actually in effect (mirrors the base theme's own dark→light ordering
   at the top of this file). */
:root.snes-theme {
  --accent-2: #17a98d;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']).snes-theme {
    --accent-2: #0e8a72;
  }
}
:root[data-theme='light'].snes-theme {
  --accent-2: #0e8a72;
}

/* Online mode's idle screen (see enterOnlineIdle in app.js) — this same
   client before any game exists, showing the profile panel where the
   scoreboard/now-playing/game chrome below would be. All of these
   reappear once a live session is actually joined (.online-idle comes off
   #game-screen right when joinRoom() succeeds), so nothing here is a
   permanent online-mode restriction, only a "there's no game yet" one. */
.online-idle #scoreboard-wrap,
.online-idle #now-playing,
.online-idle #stage-controls,
.online-idle #chat-resize-handle,
.online-idle #chat-mode-bar,
.online-idle #player-actions,
.online-idle #profile-panel-back-btn {
  display: none !important;
}
.online-idle .stage-area { min-width: 320px; }
.online-idle #stage { min-height: 360px; }
.online-idle #stage-placeholder { gap: 12px; text-align: center; padding: 32px; }
.online-idle #stage-placeholder::before {
  content: 'sports_esports';
  font-family: 'Material Symbols Outlined';
  font-size: 54px;
  color: var(--muted);
  opacity: .75;
}
/* Simplified online-only library browser (buildOfflineRomLibraryModal in
   app.js) — reuses .rom-library-card's own look, just without the corner-
   icon rating gesture the live Load Game modal uses (that one assumes a
   room/socket this view deliberately doesn't have). */
.rom-library-card-actions { display: flex; gap: 6px; margin-top: 6px; }
.rom-library-card-actions .ghost-btn.active { border-color: var(--accent); color: var(--accent); }

@media (max-width: 780px) {
  .online-idle .layout { display: block; overflow-y: auto; }
  .online-idle .stage-area { min-width: 0; }
  .online-idle #stage { min-height: 220px; }
  .online-idle #chat-panel { width: 100%; min-width: 0; height: auto; min-height: 420px; margin-top: 14px; }
}
