/* Self-hosted rather than pulled from Google Fonts like the rest of the
   type scale — only the Dead Mime profile theme's codename uses it (see
   --profile-codename-font below), so a whole extra CDN family for one
   theme's headline text wasn't worth it. Path is /play/-prefixed, not
   root-relative — this app is deployed under /play/ on Neocities (see the
   flair-image src convention in accounts.js), so a bare "/icons/..." 404s
   on the real site even though it resolves fine against a local server
   rooted at the repo. */
@font-face {
  font-family: 'Rye';
  src: url('/play/icons/fonts/RyeRegular.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  --black: #08080a;
  --card: #131315;
  --card-2: #1a1a1d;
  /* --card's own R/G/B, broken out for rgba() call sites that need a
     variable alpha on top of it — the Field/Home Map's glass chrome
     (search bars, action buttons, zoom controls) is --card at several
     different opacities, previously written out as 13 separate raw
     rgba(19, 19, 21, X) literals with no token tying them back to --card at
     all (per docs/CODE-DOCTOR-AUDIT.md). */
  --card-rgb: 19, 19, 21;
  --red: #e11c2e;
  --red-dim: rgba(225, 28, 46, 0.14);
  --white: #f4f2ec;
  --muted: #9a988f;
  --gold: #d9b45a;
  --mono: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
  --display: 'Anton', Impact, sans-serif;
  --body: 'Archivo', system-ui, sans-serif;
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);

  /* Real measured heights of .topbar/.bottom-nav, set by js/lib/ui.js's
     syncChromeHeights() once #screen-main is visible — these are fallbacks
     for the first paint before that JS runs. Used by the open-thread view
     (#thread-detail-view) to size itself to exactly fill the gap between
     the two bars instead of a guessed vh percentage. */
  --topbar-h: 4rem;
  --bottom-nav-h: 4.7rem;

  /* Spacing scale — every card/field padding and gap should pull from here
     instead of a freshly-typed value, so new components land on-grid. */
  --space-1: 0.35rem;
  --space-2: 0.5rem;
  --space-3: 0.7rem;
  --space-4: 0.9rem;
  --space-5: 1.2rem;
  --space-6: 1.5rem;

  /* Radius scale — three tiers instead of nine ad hoc values: sm for
     controls/rows, md for cards, lg for the biggest surfaces. */
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;

  /* Type scale — small-caps meta labels, body copy, and headings each pick
     one step instead of a bespoke rem value per rule. */
  --text-xs: 0.68rem;
  --text-sm: 0.78rem;
  --text-base: 0.9rem;
  --text-lg: 1.05rem;
  --text-xl: 1.4rem;

  /* Elevation — the dark app had zero box-shadow anywhere outside the
     disguised login; these are what actually lift a card/modal off the
     page instead of relying on a 1px border and a slightly different gray. */
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.35), 0 10px 24px -14px rgba(0, 0, 0, 0.6);
  --shadow-modal: 0 4px 16px -4px rgba(0, 0, 0, 0.55), 0 24px 48px -16px rgba(0, 0, 0, 0.7);
  --radius-xs: 4px;

  /* Laundry-front cover story palette — global (not scoped to .laundry-screen
     only) because the real login/signup now lives in #modal-login, a sibling
     of #screen-auth, and needs the same colors. The rest of the app's
     --black/--red/--card variables stay untouched everywhere past login. */
  --pw-teal: #18c7c1;
  --pw-teal-dark: #0fa9a4;
  --pw-navy: #1f2733;

  /* Status colors — from scratch/ui-pitch/index.html's design pass. Gold
     already existed (points); teal/success are new, added specifically so
     "pending vs active vs done" reads as a color+dot at a glance instead of
     only from which section/heading a card sits under. */
  --teal: #23d3cd;
  --teal-dim: rgba(35, 211, 205, 0.14);
  --success: #34d399;
  --success-dim: rgba(52, 211, 153, 0.15);
}


/* HQ Mode — a felt, not just read, difference. Nearly every component
   colors itself from these same custom properties, so overriding them here
   reskins tabs/buttons/cards/modals/bubbles app-wide with no per-component
   changes needed. Shadows pick up a faint red cast too, so a lifted card
   reads as "console," not just "the player app tinted red." */
body.hq-mode-active {
  --black: #1a0305;
  --card: #330a0f;
  --card-2: #471017;
  --red: #ff2b40;
  --red-dim: rgba(255, 43, 64, 0.28);
  --white: #ffffff;
  --muted: #e3a3a8;
  --gold: #ffcf4d;
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 24px -14px rgba(255, 43, 64, 0.35);
  --shadow-modal: 0 4px 16px -4px rgba(0, 0, 0, 0.6), 0 24px 48px -16px rgba(255, 43, 64, 0.4);
}

* { box-sizing: border-box; }

/* Buttons are controls, not content — nothing with a <button> tag should
   ever be text-grabbable by mobile's press-and-hold text selection (this is
   what the reaction pills' and emoji picker's own emoji characters were
   still catching, since .chat-bubble's user-select:none doesn't reach the
   picker's separate modal, and reaction pills sit inside a bubble but are
   worth locking down explicitly rather than relying on inheritance alone). */
button {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

/* Vendored Lucide SVG chrome icons (js/lib/icons.js) — stroke inherits
   currentColor, so every color state (active tab, muted, HQ re-theme) tints
   them for free. Vertical-align keeps them sitting right inside text-flow
   spots like buttons and labels without per-site tweaks. */
.ui-icon {
  display: inline-block;
  vertical-align: -0.22em;
  flex-shrink: 0;
}

html, body {
  margin: 0;
  padding: 0;
  background: var(--black);
  color: var(--white);
  font-family: var(--body);
  -webkit-tap-highlight-color: transparent;
  /* Blocks double-tap-to-zoom app-wide (pinch-zoom still works) — this is a
     PWA meant to be tapped rapidly (mission cards, chat), not zoomed. */
  touch-action: manipulation;
  /* Without this, a screen shorter than the viewport (e.g. an empty/short
     message thread) still lets touch drags rubber-band the whole document
     on iOS Safari — since .topbar is only position:sticky within that same
     bouncing document, the header and its settings button visibly move
     along with the bounce instead of staying put. */
  overscroll-behavior-y: none;
}

body {
  min-height: 100vh;
  min-height: 100dvh;
}

.hidden { display: none !important; }

.label {
  display: block;
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--red);
  margin-bottom: 0.5rem;
  /* A section heading, never content worth copying — without this, a
     long-press that lands on one (like the emoji-picker sheet's "React"
     label, easy to clip a thumb on right below the drag handle) triggers
     iOS's native Copy/Look Up/Translate callout floating over the sheet. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

.brand-mark {
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(2.2rem, 9vw, 3rem);
  letter-spacing: 0.02em;
  line-height: 1;
  margin: 0 0 0.75rem;
  color: var(--white);
}
.brand-mark span { color: var(--red); }
.brand-mark.small {
  font-size: 1.2rem;
  margin: 0;
}
/* AGENCY wordmark — reads as [noise] TITLE [noise] at rest: a fixed count of
   red glyphs flanking the current phrase (see main.js's AGENCY_TITLE_PHRASES)
   that burst through a matrix scramble and resettle every few seconds, while
   the phrase itself sits still. The first tap kills the noise for good and
   rolodexes the line into a plain, noise-free phrase; every tap after that
   just rolodexes to the next phrase, same as the first — chars drift in/out
   vertically in alternating directions while scrambling through glyphs before
   settling, driven by js/lib/title-flicker.js. There is no auto-cycle timer
   for the phrase itself. */
.brand-mark-agency {
  display: inline-flex;
  align-items: baseline;
  min-width: 0;
  flex-shrink: 1;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  letter-spacing: 0.01em;
}
.tf-title {
  display: inline-flex;
  align-items: baseline;
  max-width: 100%;
  overflow: hidden;
  white-space: nowrap;
}
/* Longer phrases can outgrow the topbar on narrow phones — fade the tail
   into the background instead of hard-clipping mid-glyph (text-overflow:
   ellipsis can't render "…" here since each character is its own span).
   Only applied when title-flicker.js detects real overflow, since a mask
   is always relative to this shrink-to-fit box and would otherwise fade
   the last glyph of every phrase, overflowing or not. */
.tf-title.tf-clipped {
  mask-image: linear-gradient(to right, black 85%, transparent 100%);
  -webkit-mask-image: linear-gradient(to right, black 85%, transparent 100%);
}
.tf-char {
  display: inline-block;
  white-space: pre;
}
.tf-char.tf-prefix { color: var(--red); }
.tf-char.tf-main { color: var(--white); }
.tf-char.tf-noise {
  color: var(--red);
  opacity: 0.85;
  /* Fixed width regardless of which glyph is showing — the noise clusters
     reshuffle their contents constantly, and if their width tracked each
     glyph's natural size the title next to them would visibly shift. */
  width: 0.72em;
  text-align: center;
  overflow: hidden;
}
.tf-char.tf-scrambling {
  color: #3dffa0;
  text-shadow: 0 0 6px rgba(61, 255, 160, 0.55);
}
.tf-char.tf-entering { animation: pw-tf-in 0.42s steps(6, end) both; }
.tf-char.tf-exiting { animation: pw-tf-out 0.28s steps(5, end) both; }
.tf-gap {
  display: inline-block;
  width: 0.35em;
  flex-shrink: 0;
}
@keyframes pw-tf-in {
  0% { opacity: 0; transform: translateY(var(--tf-y, -0.55em)); }
  15% { opacity: 0.2; }
  30% { opacity: 0.85; transform: translateY(calc(var(--tf-y, -0.55em) * 0.35)); }
  45% { opacity: 0.3; }
  60% { opacity: 1; transform: translateY(calc(var(--tf-y, -0.55em) * 0.1)); }
  100% { opacity: 1; transform: translateY(0); }
}
@keyframes pw-tf-out {
  0% { opacity: 1; transform: translateY(0); }
  30% { opacity: 0.4; }
  55% { opacity: 0.8; transform: translateY(calc(var(--tf-y, 0.55em) * 0.4)); }
  70% { opacity: 0.15; }
  100% { opacity: 0; transform: translateY(var(--tf-y, 0.55em)); }
}

/* HQ's "you're in a privileged view" signal — used to be its own striped
   banner bar above the topbar; swapping the title itself is the same
   signal in one line instead of two, and needs no separate --topbar-h
   accounting the way a second stacked bar did. */
.brand-mark-hq {
  display: none;
  color: var(--red);
  letter-spacing: 0.04em;
}
body.hq-mode-active .brand-mark-agency { display: none; }
body.hq-mode-active .brand-mark-hq { display: inline; }

/* ---------- Auth screen ---------- */
.screen {
  min-height: 100vh;
  min-height: 100dvh;
}
/* Lets js/main.js's attachTabSwipe drive horizontal gestures itself instead
   of the OS treating a horizontal drag as its own back/forward navigation
   swipe — vertical panning (actually scrolling a tab's content) stays
   fully native. */
#screen-main { touch-action: pan-y; }

.auth-sub {
  color: var(--muted);
  font-size: 0.95rem;
  line-height: 1.5;
  margin: 0 0 2rem;
}

/* ---------- Laundry-front auth screen (disguised as spy.show/powerwash/) ----------
   White background, cyan kicker — deliberately its own palette/fonts (see the
   --pw-* variables in :root) — the real app's --black/--red/--card variables
   stay untouched for everything past login. */
.laundry-screen {
  background: #ffffff;
  color: var(--pw-navy);
  font-family: "Oswald", sans-serif;
}
.laundry-wrap {
  max-width: 420px;
  margin: 0 auto;
  padding: calc(3rem + var(--safe-top)) 1.25rem calc(3rem + var(--safe-bottom));
}
.laundry-card {
  text-align: center;
  padding: 0 0 1.1rem;
}
.laundry-kicker {
  font-weight: 600;
  letter-spacing: 0.03em;
  font-size: 0.85rem;
  color: var(--pw-teal-dark);
  margin: 0 0 0.2rem;
  text-transform: uppercase;
}
.laundry-wordmark {
  font-family: "Anton", sans-serif;
  font-size: clamp(2.1rem, 9vw, 2.8rem);
  color: var(--pw-navy);
  margin: 0;
  line-height: 1;
}
.laundry-script {
  font-family: "Pacifico", cursive;
  font-size: clamp(1.5rem, 7vw, 2.1rem);
  color: var(--pw-teal-dark);
  margin: -2px 0 0.4rem;
  line-height: 1;
}
.laundry-machine { width: 72px; height: 72px; margin: 0.6rem auto 0.2rem; }
.laundry-machine img {
  width: 100%;
  height: 100%;
  display: block;
  border-radius: 22%;
  box-shadow: 0 6px 16px -8px rgba(31, 39, 51, 0.35);
}
.laundry-card-foot {
  margin-top: 1.4rem;
  display: inline-block;
  background: var(--pw-navy);
  color: #fff;
  padding: 0.7rem 1.4rem 0.8rem;
  border-radius: 20px;
}
.laundry-card-foot p { margin: 0; text-transform: uppercase; letter-spacing: 0.03em; }
.laundry-foot-small { font-size: 0.7rem; font-weight: 500; opacity: 0.85; }
.laundry-foot-big { font-size: 1.05rem; font-weight: 700; margin-top: 2px; }

.laundry-actions {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-top: 2rem;
}

.laundry-form {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  margin-top: 0.5rem;
}
.laundry-field { display: flex; flex-direction: column; gap: 0.3rem; }
.laundry-field span {
  font-weight: 600;
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #5b6472;
}
.laundry-field input {
  background: #fff;
  border: 1px solid #e2ded2;
  border-radius: 10px;
  color: var(--pw-navy);
  font-family: "Oswald", sans-serif;
  font-size: 1rem;
  padding: 0.75rem 0.85rem;
}
.laundry-field input:focus { outline: none; border-color: var(--pw-teal-dark); }

.laundry-btn {
  display: block;
  width: 100%;
  text-align: center;
  font-family: "Oswald", sans-serif;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  font-size: 0.9rem;
  padding: 0.8rem 0.6rem;
  border-radius: 12px;
  border: 2px solid var(--pw-navy);
  cursor: pointer;
  transition: opacity 0.15s;
}
.laundry-btn:active { opacity: 0.75; }
.laundry-btn-primary { background: var(--pw-navy); color: #fff; }
.laundry-btn-ghost { background: transparent; color: var(--pw-navy); }

.laundry-error {
  color: #b0392c;
  font-size: 0.85rem;
  margin: 0;
  min-height: 1.2em;
}

.laundry-link {
  display: block;
  width: 100%;
  text-align: center;
  background: none;
  border: none;
  color: #5b6472;
  font-family: "Oswald", sans-serif;
  font-size: 0.85rem;
  text-decoration: underline;
  margin-top: 1rem;
  cursor: pointer;
}

/* ---------- Login modal — light/laundry-themed, not the dark app modal ---------- */
.laundry-modal-card {
  background: #fff;
  border: 1px solid #e2ded2;
  color: var(--pw-navy);
  font-family: "Oswald", sans-serif;
  position: relative;
  text-align: center;
}
.laundry-modal-card .laundry-kicker { margin-top: 0.2rem; }
.laundry-modal-close {
  position: absolute;
  top: 0.6rem;
  right: 0.7rem;
  background: none;
  border: none;
  color: #9aa0a8;
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
  padding: 0.2rem;
}
.laundry-modal-close:hover { color: var(--pw-navy); }

/* ---------- Glitch transition: laundry-front -> real dark app ---------- */
.screen-transition {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: var(--black);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.screen-transition.hidden { display: none; }
.screen-transition::before {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(0deg, rgba(244, 242, 236, 0.06) 0px, rgba(244, 242, 236, 0.06) 1px, transparent 1px, transparent 3px);
  animation: pw-scanlines 0.15s linear infinite;
}
.glitch-text {
  position: relative;
  font-family: var(--mono);
  font-size: 1.4rem;
  letter-spacing: 0.12em;
  font-weight: 700;
  color: var(--white);
  text-transform: uppercase;
  animation: pw-glitch-flicker 0.7s steps(2, end);
}
.glitch-text::before,
.glitch-text::after {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  background: var(--black);
}
.glitch-text::before {
  color: var(--red);
  animation: pw-glitch-split-1 0.7s steps(2, end);
}
.glitch-text::after {
  color: #2de0e6;
  animation: pw-glitch-split-2 0.7s steps(2, end);
}
@keyframes pw-scanlines { from { transform: translateY(0); } to { transform: translateY(3px); } }
@keyframes pw-glitch-flicker {
  0%, 100% { opacity: 1; }
  10% { opacity: 0.2; }
  20% { opacity: 1; }
  55% { opacity: 0.3; }
  60% { opacity: 1; }
}
@keyframes pw-glitch-split-1 {
  0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
  20% { transform: translate(-3px, 1px); clip-path: inset(10% 0 60% 0); }
  40% { transform: translate(3px, -1px); clip-path: inset(60% 0 10% 0); }
  60% { transform: translate(-2px, 0); clip-path: inset(30% 0 40% 0); }
  80% { transform: translate(2px, 1px); clip-path: inset(50% 0 20% 0); }
}
@keyframes pw-glitch-split-2 {
  0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
  20% { transform: translate(3px, -1px); clip-path: inset(60% 0 5% 0); }
  40% { transform: translate(-3px, 1px); clip-path: inset(5% 0 60% 0); }
  60% { transform: translate(2px, 0); clip-path: inset(40% 0 30% 0); }
  80% { transform: translate(-2px, -1px); clip-path: inset(20% 0 50% 0); }
}

/* ---------- Splash screen (radar ping, shown while Firebase resolves auth) ----------
   White background on purpose — a deliberate contrast with the dark app and
   the laundry-front's cyan, so this reads as its own "instrument warming up"
   beat rather than either destination screen. Sits above .screen-transition
   (z-index 50) since a resumed session could in theory still be fading that
   out underneath if this were ever left visible that long. */
.screen-splash {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: #ffffff;
  display: flex;
  align-items: center;
  justify-content: center;
}
.screen-splash.hidden { display: none; }

/* ---------- Pre-login hard refresh (2026-07-17) ----------
   Reachable the instant the app opens — on the splash screen (index.html
   only, wired from a plain classic script, no ES-module dependency, since
   the whole point is to still work if the module bundle itself is what's
   stale/broken) and on the login screen behind it — so a stale deploy can
   be forced fresh from a device without uninstalling/reinstalling the PWA.
   Deliberately a small, low-contrast corner icon rather than a labeled
   button: #screen-auth is still playing the Powerwash Cleaners cover
   story, and an obvious "REFRESH APP" button would read as an out-of-
   fiction admin control sitting right on top of it. Styled for the white
   laundry-front/splash background specifically (--pw-navy, not the dark
   app's --muted, which has poor contrast here). */
.pre-login-refresh-btn {
  position: fixed;
  top: calc(0.9rem + var(--safe-top));
  right: 0.9rem;
  z-index: 70;
  width: 34px;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid rgba(31, 39, 51, 0.18);
  background: rgba(255, 255, 255, 0.7);
  color: var(--pw-navy);
  opacity: 0.55;
  cursor: pointer;
  transition: opacity 0.15s var(--ease), transform 0.1s var(--ease);
}
.pre-login-refresh-btn:active { transform: scale(0.92); }
.pre-login-refresh-btn:hover,
.pre-login-refresh-btn:focus-visible { opacity: 1; }

.radar-ping {
  position: relative;
  width: 5.5rem;
  height: 5.5rem;
  display: flex;
  align-items: center;
  justify-content: center;
}
.radar-ping-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--red);
  opacity: 0;
  animation: pw-radar-ping 2.4s cubic-bezier(0.2, 0.6, 0.35, 1) infinite;
}
.radar-ping-ring-2 { animation-delay: 0.8s; }
.radar-ping-ring-3 { animation-delay: 1.6s; }
.radar-ping-dot {
  position: relative;
  width: 0.85rem;
  height: 0.85rem;
  border-radius: 50%;
  background: var(--red);
  box-shadow: 0 0 0.7rem rgba(225, 28, 46, 0.55);
}
@keyframes pw-radar-ping {
  0% { transform: scale(0.25); opacity: 0.9; }
  100% { transform: scale(2.8); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .radar-ping-ring { animation: none; opacity: 0.5; }
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.field span {
  font-family: var(--mono);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}
.field input {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: 1rem;
  padding: 0.85rem 0.9rem;
}
/* Every plain text input across the app shares this focus treatment — a red
   border standing in for the native focus ring, no separate outline. Every
   selector below used to carry its own copy of these same two declarations
   (originally 9 identical rule blocks, per docs/CODE-DOCTOR-AUDIT.md;
   .home-mission-search-input:focus folded in later, same shape) — collected
   here instead. .laundry-field input:focus (teal, not red) is intentionally NOT
   part of this list — the laundry-front disguise UI is themed separately on
   purpose. */
.field input:focus,
.field select:focus,
.field textarea:focus,
.search-input:focus,
.hq-grant-row select:focus,
.home-map-search-input:focus,
.home-mission-search-input:focus,
.code-input:focus,
#write-note-text:focus,
#note-thread-reply:focus,
#load-laundry-card-amount:focus,
.thread-reply-row input:focus,
.points-custom-row input:focus {
  outline: none;
  border-color: var(--red);
}

.search-input {
  display: block;
  width: 100%;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: 1rem;
  padding: 0.85rem 0.9rem;
  margin: 0.9rem 0;
}

.btn {
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  border-radius: var(--radius-sm);
  padding: 0.9rem 1.2rem;
  /* Explicit tap-target floor (docs/HQ-REDESIGN.md §1) — this had no
     min-height before, just padding, which reduces mis-taps while walking.
     Deliberately NOT applied to .btn-small: the dense HQ mission-list rows
     (several small buttons per row) rely on staying compact — see
     js/admin.js's "console density" comment on renderAdminMissionsList. */
  min-height: 44px;
  border: 1px solid transparent;
  cursor: pointer;
  transition: transform 0.15s var(--ease), opacity 0.15s var(--ease);
}
.btn:active { transform: scale(0.97); }
.btn-primary {
  background: var(--red);
  color: var(--white);
}
.btn-outline {
  background: transparent;
  border-color: rgba(244, 242, 236, 0.25);
  color: var(--white);
}
.btn-danger {
  background: var(--red);
  color: var(--white);
}
.btn-small {
  padding: 0.55rem 0.9rem;
  font-size: 0.75rem;
  /* .btn's min-height would otherwise still apply here too (equal
     specificity, .btn-small just doesn't set it) — reset explicitly so
     the dense HQ console rows actually stay compact. */
  min-height: 0;
}

.auth-error {
  color: var(--red);
  font-family: var(--mono);
  font-size: 0.8rem;
  min-height: 1.2em;
  margin: 0;
}

.link-btn {
  margin-top: 1.5rem;
  background: none;
  border: none;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 0.8rem;
  text-decoration: underline;
  cursor: pointer;
  align-self: flex-start;
  padding: 0;
  transition: opacity 0.1s var(--ease);
}
.link-btn:active { opacity: 0.55; }
/* Inside a .roster-row (Roles panel's Revoke, Hub/badge/flair/theme
   library's Edit/Delete) the row itself is already a 44px tap-target floor,
   but a bare .link-btn only ever fills as much space as its underlined
   text — a tap has to land on the word itself, not anywhere in the
   visually-44px row. Undo the standalone-context defaults (margin/align-self
   meant for a link sitting under a form) and give it an explicit min-height
   instead of relying on align-self: stretch — verified in a real browser
   that Chromium doesn't stretch a <button> to its flex container's cross
   size the way it does a plain <div>, same reason .btn sets min-height
   directly rather than trusting stretch. */
.roster-row .link-btn {
  margin-top: 0;
  align-self: center;
  display: flex;
  align-items: center;
  min-height: 2.75rem;
  padding: 0 0.6rem;
}
/* Same shortfall, different button: the System panel's report/deletion-
   request rows put a .btn-small (Mark Reviewed/Delete/Dismiss — one of them
   genuinely destructive) inside a .roster-row too. .roster-row centers
   rather than stretches its children, so .btn-small's own min-height: 0
   reset leaves these sitting at their natural ~30px height inside a
   visually-44px row. (Ops Home's own copy of these same actions lives in
   .ops-card-row instead, which stretches its children to the row's full
   height already — no fix needed there.) */
.roster-row .btn-small {
  min-height: 2.75rem;
}
.link-btn-danger { color: var(--red); }
.msg-delete-btn {
  background: none;
  border: none;
  color: inherit;
  opacity: 0.5;
  cursor: pointer;
  font-size: 0.65rem;
  padding: 0 0 0 0.4rem;
  float: right;
}
.msg-delete-btn:hover { opacity: 1; }

/* ---------- Main app ---------- */
.topbar {
  position: sticky;
  top: 0;
  z-index: 5;
  background: rgba(8, 8, 10, 0.92);
  backdrop-filter: blur(6px);
  border-bottom: 1px solid var(--card-2);
  padding-top: var(--safe-top);
}
.topbar-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.9rem 1.25rem;
}
.icon-btn {
  background: none;
  border: 1px solid rgba(244, 242, 236, 0.25);
  color: var(--muted);
  font-family: var(--mono);
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 0.4rem 0.7rem;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: transform 0.1s var(--ease);
}
.icon-btn:active { transform: scale(0.92); }
.topbar-actions { display: flex; align-items: center; gap: 0.6rem; position: relative; flex-shrink: 0; }

/* Settings dropdown — replaces the old bare "Log Out" button with a small
   menu anchored under the gear icon, holding Log Out plus (for an `hq`-role
   account) the HQ View toggle that used to live inside the profile modal. */
.settings-menu {
  position: absolute;
  top: calc(100% + 0.4rem);
  right: 0;
  min-width: 11rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-modal);
  overflow: hidden;
  z-index: 30;
}
.settings-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  border-bottom: 1px solid var(--card-2);
  color: var(--white);
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.03em;
  padding: 0.7rem 0.9rem;
  cursor: pointer;
}
.settings-menu-item:last-child { border-bottom: none; }
.settings-menu-item:active { background: var(--card-2); }
.settings-menu-danger { color: var(--red); }
/* Account Switcher section divider (2026-07-17, js/account-switcher.js) —
   a slightly heavier rule than each item's own plain border-bottom, so the
   account-related rows below it read as their own group rather than just
   more items in the same flat list. */
.settings-menu-divider { height: 6px; background: var(--black); border-bottom: 1px solid var(--card-2); }

/* Push-enable card — replaces a plain text link with a real, tappable
   invitation: icon + headline/subhead + a genuine switch control, so turning
   on push alerts reads as a real feature, not fine print. */
.push-card {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin: 0 0 0.9rem;
  padding: 0.8rem 0.9rem;
  background: var(--card-2);
  border: 1px solid rgba(244, 242, 236, 0.1);
  border-radius: var(--radius-md);
  transition: border-color 0.2s var(--ease), background 0.2s var(--ease);
}
.push-card-on {
  border-color: rgba(217, 180, 90, 0.4);
  background: rgba(217, 180, 90, 0.08);
}
.push-card-blocked { opacity: 0.75; }
.push-card-icon { flex-shrink: 0; color: var(--muted); }
.push-card-on .push-card-icon { color: var(--gold); }
.push-card-blocked .push-card-icon { color: var(--red); }
.push-card-body { flex: 1; display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.push-card-title { font-family: var(--body); font-weight: 700; font-size: var(--text-base); color: var(--white); }
.push-card-sub { font-family: var(--body); font-size: var(--text-xs); color: var(--muted); line-height: 1.35; }
.push-toggle-switch {
  position: relative;
  flex-shrink: 0;
  width: 2.6rem;
  height: 1.5rem;
  border-radius: 999px;
  border: none;
  background: rgba(244, 242, 236, 0.18);
  cursor: pointer;
  transition: background 0.2s var(--ease);
}
.push-toggle-switch:disabled { cursor: default; opacity: 0.6; }
.push-toggle-knob {
  position: absolute;
  top: 0.15rem;
  left: 0.15rem;
  width: 1.2rem;
  height: 1.2rem;
  border-radius: 50%;
  background: var(--white);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
  transition: transform 0.2s var(--ease);
}
.push-toggle-switch[aria-checked="true"] { background: var(--gold); }
.push-toggle-switch[aria-checked="true"] .push-toggle-knob { transform: translateX(1.1rem); }
.notifications-feed-btn { position: relative; font-size: 1rem; padding: 0.35rem 0.55rem; }
.notifications-count {
  position: absolute;
  top: -0.35rem;
  right: -0.35rem;
  min-width: 1.1rem;
  height: 1.1rem;
  padding: 0 0.25rem;
  border-radius: 999px;
  background: var(--red);
  color: var(--white);
  font-family: var(--mono);
  font-size: 0.6rem;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
}
/* Needs Your Action vs Activity — same split Slack/Instagram's activity
   feeds make: items that only resolve by YOU acting (a request, a pending
   RSVP) get a distinct gold-bordered card and stay put; everything else
   (messages, badges, accomplishments) is quieter "here's what happened."
   Zone label only renders when a zone actually has items — see
   notifications.js's renderNotifications(). */
.notification-zone-label {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0.9rem 0 0.5rem;
}
.notification-zone-label:first-child { margin-top: 0; }
/* Replaces the old modal-wide "Clear All" footer button, which silently did
   nothing to unread threads/pending invites — those only resolve by being
   acted on, not dismissed. This one lives right on the zone it actually
   affects (Slack's per-section "mark as read"), and only renders at all when
   there's something in THIS zone it can honestly claim to clear. */
.notification-zone-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  margin: 0.9rem 0 0.5rem;
}
.notification-zone-header:first-child { margin-top: 0; }
.notification-zone-header .notification-zone-label { margin: 0; }
.notification-zone-action {
  background: none;
  border: none;
  color: var(--gold);
  font-family: var(--mono);
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: pointer;
  padding: 0.2rem 0;
}
.notification-zone-action:active { opacity: 0.6; }
.notification-row-action {
  border-left: 3px solid var(--gold);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.notification-row-quiet { opacity: 0.75; }
/* touch-action:pan-y hands vertical scrolling back to the browser but lets
   the swipe-to-dismiss gesture (notifications.js) own horizontal drags
   itself instead of fighting iOS's own edge-swipe/scroll handling. */
.notification-row { cursor: pointer; align-items: flex-start; touch-action: pan-y; }
.notification-row-icon { font-size: 1rem; }
.notification-row-text { flex: 1; font-family: var(--body); font-size: 0.85rem; color: var(--white); line-height: 1.4; }
.notification-dismiss-btn {
  background: none;
  border: none;
  color: var(--muted);
  opacity: 0.6;
  cursor: pointer;
  font-size: 0.9rem;
  line-height: 1;
  padding: 0 0 0 0.4rem;
  flex-shrink: 0;
}
.notification-dismiss-btn:hover { opacity: 1; color: var(--red); }
@keyframes notification-dismiss-out {
  to { opacity: 0; transform: translateX(12px); max-height: 0; margin: 0; padding-top: 0; padding-bottom: 0; }
}
.notification-row.notification-row-dismissing {
  animation: notification-dismiss-out 0.2s var(--ease) forwards;
  overflow: hidden;
}
/* Read/dismissed items fade rather than vanish — two independent
   focus-group personas expected a scrollable history, not a list that
   erases itself the moment you act on something. */
.notifications-history { margin-top: 0.4rem; }
.notifications-history summary { cursor: pointer; margin-bottom: 0.4rem; }
.notifications-history .notification-row-history {
  cursor: default;
  opacity: 0.55;
  margin-bottom: 0.4rem;
}

/* ---------- Requests panel (Mainframe) ---------- */
.request-row-actions { display: flex; align-items: center; gap: 0.4rem; }

.dossier {
  margin: 1.25rem 1.25rem 0.7rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  padding: var(--space-5);
  box-shadow: var(--shadow-card);
  transition: background 0.2s var(--ease), border-color 0.2s var(--ease);
}
.dossier-row {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1rem;
  cursor: pointer;
}
.codename {
  font-family: var(--display);
  font-weight: 400;
  font-size: 1.4rem;
  margin: 0.15rem 0 0;
  color: var(--white);
}
.points-value {
  font-family: var(--display);
  font-size: 1.8rem;
  color: var(--gold);
  line-height: 1;
  display: inline-block;
}
/* Inside the dossier stat row, .points-value's own larger size fought
   .profile-card-stat b's font-size (same specificity, cascade order won) —
   the Spy Points number rendered visibly bigger and off the baseline from
   Active Missions/Badges next to it. Match their size; keep the gold color
   as a deliberate accent, that part wasn't the bug. */
.profile-card-stat .points-value {
  font-size: var(--text-lg);
  line-height: inherit;
}
@keyframes points-pop {
  0% { transform: scale(1); }
  35% { transform: scale(1.35); color: var(--white); }
  100% { transform: scale(1); }
}
.points-value.points-pulse {
  animation: points-pop 0.5s var(--ease);
}

/* Removed entirely in HQ mode (2026-07-15) — the 🛡️ "HQ CONTROL" chip
   (js/accounts.js's renderDossier()) read as a leftover player-UI banner
   sitting at the top of the Missions tab, not real console chrome; HQ
   already has its own identity via the topbar brand mark. #dossier-wrap's
   own show/hide (js/main.js's tab-click handler, keyed to whether Missions
   is the active tab) still runs unmodified for the player app — this is a
   pure override, higher-specificity id selector so it wins regardless of
   that class-toggle timing. */
body.hq-mode-active #dossier-wrap {
  display: none;
}

/* ---------- Bottom nav (IG/X-style 5-tab bar) ----------
   Replaces the old top tab strip. Buttons keep the `.tab` class (main.js,
   notifications.js, and messages.js all still select `.tab[data-tab=...]`
   directly), so these rules target `.bottom-nav .tab` specifically — higher
   specificity than the bare `.tab` selector would need, but there is no bare
   `.tab` usage left outside this bar. Fixed to the viewport bottom rather
   than flowing with the page, the way every mainstream app anchors its root
   nav for one-handed thumb reach. */
.bottom-nav {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 25;
  display: flex;
  background: rgba(8, 8, 10, 0.94);
  backdrop-filter: blur(8px);
  border-top: 1px solid var(--card-2);
  padding: 0.5rem 0.25rem calc(0.6rem + var(--safe-bottom));
  transition: background 0.2s var(--ease), border-color 0.2s var(--ease);
}
.bottom-nav .tab {
  flex: 1;
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.2rem;
  background: none;
  border: none;
  padding: 0.3rem 0.2rem;
  min-height: 44px; /* tap-target floor, docs/HQ-REDESIGN.md §1 — the primary
    nav bar's own buttons sat well under it (padding alone measured ~32px) */
  border-radius: var(--radius-sm);
  color: var(--muted);
  cursor: pointer;
  transition: color 0.15s var(--ease), transform 0.1s var(--ease);
}
.bottom-nav .tab:active { transform: scale(0.92); }
.bn-icon { font-size: 1.3rem; line-height: 1; filter: grayscale(0.4); opacity: 0.85; }
.bn-label {
  font-family: var(--mono);
  font-size: 0.6rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.bottom-nav .tab.active {
  color: var(--white);
}
.bottom-nav .tab.active .bn-icon {
  filter: none;
  opacity: 1;
  text-shadow: 0 0 8px var(--red-dim);
}
.bottom-nav .tab.active .bn-label { color: var(--red); }

/* Unread/request indicators — same elements + logic the old top tabs used
   (messages.js's refreshMessagesBadge, relationships.js's
   renderConnectionRequests), just repositioned to float over this bar's
   icon instead of sitting inline after text. */
.bn-dot {
  position: absolute !important;
  top: 0.15rem;
  right: calc(50% - 1.3rem);
  margin: 0 !important;
}
.unread-dot.bn-dot {
  display: block;
  width: 0.45rem;
  height: 0.45rem;
  border-radius: 50%;
  background: var(--red);
  top: 0.2rem;
}

/* HQ Control reskins the bar (same --red/--card variables shift as
   everywhere else) rather than swapping to a different component. Map and
   Missions both stay visible in HQ mode now (Map/Missions redesign) — each
   tab shows HQ's own admin view instead of the player one, no bounce needed. */
body.hq-mode-active .bottom-nav {
  border-top-color: var(--card-2);
  box-shadow: 0 -1px 0 var(--red-dim);
}

.tab-panel {
  padding: 0 1.25rem calc(5.6rem + var(--safe-bottom));
}
/* Profile color (accounts.js's applyProfileColor) is set on this whole tab,
   not just .profile-card, so it colors the full profile screen — card,
   editors, and badge gallery all inherit --profile-color from here. A
   textured theme's --profile-pattern (brick/terminal/caution) rides along
   as a background-image, so the pattern reads as a frame around the Board
   sub-tab's own opaque corkboard rather than just a flat-colored tab. */
#tab-profile {
  background-color: var(--profile-color, transparent);
  background-image: var(--profile-pattern, none);
  background-position: var(--profile-pattern-position, 0 0);
  background-size: var(--profile-pattern-size, auto);
  background-repeat: var(--profile-pattern-repeat, repeat);
  transition: background-color 0.25s var(--ease);
}
/* .tab-panel is flush against the topbar with square corners by design —
   fine for ordinary feed-style tab content, but a vivid pattern butting
   straight into the topbar with a hard edge read as an abrupt, unfinished
   transition. #agent-profile-content doesn't need this — its own
   .agent-profile-body padding already gives it breathing room up top —
   so this is scoped to #tab-profile specifically, and only when a
   textured theme is actually painting something back there. */
#tab-profile.profile-theme-textured {
  padding-top: var(--space-4);
  border-top-left-radius: var(--radius-lg);
  border-top-right-radius: var(--radius-lg);
}
.tab-panel .label { padding-left: 0.1rem; }
/* Fallback fade for any .tab-panel that becomes visible outside
   js/main.js's switchTab() (there shouldn't be one in practice — the
   initial active tab is already unhidden in static markup, no animation
   needed — but this keeps a panel from ever popping in bare if one is). */
@keyframes tab-panel-in {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}
.tab-panel:not(.hidden) {
  animation: tab-panel-in 0.2s var(--ease);
}
/* Unified tab-switch motion (js/main.js's switchTab) — every switch, tap OR
   swipe, now runs the SAME two-part animation: the outgoing panel briefly
   exits toward the edge it's leaving from, then the incoming panel enters
   from the opposite edge. Direction for a tap is inferred from bottom-nav
   order (moving to a later tab = "right", same as swiping left to get
   there) so taps read as movement along the strip instead of an instant
   cut, and a real swipe reuses the exact same classes with the gesture's
   own direction. Stronger motion than the old swipe-only version (32px vs
   18px). Two classes beat the plain .tab-panel:not(.hidden) fade's
   specificity, so this replaces it rather than both trying to animate at
   once.
   No opacity in any of these four (2026-08-01 fix) — html/body's own
   background (--black, #08080a) sits directly behind every .tab-panel,
   which has no opaque background of its own; fading a panel's opacity
   toward 0 doesn't reveal transparency so much as it reveals THAT
   near-black showing through content that isn't already exactly that
   shade (a themed Profile background, a card, a badge) — read back as a
   real dark flash on every single switch once this became universal
   instead of swipe-only. Pure translateX (no fade) reads as an equally
   strong slide with nothing ever dimming toward the page's own
   background color. */
@keyframes tab-enter-from-right {
  from { transform: translateX(32px); }
  to { transform: translateX(0); }
}
@keyframes tab-enter-from-left {
  from { transform: translateX(-32px); }
  to { transform: translateX(0); }
}
@keyframes tab-exit-to-left {
  from { transform: translateX(0); }
  to { transform: translateX(-22px); }
}
@keyframes tab-exit-to-right {
  from { transform: translateX(0); }
  to { transform: translateX(22px); }
}
.tab-panel.tab-enter-from-right:not(.hidden) {
  animation: tab-enter-from-right 0.26s var(--ease);
}
.tab-panel.tab-enter-from-left:not(.hidden) {
  animation: tab-enter-from-left 0.26s var(--ease);
}
.tab-panel.tab-exit-to-left {
  animation: tab-exit-to-left 0.14s var(--ease) forwards;
}
.tab-panel.tab-exit-to-right {
  animation: tab-exit-to-right 0.14s var(--ease) forwards;
}

/* An open thread breaks out of the normal scrolling-page layout every other
   tab uses (.tab-panel's big bottom padding is tuned to clear the bottom
   nav for a LIST that ends there — it left a visibly dead gap under a
   chat's reply row instead, since the message list itself was also capped
   at a guessed 55vh). Fixed to exactly fill topbar-to-nav using the heights
   js/lib/ui.js measures, with the message list as the one scrolling region
   and the reply row pinned at the true bottom — the standard native chat
   layout. #thread-list-view (the inbox) keeps the normal page-scroll model
   and is never hidden — this fixed, opaque layer simply covers it.

   Positioned off-screen via transform instead of display:none so opening
   and closing can be a real horizontal push, not an instant cut: tapping a
   thread row toggles .open and this transition slides it in; dragging from
   the left edge (attachThreadSwipeBack in messages.js) drives the same
   transform 1:1 with the finger, then hands off to this transition to
   settle open or closed. touch-action:pan-y leaves ordinary vertical
   scrolling of the message list to the browser. */
#thread-detail-view {
  position: fixed;
  left: 0;
  right: 0;
  top: var(--topbar-h);
  bottom: var(--bottom-nav-h);
  z-index: 6;
  display: flex;
  flex-direction: column;
  padding: 0.9rem 1.25rem 0;
  background: var(--black);
  transform: translateX(100%);
  transition: transform 0.28s var(--ease);
  pointer-events: none;
  touch-action: pan-y;
}
#thread-detail-view.open {
  transform: translateX(0);
  pointer-events: auto;
}
#thread-detail-view .thread-detail-header { flex-shrink: 0; }
#thread-detail-view .thread-reply-row {
  flex-shrink: 0;
  padding-bottom: calc(0.9rem + var(--safe-bottom));
}

details.hidden { display: none; }
details summary {
  list-style: none;
  cursor: pointer;
}
details summary::-webkit-details-marker { display: none; }
details summary::before {
  content: "▸ ";
  display: inline-block;
  transition: transform 0.15s ease;
}
details[open] summary::before { transform: rotate(90deg); }

.agent-avatar { margin-right: 0.35rem; }
.chat-bubble-persona {
  display: inline-block;
  font-family: var(--mono);
  font-size: 0.6rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  border: 1px solid rgba(154, 152, 143, 0.35);
  border-radius: 3px;
  padding: 0.05rem 0.35rem;
  margin-left: 0.4rem;
}
.chat-bubble-persona {
  display: block;
  width: fit-content;
  margin: 0 0 0.3rem;
}

.thread-title-clickable {
  cursor: pointer;
  text-decoration: underline dotted;
  text-underline-offset: 3px;
}

.avatar-picker {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.6rem;
}
.avatar-option {
  background: none;
  border: 1px solid rgba(244, 242, 236, 0.2);
  border-radius: var(--radius-sm);
  font-size: 1.2rem;
  padding: 0.3rem 0.5rem;
  cursor: pointer;
}
.avatar-option-selected { border-color: var(--red); background: var(--red-dim); }

.badge-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: 0.5rem;
  background: var(--profile-color, transparent);
  transition: background 0.25s var(--ease);
}
.badge-card,
.badge-manage-card,
.flair-manage-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  text-align: center;
  padding: 0.7rem 0.4rem;
  border-radius: var(--radius-md);
  background: var(--card);
  border: 1px solid var(--card-2);
  cursor: pointer;
  box-shadow: var(--shadow-card);
}
/* HQ's grant/revoke grid — a real <button>, reset native chrome. Locked
   (not-yet-granted) still needs cursor:pointer here, unlike the read-only
   player Library's .badge-card-locked, since tapping it grants the badge. */
.badge-manage-card,
.flair-manage-card {
  font: inherit;
  color: inherit;
  width: 100%;
}
.badge-manage-card.badge-card-locked,
.flair-manage-card.badge-card-locked { cursor: pointer; }
.flair-manage-card-image {
  width: 2.6rem;
  height: 2.6rem;
  border-radius: var(--radius-sm);
  object-fit: cover;
  background: var(--card-2);
}
.flair-manage-card.badge-card-locked .flair-manage-card-image { filter: grayscale(0.6); opacity: 0.6; }
.badge-card-icon { font-size: 1.6rem; line-height: 1; }
.badge-card-name {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}
@keyframes badge-reveal {
  0% { opacity: 0; transform: scale(0.6) rotate(-8deg); }
  60% { opacity: 1; transform: scale(1.08) rotate(2deg); }
  100% { opacity: 1; transform: scale(1) rotate(0); }
}
.badge-card-new {
  animation: badge-reveal 0.55s var(--ease);
  border-color: var(--gold);
  box-shadow: var(--shadow-card), 0 0 0 2px rgba(217, 180, 90, 0.35);
}
/* ---------- Bomb gift: delivery box / reveal / detonation ---------- */
.delivery-box-icon {
  font-size: 3.4rem;
  text-align: center;
  margin-bottom: 0.3rem;
}
.delivery-title { text-align: center; }
.delivery-sub { text-align: center; color: var(--muted); margin-bottom: 0.4rem; }

/* The beat between tapping "Open It" and detonation — full-screen, above
   every normal modal, on purpose not a .modal itself (see index.html). */
.bomb-reveal {
  position: fixed;
  inset: 0;
  z-index: 9000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 8, 10, 0.94);
}
.bomb-reveal-icon {
  font-size: 6rem;
  line-height: 1;
  animation: badge-reveal 0.55s var(--ease), bomb-tick 0.4s ease-in-out 0.55s 3;
}
@keyframes bomb-tick {
  0%, 100% { transform: rotate(0deg); }
  25% { transform: rotate(-12deg); }
  75% { transform: rotate(12deg); }
}

/* Generic utility — visually hides an element without display:none (which
   would stop it from being interactable via JS .click() in most engines,
   e.g. the iOS haptic switch below relies on this staying "present," just
   invisible/unreachable). Standard clip-based screen-reader-only pattern;
   aria-hidden + tabindex="-1" on the elements using this handle keeping
   assistive tech and keyboard focus off it too, since unlike a real sr-only
   label this element has no meaningful content to expose either way. */
.visually-hidden {
  position: fixed;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The detonation itself, then the frozen crack it leaves behind. Sits above
   #bomb-reveal (which is already hidden by the time this shows) and above
   #screen-transition — nothing in the app should be able to cover it.
   pointer-events: all + no dismiss control anywhere in here is what makes
   the freeze real: the only way out is to actually leave the app. */
.bomb-blast {
  position: fixed;
  inset: 0;
  z-index: 9100;
  pointer-events: all;
  overflow: hidden;
  /* Rapid taps on this exact rhythm (break the glass fast) is precisely
     the gesture a real touch browser can mistake for pinch-zoom or
     rubber-band overscroll with no touch-action set (the default is
     `auto`, every native gesture live) — this app already reaches for
     touch-action: none on every other "this element owns the whole
     gesture, don't let the browser also interpret it" surface (flair
     drag/resize, swipe-to-dismiss), just never on this one. A synthetic
     mouse-click Playwright test can't surface this since it doesn't run
     real touch-gesture recognition at all — only a real device would. */
  touch-action: none;
}
.bomb-blast-white,
.bomb-blast-glitch,
.bomb-blast-crack,
.bomb-blast-smoke {
  position: absolute;
  inset: 0;
  opacity: 0;
}
.bomb-blast-white { background: #fff; }
.bomb-blast-flash .bomb-blast-white {
  animation: blast-flash 0.35s ease-out forwards;
}
@keyframes blast-flash {
  0% { opacity: 0; }
  8% { opacity: 1; }
  100% { opacity: 0; }
}
/* Thin RGB-tinted scanlines, screen-blended so they only ever ADD light —
   sits behind the crack photo (painted first, same stacking context) so it
   reads as broken-circuitry static peeking through the glass shards rather
   than a layer competing with them. Kept subtle (low opacity) — this is
   texture, not the main event. A one-time fade-in only (2026-07-17,
   glitch-flicker's own infinite jump/flicker loop dropped per feedback: it
   read as the crack visibly "flipping"/jumping every 2.4s regardless of
   tap-to-break's own steadier, deliberate growing-filter escalation, which
   is the only thing that should visibly change with each tap). */
.bomb-blast-glitch {
  mix-blend-mode: screen;
  background: repeating-linear-gradient(
    0deg,
    rgba(255, 70, 90, 0.14) 0px,
    rgba(255, 70, 90, 0.14) 1px,
    rgba(70, 200, 255, 0.12) 2px,
    transparent 3px,
    transparent 7px
  );
}
.bomb-blast-cracked .bomb-blast-glitch {
  animation: crack-in 0.4s ease-out forwards;
}
.bomb-blast-clearing .bomb-blast-glitch {
  animation: crack-in 1.4s ease-in reverse forwards;
}
.bomb-blast-crack {
  /* Real cracked-glass photo. mix-blend-mode: screen only ADDS light
     (bright crack lines/shard highlights glow over the real screen, dark
     areas let content underneath show through) — kept here since it's
     honored in most engines and only helps where it works, but it can't be
     the ONLY thing making this see-through: WebKit/Safari has known
     inconsistencies compositing a blend mode across the stacking context a
     position:fixed ancestor like #bomb-blast establishes, and on a real
     iOS device this rendered as a flat, non-blended, effectively opaque
     image — the exact "not see-through" report this fixes. Real alpha
     transparency doesn't have that ambiguity in any engine, so
     crack-in-translucent (below) caps this layer's own opacity well under
     1 regardless of whether the blend mode landed — that alone guarantees
     the real app content keeps showing through everywhere, blend-mode
     support or not. Portrait source (785x1400) — cover+center crops to
     fill the viewport the same way the brick-wall theme's photo does (see
     PROFILE_THEMES's "brick" entry in js/accounts.js). */
  mix-blend-mode: screen;
  /* The source photo is a fairly flat, washed-out light gray — contrast()
     widens the gap between the bright intact-glass areas and the darker
     fracture lines/shadows so the cracks themselves read as distinct lines
     rather than a faint texture; brightness() nudged up alongside it so
     that contrast boost doesn't crush the midtones down too dark. */
  filter: contrast(1.55) brightness(1.08);
  /* will-change: filter (2026-07-17) — a mix-blend-mode:screen layer with
     an ANIMATED filter (the per-tap escalation below transitions it every
     0.15s) inside a position:fixed stacking context is exactly the
     combination WebKit is known to mishandle (see this rule's own comment
     above on the blend-mode compositing bug that motivated the opacity
     fallback) — without an upfront compositing-layer promotion hint,
     Safari can lazily re-promote/re-rasterize this layer mid-transition,
     which reads as the image briefly popping to a different size before
     settling — a real, if subtle, geometry change, not a filter update.
     Forcing the layer to exist from the start removes that mid-animation
     promotion moment. Desktop Chromium (what every Playwright test here
     runs against) doesn't reproduce this class of bug at all, which is why
     it never showed up in any of the automated verification for this
     feature. */
  will-change: filter;
  background-image: url("/play/icons/bomb-crack.jpg");
  background-size: cover;
  background-position: center;
}
.bomb-blast-cracked .bomb-blast-crack {
  animation: crack-in-translucent 0.5s ease-out forwards;
}
@keyframes crack-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes crack-in-translucent {
  from { opacity: 0; }
  to { opacity: 0.62; }
}
.bomb-blast-smoke {
  background: radial-gradient(circle at 50% 42%, rgba(140, 140, 140, 0.9), rgba(15, 15, 15, 0.97) 72%);
}
.bomb-blast-clearing .bomb-blast-smoke {
  animation: smoke-clear 1.4s ease-in forwards;
}
.bomb-blast-clearing .bomb-blast-crack {
  /* Must match crack-in-translucent (the animation that put it at 0.62 in
     the first place) — reversing the plain 0->1 crack-in here would jump
     the crack's opacity up to 1 before animating back down, a visible pop
     at the start of every clear. */
  animation: crack-in-translucent 1.4s ease-in reverse forwards;
}
@keyframes smoke-clear {
  0% { opacity: 0; }
  18% { opacity: 0.92; }
  100% { opacity: 0; }
}
/* Soft blurred blobs, not confetti — js/gifts.js's spawnSmokePuffs() drops
   a handful of these in with randomized size/position/drift/timing at the
   start of the clearing sequence. filter: blur() is what sells "smoke"
   over "circle" — a hard-edged dot reads as a particle, a heavily blurred
   radial gradient reads as a puff. Each puff owns its own opacity/scale/
   drift via smoke-puff-rise; this container itself never needs an opacity
   toggle — empty is already invisible, and clearCrack() clears it back out
   before the next detonation ever spawns a fresh set. */
.bomb-blast-smoke-puffs {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
.smoke-puff {
  position: absolute;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(195, 195, 195, 0.6) 0%, rgba(110, 110, 110, 0.4) 50%, transparent 75%);
  filter: blur(14px);
  opacity: 0;
  animation: smoke-puff-rise 2s ease-out forwards;
}
@keyframes smoke-puff-rise {
  0% { opacity: 0; transform: translate(0, 0) scale(0.5); }
  15% { opacity: 0.6; }
  100% { opacity: 0; transform: translate(var(--drift-x, 0px), -140px) scale(2.2); }
}

.bomb-cleared-message {
  text-align: center;
  font-size: 1.05rem;
  padding: 0.4rem 0;
}
.bomb-cleared-penalty {
  text-align: center;
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--red);
  margin: 0 0 0.2rem;
}

/* ---------- Tap-to-break (2026-07-17) — an active way out of the frozen
   crack that doesn't require leaving the app: js/gifts.js's handleGlassTap
   escalates --crack-progress (0-1) on #bomb-blast per tap, which this drives
   into a growing contrast/brightness push on the SAME .bomb-blast-crack
   photo — no second crack image needed, the glass just visibly stresses
   further under each hit. Passive relaunch/background-return clearing
   (clearCrack, above) is untouched; this is purely an additional, faster
   path that runs entirely while the app stays foregrounded. ---------- */
.bomb-blast-cracked .bomb-blast-crack {
  /* Filter-only, deliberately no transform (2026-07-17, a scale() push was
     here originally, tuned down from an even earlier +0.75 contrast/+0.12
     brightness) — repeated feedback was that ANY per-tap motion beyond the
     screen-shake read as the image itself "moving"/"popping" rather than a
     steady, readable stress reaction; even a small scale nudge was enough to
     trigger that. contrast()/brightness() alone still escalates visibly
     (confirmed against a realistic busy backdrop) without shifting a single
     pixel's position. */
  filter: contrast(calc(1.55 + var(--crack-progress, 0) * 0.15)) brightness(calc(1.08 + var(--crack-progress, 0) * 0.03));
  transition: filter 0.15s ease-out;
}

/* A quick, sharp shake per tap — same transform-only html-level approach as
   Shock Bomb's screen-shake below, just faster (0.18s vs 0.5s) since taps
   can land in rapid succession and each one needs to read as a distinct
   hit, not blur into the last. js/gifts.js's retriggerClass forces a reflow
   between taps so the animation actually restarts instead of no-op'ing when
   the class is already present.
   Amplitude reverted back to this original size (2026-07-17) — a smaller
   ±1.5px version shipped briefly on a theory (screen-shake reading as
   large on a high-DPI phone) that turned out not to be the actual "image
   moving" complaint; the smaller shake just felt weaker, with the real
   issue still unresolved. Back to this size since it's what actually felt
   right. */
@keyframes screen-shake-hit {
  0%, 100% { transform: translate(0, 0); }
  25% { transform: translate(-5px, 2px); }
  50% { transform: translate(4px, -3px); }
  75% { transform: translate(-3px, 2px); }
}
html.screen-shake-hit { animation: screen-shake-hit 0.18s linear; }

/* The payoff — js/gifts.js's shatterCrack tiles the SAME crack photo into a
   grid of .glass-shard pieces (spawnGlassShards computes each tile's own
   background-position/-size so together they reconstruct the whole image,
   same "slice one shared texture into pieces" idea spawnLightningBolts/
   spawnSmokePuffs already use for randomized-but-shared visual language),
   then lets each one fall away on its own randomized delay/drift/spin —
   .bomb-blast-crack itself is faded out from underneath the instant this
   spawns (bomb-blast-clearing's own crack-in-translucent reverse, below),
   so what's actually breaking apart IS the crack, not a copy laid over it. */
.bomb-blast-shards {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
.glass-shard {
  position: absolute;
  background-image: url("/play/icons/bomb-crack.jpg");
  background-repeat: no-repeat;
  mix-blend-mode: screen;
  /* drop-shadow (not box-shadow, which ignores clip-path) follows the actual
     jagged silhouette below, giving each piece a faint dark contact edge so
     it reads as a discrete cut shard instead of a flat, edgeless tile. */
  filter: contrast(2.1) brightness(1.15) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55));
  opacity: 0.62;
  animation: shard-fall 0.95s cubic-bezier(0.5, 0, 0.85, 1) forwards;
  animation-delay: var(--fall-delay, 0s);
  will-change: transform, opacity;
}
/* Eight jagged, irregular silhouettes (js/gifts.js's spawnGlassShards
   assigns one at random per tile) — each covers most but not all of its
   grid cell, so a piece's edges are angular cut lines rather than a plain
   rectangle. Deliberately never a simple 4-point right-angled quad. */
.glass-shard-shape-0 { clip-path: polygon(50% 0%, 100% 22%, 88% 100%, 8% 92%, 0% 28%); }
.glass-shard-shape-1 { clip-path: polygon(12% 0%, 100% 12%, 92% 100%, 0% 82%); }
.glass-shard-shape-2 { clip-path: polygon(0% 18%, 62% 0%, 100% 58%, 42% 100%, 0% 70%); }
.glass-shard-shape-3 { clip-path: polygon(32% 0%, 100% 0%, 72% 100%, 0% 62%); }
.glass-shard-shape-4 { clip-path: polygon(0% 0%, 78% 0%, 100% 100%, 18% 88%); }
.glass-shard-shape-5 { clip-path: polygon(22% 0%, 100% 28%, 82% 100%, 0% 100%); }
.glass-shard-shape-6 { clip-path: polygon(0% 8%, 68% 0%, 100% 82%, 28% 100%); }
.glass-shard-shape-7 { clip-path: polygon(8% 0%, 92% 8%, 100% 92%, 38% 100%, 0% 58%); }
@keyframes shard-fall {
  0% { transform: translate(0, 0) rotate(0deg); opacity: 0.62; }
  12% { transform: translate(calc(var(--fall-x, 0px) * 0.12), 4px) rotate(calc(var(--fall-rot, 0deg) * 0.15)); opacity: 0.62; }
  100% { transform: translate(var(--fall-x, 0px), 130vh) rotate(var(--fall-rot, 0deg)); opacity: 0; }
}

/* The tap hint + progress meter — HUD-style, pointer-events: none (taps land
   on #bomb-blast itself, this is display only), fades in a beat after the
   crack settles (0.9s — after crack-in-translucent's own 0.5s reveal) so it
   doesn't compete with the initial detonation beat, and fades back out the
   moment shatterCrack fires rather than just vanishing with everything else
   at once. */
.bomb-blast-tap-hud {
  position: absolute;
  left: 50%;
  bottom: 12%;
  transform: translateX(-50%);
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  pointer-events: none;
  opacity: 0;
  text-align: center;
}
.bomb-blast-cracked .bomb-blast-tap-hud {
  animation: tap-hud-fade-in 0.6s ease-out 0.9s forwards;
}
.bomb-blast-cracked .bomb-blast-tap-hud.hud-hidden {
  animation: tap-hud-fade-out 0.3s ease-out forwards;
}
@keyframes tap-hud-fade-in {
  from { opacity: 0; transform: translateX(-50%) translateY(6px); }
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes tap-hud-fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}
.bomb-blast-tap-hint {
  margin: 0;
  font-size: 0.82rem;
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.85);
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.bomb-blast-tap-meter {
  width: 140px;
  height: 4px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.18);
  overflow: hidden;
}
.bomb-blast-tap-meter-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, #d9b45a, #ff5a4d);
  transition: width 0.15s ease-out;
}

/* ---------- Shock Bomb: reveal / detonation (2026-07-17) ----------
   Mirrors Bomb's reveal/blast shape (same z-index layers, same "not a
   .modal" no-dismiss-during-playback rule) but themed electric instead of
   explosive, and — unlike Bomb's blast — NOT a persistent frozen state:
   the "everything spilled" consequence is already final and server-
   confirmed by the time this plays (js/gifts.js's openShockBombGift/
   triggerShockDetonation), so the whole sequence runs once and clears
   itself into #modal-shock-cleared, no relaunch-to-resume logic needed. */
.shock-reveal {
  position: fixed;
  inset: 0;
  z-index: 9000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 8, 10, 0.94);
}
.shock-reveal-icon {
  font-size: 6rem;
  line-height: 1;
  filter: drop-shadow(0 0 18px rgba(35, 211, 205, 0.85));
  animation: badge-reveal 0.55s var(--ease), shock-tick 0.35s ease-in-out 0.55s 4;
}
/* Sharp flicker/pulse, not bomb-tick's rotate wobble — reads as "crackling
   with charge" rather than "about to physically go off." */
@keyframes shock-tick {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.55; transform: scale(0.92); }
}

.shock-blast {
  position: fixed;
  inset: 0;
  z-index: 9100;
  pointer-events: all;
  overflow: hidden;
  background: #000;
}
.shock-blast-flash-layer {
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at 50% 50%, rgba(180, 240, 255, 0.95) 0%, rgba(35, 211, 205, 0.55) 40%, rgba(8, 8, 10, 0.92) 80%);
  opacity: 0;
}
.shock-blast-flash .shock-blast-flash-layer {
  animation: shock-flash 0.9s ease-out forwards;
}
/* Two pulses, not one — a single flash reads as a photo/camera flash; a
   real electric surge stutters. Second pulse is dimmer, the "afterimage"
   before it settles to black for the alert underneath. */
@keyframes shock-flash {
  0% { opacity: 0; }
  6% { opacity: 1; }
  18% { opacity: 0.15; }
  30% { opacity: 0.85; }
  55% { opacity: 0.1; }
  100% { opacity: 0; }
}
.shock-blast-bolts {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
/* Individual bolts are spawned/positioned by js/gifts.js (randomized point/
   rotation/size/delay, same "generate a handful of varied instances at
   runtime" approach spawnSmokePuffs already uses for Bomb's smoke) —
   this is just the one shared shape + glow every bolt renders with. */
.lightning-bolt {
  position: absolute;
  width: 40px;
  height: 140px;
  background: linear-gradient(180deg, #fff 0%, #b4f0ff 40%, var(--teal) 100%);
  clip-path: polygon(35% 0%, 65% 0%, 45% 38%, 72% 38%, 28% 100%, 44% 54%, 18% 54%);
  filter: drop-shadow(0 0 10px rgba(180, 240, 255, 0.9)) drop-shadow(0 0 22px rgba(35, 211, 205, 0.7));
  opacity: 0;
  animation: bolt-strike 0.5s ease-out forwards;
}
@keyframes bolt-strike {
  0% { opacity: 0; }
  10% { opacity: 1; }
  22% { opacity: 0.2; }
  35% { opacity: 0.9; }
  100% { opacity: 0; }
}
/* Screen shake — toggled on document.documentElement (not just the blast
   overlay) so the whole visible app rattles, not only the flash layer
   sitting on top of it. transform-only, reverts cleanly, no scroll/layout
   side effects. */
@keyframes screen-shake {
  0%, 100% { transform: translate(0, 0); }
  10% { transform: translate(-6px, 3px); }
  20% { transform: translate(5px, -4px); }
  30% { transform: translate(-7px, -2px); }
  40% { transform: translate(6px, 4px); }
  50% { transform: translate(-4px, 3px); }
  60% { transform: translate(7px, -3px); }
  70% { transform: translate(-5px, 2px); }
  80% { transform: translate(4px, -3px); }
  90% { transform: translate(-3px, 1px); }
}
html.shock-screen-shake { animation: screen-shake 0.5s linear; }

/* Badge Library's locked side — the icon is visible (it's not a secret which
   emoji a badge uses), the name/blurb are, so this is deliberately inert. */
.badge-card-locked {
  cursor: default;
  opacity: 0.55;
}
.badge-card-locked .badge-card-icon { filter: grayscale(0.6); }

/* HQ's grant roster (js/accounts.js's buildBadgeManageGridHtml/
   buildFlairManageGridHtml) — every card is .badge-card-locked (dark)
   uniformly; .badge-card-owned is the one thing that varies, a quiet ring +
   checkmark so HQ can tell what will be owned after Save without the grid
   lighting up into a second visual language. Tap-to-toggle + Save Changes
   (2026-07-30, replaced the old dropdown + Gift button pair): tapping ANY
   card — owned or not — queues a pending change; .badge-card-owned itself
   already reflects the FUTURE state (will be owned after Save), so the
   pending ring's color is a separate class keyed to which DIRECTION this
   is (.badge-card-pending-grant/-revoke), not derived from .badge-card-
   owned's own value — see that function's own comment for why a single
   generic pending class couldn't tell those two apart. */
.badge-manage-card,
.flair-manage-card {
  position: relative;
}
.badge-card-owned.badge-card-locked {
  opacity: 0.85;
  border-color: var(--gold);
  box-shadow: 0 0 0 1px var(--gold);
}
.badge-card-owned .badge-card-icon {
  filter: none;
}
/* Same specificity as .flair-manage-card.badge-card-locked
   .flair-manage-card-image above (3 classes) so this override actually
   wins — a lower-specificity rule here would lose to that one regardless
   of source order. */
.flair-manage-card.badge-card-owned .flair-manage-card-image {
  filter: none;
  opacity: 1;
}
.badge-card-owned-dot {
  position: absolute;
  top: 0.3rem;
  right: 0.3rem;
  width: 1rem;
  height: 1rem;
  border-radius: 50%;
  background: var(--gold);
  color: var(--black);
  font-size: 0.6rem;
  line-height: 1rem;
  text-align: center;
}
/* A queued change (not yet saved) — teal for "will be granted" (wasn't
   owned, now armed to become so), red for "will be revoked" (was owned,
   now armed to lose it) — same armed-drop-mode color contract the World
   map's own Drop menu already established (teal = additive, red = the
   mission/critical one), reused here rather than inventing a third meaning
   for either color. */
.badge-manage-card.badge-card-pending-grant,
.flair-manage-card.badge-card-pending-grant {
  opacity: 0.85;
  border-color: var(--teal);
  box-shadow: 0 0 0 1px var(--teal);
}
.badge-manage-card.badge-card-pending-revoke,
.flair-manage-card.badge-card-pending-revoke {
  border-color: var(--red);
  box-shadow: 0 0 0 1px var(--red);
}
.hq-grant-panel { margin-top: var(--space-2); }
.hq-grant-panel .search-input { margin-bottom: var(--space-2); }
.hq-grant-save-btn { width: 100%; margin: 0.6rem 0 var(--space-2); }

/* ---------- Badge lightbox ---------- */
.badge-detail-icon {
  width: 5.2rem;
  height: 5.2rem;
  border-radius: 50%;
  background: var(--card-2);
  border: 2px solid var(--card-2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2.4rem;
  margin: 0 auto var(--space-3);
}
.badge-detail-name {
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--text-xl);
  text-align: center;
  margin: 0;
  color: var(--white);
}
.badge-detail-blurb {
  color: var(--white);
  font-size: var(--text-sm);
  line-height: 1.55;
  text-align: center;
  margin: var(--space-3) 0 0;
}
.badge-detail-blurb-locked {
  color: var(--muted);
  font-style: italic;
}
#badge-detail-holders-list .roster-row { cursor: pointer; }

/* ---------- Badge/flair celebration modal ---------- */
.celebrate-card { text-align: center; }
.celebrate-icon {
  width: 6rem;
  height: 6rem;
  border-radius: 50%;
  background: var(--profile-color, var(--card-2));
  border: 2px solid var(--card-2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2.8rem;
  margin: 0 auto var(--space-3);
}
.celebrate-image {
  width: 6rem;
  height: 6rem;
  object-fit: contain;
  display: block;
  margin: 0 auto var(--space-3);
}
.celebrate-name {
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--text-xl);
  text-align: center;
  margin: 0;
  color: var(--white);
}
.celebrate-blurb {
  color: var(--white);
  font-size: var(--text-sm);
  line-height: 1.55;
  text-align: center;
  margin: var(--space-3) 0 0;
}

/* Own Profile's "see the rest of the catalog" tile — the grid's own last
   item, not a header button, and deliberately NOT styled like an earned or
   locked badge card (dashed border + gold instead of the solid card
   treatment) so it reads as its own kind of thing: an action, not a badge. */
.badge-more-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.35rem;
  padding: 0.7rem 0.4rem;
  border-radius: var(--radius-md);
  background: none;
  border: 1.5px dashed var(--card-2);
  color: var(--gold);
  cursor: pointer;
  transition: transform 0.1s var(--ease), border-color 0.15s var(--ease);
}
.badge-more-tile:active { transform: scale(0.96); }
.badge-more-tile:hover,
.badge-more-tile:focus-visible { border-color: var(--gold); }
.badge-more-tile-label {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}


/* ---------- Profile card (Instagram-style) ----------
   The Profile tab's own view of yourself. First real circular-avatar
   treatment in the app — everywhere else an avatar is just an inline emoji
   character with no container, but a profile header reads as homemade
   without one, so this is a new primitive rather than reusing .agent-avatar. */
/* --profile-color is set inline (accounts.js) on the whole profile screen
   (#tab-profile / #agent-profile-content), not this card — it inherits down
   to the card, badge grid, and everything else on the page so the color
   reads as a profile background rather than just a card fill. Comes from
   accounts/{uid}/profileColor, a progress-gated cosmetic (unlocks after 1
   completed mission, see database.rules.json). Falls back to transparent so
   an unset/locked profile looks exactly like it always has. */
.profile-card {
  position: relative;
  overflow: hidden;
  text-align: left;
  padding: var(--space-3);
  background-color: var(--profile-color, transparent);
  background-image: var(--profile-pattern, none);
  background-position: var(--profile-pattern-position, 0 0);
  background-size: var(--profile-pattern-size, auto);
  background-repeat: var(--profile-pattern-repeat, repeat);
  border-radius: var(--radius-lg);
  transition: background-color 0.25s var(--ease);
}
/* The card itself always runs almost-black over the theme's own pattern —
   independent of which textured theme this is (brick/terminal/caution) —
   so it reads as a calm dark plate rather than the full pattern at reading
   size (which came across as abrasive vs. the same pattern working fine
   as a big background texture). 0.85 rather than fully opaque black: a
   faint whisper of the theme's own color still shows through, so the
   header stays visually tied to its theme instead of collapsing into one
   identical dark rectangle across all of them. Layered in front of
   --profile-pattern (first background-image listed paints frontmost), not
   baked into any one theme's own pattern value, so it applies uniformly —
   the tab/Board-frame area AROUND this card, which reads --profile-pattern
   directly and not through this rule, stays at the theme's full vividness.
   Prepending "0 0" / "100% 100%" / "no-repeat" ahead of each var() below
   isn't decorative — it's a real extra background-image layer (the scrim
   above), so background-position/size/repeat need a matching extra leading
   value or every layer after it silently shifts one index off its intended
   value once --profile-pattern itself has more than one layer (brick's
   vignette + tile image). var() substitutes as raw text, so the "0 0, "
   prefix plus a multi-value --profile-pattern-position both land in the
   same comma list correctly. */
.profile-theme-textured .profile-card {
  background-image: linear-gradient(rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.85)), var(--profile-pattern, none);
  background-position: 0 0, var(--profile-pattern-position, 0 0);
  background-size: 100% 100%, var(--profile-pattern-size, auto);
  background-repeat: no-repeat, var(--profile-pattern-repeat, repeat);
}
/* A tiling pattern's "ghost through the card" above works because a
   repeat has no unique focal point — a few pixels of misalignment between
   the card's own copy and the container's copy is imperceptible. A single
   centered hero image (Dead Mime's spiral) very much has one, so the same
   trick reads as two overlapping, misaligned spirals instead of one
   texture. profile-pattern-hero (toggled alongside profile-theme-textured
   whenever a theme's patternRepeat is "no-repeat") drops the pattern from
   the card entirely — solid dark plate, no second copy — so the single
   true image lives only on the tab/Board-frame container around it. Half
   opacity (not the 0.9 other textured themes' cards get) is deliberate
   here: the card has no background-image of its own to blend into, so
   what shows through is the ONE real spiral sitting on the container
   behind it — exactly the "one image, not two" the .profile-card fix
   above was already going for, just less opaque about it. Chip legibility
   isn't at risk the way a translucent chip would be — codename/stat/bio
   chips are their own solid #1c1c1c surface (PROFILE_THEMES in
   accounts.js), independent of whatever the card shows behind them. */
.profile-theme-textured.profile-pattern-hero .profile-card {
  background-image: none;
  background-color: rgba(0, 0, 0, 0.5);
}
/* Soft drifting fog/smoke over the spiral scene — a handful of blurred,
   semi-transparent white blobs, slowly drifting via one shared animation.
   pointer-events: none is load-bearing: this sits above the card/tabs/
   board in paint order (z-index below), so without it every tap on the
   avatar, stat chips, tab buttons, or pinned board items would hit the
   fog instead of the real control.
   Scoped to profile-pattern-hero specifically (not every textured theme)
   — this is a Dead Mime mood, not a brick/terminal/caution/blueprint one.
   Positioned on the same container as the pattern itself (#tab-profile /
   #agent-profile-content) so it drifts across the whole scene, not just
   the card.
   z-index 1 keeps the fog UNDER the board's opaque wood surface without
   having to fight profile-board.js's own internal z-index scheme (its
   pinned nodes/strings use 2-4) — .profile-subtab-panel below gets lifted
   to a new stacking context at z-index 2, so everything inside it,
   whatever z-index it uses internally, stays above the fog as a group;
   the fog itself never needs to know that scheme exists. */
.profile-pattern-hero {
  position: relative;
}
.profile-pattern-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background-image: radial-gradient(ellipse 55% 38% at 18% 12%, rgba(236, 231, 216, 0.16), transparent 65%),
    radial-gradient(ellipse 50% 34% at 84% 22%, rgba(236, 231, 216, 0.13), transparent 68%),
    radial-gradient(ellipse 65% 42% at 28% 66%, rgba(236, 231, 216, 0.11), transparent 62%),
    radial-gradient(ellipse 48% 30% at 78% 82%, rgba(236, 231, 216, 0.14), transparent 65%),
    radial-gradient(ellipse 60% 40% at 50% 45%, rgba(236, 231, 216, 0.06), transparent 70%);
  mix-blend-mode: screen;
  animation: profile-fog-drift 46s ease-in-out infinite alternate;
}
@keyframes profile-fog-drift {
  0% { opacity: 0.75; transform: translate(0, 0) scale(1); }
  50% { opacity: 1; transform: translate(2.5%, -2%) scale(1.05); }
  100% { opacity: 0.8; transform: translate(-2%, 1.5%) scale(1.02); }
}
.profile-pattern-hero .profile-card,
.profile-pattern-hero .customize-tabs,
.profile-pattern-hero .profile-subtab-panel {
  position: relative;
  z-index: 2;
}
/* inline-block so .profile-card-header (a flex row) can lay it out as a
   flex item without an explicit block conversion. */
.profile-avatar-wrap {
  position: relative;
  display: inline-block;
  flex-shrink: 0;
}
.profile-card-avatar {
  width: 4.2rem;
  height: 4.2rem;
  border-radius: 50%;
  background: var(--card);
  border: 2px solid var(--card-2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.8rem;
}
/* ---------- Compact profile header (avatar + codename + stats side by
   side instead of stacked) — shared by the self Profile tab and
   openAgentProfile()'s template. A plain flex row, not a new "section": it
   only rearranges the same avatar/codename/stats elements that used to sit
   in one centered column, so the badge grid straight after it lands much
   sooner on screen. */
.profile-card-header {
  display: flex;
  align-items: center;
  gap: 0.8rem;
}
.profile-card-header-info {
  flex: 1;
  min-width: 0;
}
.profile-card-header-info .profile-card-name-row {
  justify-content: flex-start;
}
.profile-card-header-info .profile-card-codename {
  font-size: var(--text-lg);
}
.profile-card-header-info .profile-card-stats {
  justify-content: flex-start;
  flex-wrap: wrap;
  gap: 0.4rem 0.9rem;
  margin: 0.3rem 0 0;
}
.profile-card-header-info .profile-card-stat {
  flex: none;
  max-width: none;
  text-align: left;
  border-right: none;
}
/* Small round dark-glass button pinned to the profile-card's own bottom-
   right corner, same treatment as the Board's own edit FAB
   (.corkboard-edit-fab, css/corkboard.css) — that design already solves
   the exact problem this one has: staying legible sitting directly on top
   of any of the seven profile-card patterns/textures, without needing to
   know or care which theme is active. .profile-card's own overflow:
   hidden clips it neatly to the card's rounded corner. */
.profile-card-edit-fab {
  position: absolute;
  bottom: var(--space-2);
  right: var(--space-2);
  z-index: 3;
  width: 2rem;
  height: 2rem;
  border-radius: 999px;
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(var(--card-rgb), 0.78);
  color: var(--white);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
  cursor: pointer;
}
.profile-card-edit-fab:active { transform: scale(0.92); }

/* Board vs. Dossier — a two-state switch (sliding thumb), not a row of
   buttons: now that Edit Profile has moved to the card's own corner FAB
   above, this row only ever holds exactly two options, and a switch reads
   as what that actually is. Themed via --profile-chip-bg/--profile-chip-
   text (same vars PROFILE_THEMES/applyProfileColor set for the codename/
   stat chips, accounts.js) with plain --card-2/--muted fallbacks, so it's
   legible on both the five textured profile themes and the two flat ones
   without needing its own theme-awareness logic — falls back to exactly
   the old plain look when no theme (or a flat one) is active. */
/* .customize-tabs.profile-subtabs, not just .customize-tabs: this element
   also always carries .admin-section-gap (class="customize-tabs
   profile-subtabs admin-section-gap" in both index.html and accounts.js's
   openAgentProfile template), whose own display:block rule — same 0,1,0
   specificity, declared later in this file — was silently winning the
   cascade and left .customize-tabs rendering as a block all along. The old
   3-button design never revealed this (three bordered buttons look like a
   row regardless of whether they're actually flex-stretched), but the
   sliding ::before thumb below computes its position from the CONTAINER's
   width while flex:1 has zero effect on children of a display:block
   parent — the buttons sat at their own content width while the thumb
   floated at some independently-computed percentage, matching neither. */
.customize-tabs.profile-subtabs {
  position: relative;
  display: flex;
  padding: 3px;
  border-radius: 999px;
  background: var(--profile-chip-bg, var(--card-2));
  margin-bottom: var(--space-3);
}
/* The sliding thumb — a real element, not a background-position trick, so
   its own transform:translateX transition can animate independently of
   the buttons' color/opacity transition. Sized to exactly one of two
   equal slots (calc(50% - 3px) accounts for the track's own 3px inset on
   each side) and slid via .subtab-dossier-active, toggled alongside
   .is-active in switchProfileSubtab/switchAgentProfileSubtab
   (accounts.js) rather than computed from CSS :has() — broader browser
   support, and the switch functions already touch every button's class
   on every call anyway. */
.customize-tabs::before {
  content: "";
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  border-radius: 999px;
  background: var(--red);
  transition: transform 0.25s var(--ease);
}
.customize-tabs.subtab-dossier-active::before { transform: translateX(100%); }
/* .subtabs-3 — the own-profile bar only (#profile-subtabs: Board/Dossier/
   Inventory), NOT #agent-profile-subtabs (still just Board/Dossier — see
   index.html, Inventory is self-view only). translateX is a percentage of
   the THUMB's own box, not the track's, so translateX(100%)/(200%) still
   correctly read as "one slot over"/"two slots over" once the thumb itself
   is resized to a third of the track below — no different a mechanism from
   the two-slot case above, just one more stop along the same track. */
.customize-tabs.subtabs-3::before { width: calc(33.333% - 3px); }
.customize-tabs.subtabs-3.subtab-dossier-active::before { transform: translateX(100%); }
.customize-tabs.subtabs-3.subtab-inventory-active::before { transform: translateX(200%); }
.customize-tab {
  position: relative;
  z-index: 1;
  flex: 1;
  padding: 0.4rem 0.6rem;
  border: none;
  border-radius: 999px;
  background: transparent;
  color: var(--profile-chip-text, var(--muted));
  opacity: 0.7;
  font-family: var(--mono);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: color 0.2s var(--ease), opacity 0.2s var(--ease);
}
.customize-tab.is-active { opacity: 1; color: var(--profile-chip-text, var(--white)); }

/* ---------- Profile Board (Board/Dossier sub-tabs) ----------
   js/profile-board.js ports js/corkboard.js's pin/drag/string/note board
   into the real Profile tab and agent-profile screen; css/corkboard.css's
   classes (enamel-pin badges, frameless stickers, sticky notes, tray,
   storage box, lightbox) are reused completely unmodified for it. These
   rules only adapt the OUTER wrapper to sit inside a normal scrolling tab
   panel instead of corkboard.css's own full-viewport standalone page (see
   .corkboard-app's display:flex/min-height:100vh, sized for a page that
   IS the whole screen). .profile-board-app — not .corkboard-app itself —
   is the selector on purpose: cork/index.html (still deployed as-is at
   spy.show/cork/) loads this same app.css, and an unscoped override here
   would leak into that untouched standalone prototype too.
   .corkboard-app.profile-board-app (both classes), not .profile-board-app
   alone: index.html/backend/index.html load app.css BEFORE corkboard.css,
   so a plain single-class .profile-board-app rule has the SAME specificity
   as corkboard.css's own .corkboard-app base rule and loses the cascade
   purely on load order — the element was actually rendering at corkboard.
   css's full-viewport min-height:100vh in production despite this rule
   existing. Stacking both classes on the selector outranks corkboard.css's
   single-class rule regardless of which stylesheet loads second. */
.corkboard-app.profile-board-app {
  display: block;
  min-height: 0;
}
.profile-subtabs { margin-top: var(--space-4); }
.profile-subtab-panel { margin-top: var(--space-3); }
/* corkboard.css's own horizontal margins on these were sized for its
   standalone page, which has no side padding of its own — .tab-panel and
   .agent-profile-screen already contribute that here, so the same margin
   applied on top would double up as extra indent past the rest of the tab. */
.profile-board-app .corkboard-stage,
.profile-board-app .corkboard-tray-wrap {
  margin-left: 0;
  margin-right: 0;
}
/* corkboard.css's .corkboard-stage is deliberately overflow:visible now (a
   large-displayScale sticker, up to 1000%, is meant to bleed past the
   board) — the right call for the standalone cork/ page (open space all
   around) and the HQ preview modal (its own isolated stacking context),
   but wrong here: this board sits directly under the profile header inline,
   or right below the Done/Clear topbar in the dedicated editor
   (.board-editor-screen), and an oversized sticker bleeding into either is
   a real collision, not a feature — reported live as a sticker's edge
   drawn straight over the profile stats/bio. .profile-board-app's own
   class persists through enterEditMode/exitEditMode's relocation (the
   element itself moves, its classes don't change), so this one selector
   covers the inline view, the agent-profile view, AND the dedicated editor
   uniformly. */
.profile-board-app .corkboard-stage {
  overflow: hidden;
}
/* corkboard.css's sticky-note/lightbox-note font-family is Caveat (cursive)
   — cork/index.html (untouched, still deployed standalone) loads that font
   itself and the look is intentional there. index.html/backend/index.html
   never loaded it, so here it was silently falling back to the browser's
   generic "cursive," not actually rendering Caveat at all — scoped overrides
   here, not a corkboard.css edit, so the standalone page keeps its real,
   working Caveat look untouched. .profile-board-lightbox is a marker class
   profile-board.js adds at creation time (the lightbox is a fixed-position
   <body> child, not a descendant of .profile-board-app). */
.profile-board-app .corkboard-note-card,
.profile-board-lightbox .corkboard-lightbox-note {
  font-family: var(--body);
}
/* Profile color picker — the "progress-gated cosmetic" convention (see
   missionsCompletedCount in functions/missions.js), applied per-swatch
   rather than to the whole row since three themes (Classic/HQ Red/Brick)
   are free from signup and the rest unlock together; future gated cosmetics
   in the same family should reuse .color-swatch/.is-locked. */
.color-swatch-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: var(--space-2) 0 var(--space-3);
}
.color-swatch {
  width: 2.6rem;
  height: 2.6rem;
  border-radius: 50%;
  border: 2px solid var(--card-2);
  padding: 0;
  cursor: pointer;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}
.color-swatch.is-selected { border-color: var(--white); }
/* Each theme swatch shows both its colors — the ring is the theme's
   background, this inner dot is its text color — so picking one previews
   the actual contrast, not just a flat color chip. */
.color-swatch-dot {
  width: 0.7rem;
  height: 0.7rem;
  border-radius: 50%;
}
.color-swatch.is-locked { opacity: 0.35; cursor: not-allowed; }

/* Mission map-pin picker (HQ mission form, js/admin.js) — reuses
   .color-swatch/.color-swatch-row wholesale (same circular-button shape/
   selection treatment as the profile theme picker above), just filled
   solid with the actual pin color instead of a two-tone ring+dot preview —
   a map pin has no separate "text color" to preview. The one addition is
   this "Default" swatch: unlike profile themes (always some real theme
   selected), a mission's pin color is optional, so there needs to be an
   explicit "no override" option in the row itself, styled to read as
   "empty" rather than as a color choice of its own. */
.mission-pin-picker { display: flex; flex-direction: column; gap: 0.6rem; }
.color-swatch-default {
  background: var(--card);
  border-style: dashed;
  color: var(--muted);
  font-size: 1.1rem;
}
/* Live sticker sizing/pin preview (#modal-flair-preview) — a self-contained
   corkboard-style stage. Only .corkboard-board (the cork texture) is
   borrowed from css/corkboard.css; the stage's own geometry is kept
   independent so it doesn't fight that file's flex/min-vh rules, which
   assume a full-page layout this modal doesn't have. Deliberately NOT
   sized to fit the top of the displayScale range (up to 1000%, 760px) —
   overflow is visible on purpose (see below) so a huge sticker bleeds past
   the frame instead of getting invisibly capped by however big this box
   happens to be, same as the real corkboard now does. */
.flair-preview-stage {
  position: relative;
  margin: 0.5rem 0 0;
  min-height: 280px;
  border-radius: var(--radius-md);
  overflow: visible;
  box-shadow: inset 0 0 0 8px #5b3a20, inset 0 0 0 11px #2e1c0f;
}
/* #modal-flair-preview's own .modal-card, not every modal — most rely on
   modal-card's overflow-y:auto to scroll long content, but that would clip
   .flair-preview-stage's intentional overflow right back off before it
   ever reaches the fixed, unclipped .modal backdrop behind it. */
#modal-flair-preview .modal-card {
  overflow: visible;
}
/* Sized in px from js/admin.js's applyFlairPreviewScale (76px base ×
   displayScale/100, matching css/corkboard.css's .corkboard-node-sticker-img
   base size) — dragging anywhere on it (or the tack) sets pinX/pinY as a
   percentage of this box, same convention the real corkboard tack uses. */
.flair-preview-sizer {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 76px;
  height: 76px;
  transform: translate(-50%, -50%);
  touch-action: none;
  cursor: grab;
}
.flair-preview-sizer:active { cursor: grabbing; }
.flair-preview-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
  pointer-events: none;
}
.theme-preview-img {
  display: block;
  width: 100%;
  max-width: 220px;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-radius: 10px;
  margin-top: 0.5rem;
}
.flair-preview-tack {
  position: absolute;
  top: 20%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 15px;
  height: 15px;
  border-radius: 999px;
  background: radial-gradient(circle at 35% 30%, #ff5b5b, #b3141f 65%, #7a0d16);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
  cursor: grab;
  touch-action: none;
  z-index: 4;
}
.flair-preview-tack:active { cursor: grabbing; }
/* Corner resize grip — drag toward/away from the stage center to scale.
   Visual dot stays small so it doesn't dominate a 76px sticker, but its
   touch target (::before) is a lot bigger, same pattern as
   css/corkboard.css's .corkboard-tack. */
.flair-preview-resize {
  position: absolute;
  right: -9px;
  bottom: -9px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--white);
  border: 2px solid var(--red);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
  cursor: nwse-resize;
  touch-action: none;
  z-index: 5;
}
.flair-preview-resize::before {
  content: "";
  position: absolute;
  inset: -10px;
}
/* A large enough displayScale bleeds the sizer well past the stage's own
   box now (overflow is intentionally visible, see .flair-preview-stage) —
   positioned content always paints above plain in-flow content, so without
   this the slider/Cancel/Done could end up visually buried under an
   oversized sticker. Their own stacking context wins them back the top. */
#modal-flair-preview .field,
#modal-flair-preview .modal-actions {
  position: relative;
  z-index: 1;
}
.field input[type="range"] {
  padding: 0.6rem 0.9rem;
  accent-color: var(--red);
}
/* ---------- Board Editor screen (full-screen "editor mode") ----------
   Dragging a pin/note/string inline in the normally-scrolling Profile tab fought the
   page's own scroll, main.js's swipe-to-change-tab gesture, AND text
   selection all at once — moving editing into its own non-scrolling screen,
   outside #screen-main's DOM subtree entirely, kills all three
   structurally instead of chasing each one with a runtime check. The board
   itself (js/profile-board.js's relocatable root) is a flex child here,
   restored to corkboard.css's OWN original full-page flex/min-height
   behavior — #board-editor-canvas .corkboard-app's ID-anchored specificity
   beats .corkboard-app.profile-board-app's block/min-height:0 override
   above regardless of which stylesheet loaded second, same fix that rule's
   own comment describes. Once restored, the stage's flex:1 1 auto (never
   touched by that override) does the rest on its own: it grows to fill
   whatever's left after the topbar and the tray, board pinned to the top,
   tray pinned to the bottom, nothing to scroll past to reach either. */
.board-editor-screen {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.board-editor-screen .agent-profile-topbar { flex-shrink: 0; }
.board-editor-canvas {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 0 1.25rem calc(1rem + var(--safe-bottom));
}
#board-editor-canvas .corkboard-app {
  display: flex;
  flex-direction: column;
  min-height: 0;
  flex: 1 1 auto;
}
/* Editing is its own framework, its own rules: nothing in here should ever
   resolve as a text selection — that's what kept winning races against the
   drag/hold gestures and showing iOS's native copy/lookup callout on a long
   press. The one deliberate exception is the note card actually being typed
   into (contenteditable) — everything else, including a note card that ISN'T
   focused yet, stays locked down. */
.board-editor-screen,
.board-editor-screen * {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
.board-editor-screen .corkboard-note-card {
  -webkit-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
}
.profile-card-name-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}
.profile-card-codename {
  font-family: var(--profile-codename-font, var(--display));
  font-weight: 400;
  font-size: var(--text-xl);
  margin: 0;
  color: var(--profile-text-color, var(--white));
}
/* City pip (2026-07-17) — an agent's self-set city (accounts/{uid}/city),
   shown as a small glowing dot + label riding alongside the codename
   wherever a profile renders (own Profile tab, another agent's profile
   screen). Reuses the Active tab's live-dot-pulse glow (css/app.css's
   .status-dot) rather than a new animation — same "quietly alive" read.
   The whole pip stays hidden (.hidden, toggled in JS) for anyone who hasn't
   set a city, not everyone opts in. */
.profile-city-pip {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--profile-text-muted, var(--muted));
}
.profile-city-pip-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex: none;
  background: var(--teal);
  box-shadow: 0 0 6px var(--teal);
  animation: live-dot-pulse 1.6s ease-in-out infinite;
}
.profile-card-stats {
  display: flex;
  justify-content: center;
  margin: var(--space-4) 0 var(--space-2);
}
.profile-card-stat {
  flex: 1;
  max-width: 7rem;
  text-align: center;
  border-right: 1px solid var(--card-2);
}
.profile-card-stat:last-child { border-right: none; }
.profile-card-stat b {
  display: block;
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--text-lg);
  color: var(--profile-text-color, var(--white));
}
.profile-card-stat span {
  display: block;
  font-family: var(--mono);
  font-size: 0.56rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--profile-text-muted, var(--muted));
  margin-top: 0.15rem;
}
/* Clamped to 2 lines instead of growing the card with the full cover
   story — the badge grid right below is the actual destination, this is
   just a taste of it. */
.profile-card-bio {
  color: var(--profile-text-muted, var(--muted));
  font-size: var(--text-sm);
  line-height: 1.4;
  margin: var(--space-2) 0 0;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}

/* Textured profile themes (brick/terminal/caution — see PROFILE_THEMES in
   accounts.js) run a busy pattern behind the whole card, so plain colored
   text on top of it can land on a low-contrast patch of its own background
   (e.g. dark caution-tape text disappearing into a black stripe). Surfacing
   the codename/stats/bio on --profile-chip-bg/--profile-chip-text chips
   keeps them readable regardless of what's under them. Flat themes
   (classic, hq-red) never get this class — applyProfileColor only toggles
   it when the theme defines a pattern — so they keep the original
   plain-text-on-flat-color look untouched.
   Caution's chipBg is translucent (not solid), so the hazard stripe still
   shows through the chip itself — that alone isn't enough contrast against
   its own bright yellow half, so every chip's text also gets a dark
   text-shadow halo (same "stack a shadow so it survives an inconsistent
   backdrop" idea as the corkboard badge icons' outline, css/corkboard.css).
   Harmless on solid-chip themes (terminal/brick), just a bit of depth. */
.profile-theme-textured .profile-card-codename,
.profile-theme-textured .profile-card-stat {
  background: var(--profile-chip-bg, transparent);
  border-radius: 999px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.profile-theme-textured .profile-card-codename {
  padding: 0.3rem 0.9rem;
  color: var(--profile-chip-text, var(--profile-text-color, var(--white)));
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.7);
}
/* The city pip sits right next to the codename chip above, same busy
   backdrop — same text-shadow halo fix, no solid chip needed since it's
   already small/glowing enough to read on its own once it isn't fighting
   the pattern directly. */
.profile-theme-textured .profile-city-pip {
  color: var(--profile-chip-text, var(--profile-text-color, var(--white)));
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.7);
}
.profile-theme-textured .profile-card-stats { gap: 0.5rem; }
.profile-theme-textured .profile-card-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 0.4rem 0.9rem;
  border-right: none;
}
.profile-theme-textured .profile-card-stat b,
.profile-theme-textured .profile-card-stat span {
  color: var(--profile-chip-text, var(--profile-text-color, var(--white)));
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.7);
}
.profile-theme-textured .profile-card-bio {
  background: var(--profile-chip-bg, transparent);
  color: var(--profile-chip-text, var(--profile-text-muted, var(--muted)));
  padding: 0.45rem 0.7rem;
  border-radius: 8px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.7);
}
/* .btn-outline's transparent background + faint border (line ~636 above)
   reads fine against a flat theme color, but washes out against a textured
   theme's actual photo/pattern (reported directly: the Spy Store button
   unreadable over Brick Wall) — same problem the codename/stat/bio chips
   above solve, given the same fix: an opaque dark backdrop that holds up
   regardless of what's under it, rather than a per-theme color. */
.profile-theme-textured .btn-outline {
  background: rgba(0, 0, 0, 0.55);
  border-color: rgba(244, 242, 236, 0.4);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
}

.profile-card-link {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--gold);
  margin: var(--space-2) 0 0;
  text-decoration: none;
}

#profile-connect-action { margin-top: 0.4rem; }
.profile-hq-switch-row .btn { width: 100%; }

.empty-state {
  color: var(--muted);
  font-size: 0.9rem;
  font-style: italic;
}

/* Container capacity line (js/inventory.js's MAX_CONTAINER_SLOTS) — a plain
   informational line above a box/trash's content rows, not a warning, so it
   stays visually quiet (same muted tone as .empty-state) until the cap is
   actually hit. */
.inventory-slot-count {
  color: var(--muted);
  font-size: 0.8rem;
  margin: 0 0 0.5rem;
}

/* ---------- Mission cards ---------- */
.missions-list, .leaderboard-list {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
}

/* ---------- Skeleton loaders ---------- */
/* Static markup shown before the first Firebase snapshot lands, replacing
   what used to be a hardcoded "no data yet" empty-state paragraph — that text
   used to flash as if the field genuinely had nothing in it, for the split
   second before the real listener callback ran. Every render function below
   (renderMissions, renderLeaderboard, renderBadgeLibrary, thread open) already
   fully overwrites these containers' innerHTML on first snapshot, empty or
   not, so no JS changes were needed — the skeleton just sits there until that
   first overwrite happens. */
@keyframes skeleton-shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}
.skeleton-block {
  background: linear-gradient(100deg, var(--card) 40%, var(--card-2) 50%, var(--card) 60%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
  border-radius: var(--radius-sm);
}
@media (prefers-reduced-motion: reduce) {
  .skeleton-block { animation: none; }
}
.skeleton-mission-card {
  height: 88px;
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
  border-left: 4px solid var(--card-2);
}
.skeleton-leaderboard-row { height: 46px; }
.skeleton-badge-tile { aspect-ratio: 1; border-radius: var(--radius-md); }
.skeleton-chat-bubble {
  height: 2.4rem;
  border-radius: 14px;
  align-self: flex-start;
}
.skeleton-chat-bubble:nth-child(2) { align-self: flex-end; width: 55%; }
.skeleton-chat-bubble:nth-child(3) { width: 70%; }

/* Active tab's void state (design-overhaul-7.13) — the empty Active tab is
   the majority state across a whole run, so it points somewhere (the
   Missions tab's available board) instead of just reporting "nothing here." */
.mission-void {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  padding: 3.5rem 1.5rem;
  text-align: center;
}
.mission-void-icon {
  width: 44px;
  height: 44px;
  color: var(--muted);
  opacity: 0.35;
  animation: void-icon-pulse 4.5s ease-in-out infinite;
}
/* An occasional radar-style pulse, not a continuous breathing loop — the
   icon sits still most of the cycle, then briefly blips brighter/larger. */
@keyframes void-icon-pulse {
  0%, 78%, 100% { opacity: 0.35; transform: scale(1); }
  90% { opacity: 0.85; transform: scale(1.15); }
}
.mission-void-line {
  color: var(--muted);
  font-size: 0.85rem;
  margin: 0;
}
.mission-void-cta {
  margin-top: 0.3rem;
  font-family: var(--mono);
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--black);
  background: var(--teal);
  border: none;
  padding: 0.55rem 1.15rem;
  border-radius: 100px;
  cursor: pointer;
}
.mission-void-cta:active { opacity: 0.85; }

.mission-card {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-left: 4px solid var(--card-2);
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
  padding: var(--space-5);
  position: relative;
  overflow: hidden;
  box-shadow: var(--shadow-card);
}
@keyframes mission-arrive {
  from { opacity: 0; transform: translateY(6px) scale(0.98); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
.mission-card.mission-just-arrived {
  animation: mission-arrive 0.4s var(--ease);
}

/* Status color system — one border color + dot, consistent everywhere a
   mission card appears, so status reads without depending on which
   section/heading it happens to sit under. */
.mission-card.state-pending { border-left-color: var(--gold); }
.mission-card.state-available { border-left-color: var(--teal); }
.mission-card.state-active { border-left-color: var(--teal); }
.mission-card.state-complete { border-left-color: var(--success); opacity: 0.88; }
.mission-card.state-cancelled { border-left-color: var(--muted); opacity: 0.6; }

.mission-status {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin-bottom: 0.45rem;
}
.mission-status .status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.state-pending .mission-status { color: var(--gold); }
.state-pending .status-dot { background: var(--gold); }
.state-available .mission-status,
.state-active .mission-status { color: var(--teal); }
.state-available .status-dot,
.state-active .status-dot { background: var(--teal); }
.state-complete .mission-status { color: var(--success); }
.state-complete .status-dot { background: var(--success); }
.state-cancelled .mission-status { color: var(--muted); }
.state-cancelled .status-dot { background: var(--muted); }
.mission-card-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 0.75rem;
  margin-bottom: 0.5rem;
}
/* The corner .stamp (Complete/Cancelled) is absolutely positioned over the
   card's top-right — exactly where "+N pts" also renders via this row's
   justify-content:space-between, so the two rendered directly on top of
   each other into an illegible tangle. Push the whole head row down clear
   of the stamp's box instead of trying to dodge just the points span. */
.mission-card.state-complete .mission-card-head,
.mission-card.state-cancelled .mission-card-head {
  margin-top: 1.1rem;
}
.mission-title {
  font-family: var(--display);
  font-weight: 400;
  font-size: 1.15rem;
  margin: 0;
  color: var(--white);
}
.mission-points {
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--gold);
  white-space: nowrap;
  flex-shrink: 0;
  padding-top: 0.2rem;
}
.mission-briefing {
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1.5;
  margin: 0 0 0.9rem;
}
.mission-actions { display: flex; gap: 0.6rem; }

.stamp {
  position: absolute;
  top: 0.9rem;
  right: 1.1rem;
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.08em;
  color: var(--gold);
  border: 1.5px solid var(--gold);
  border-radius: 3px;
  padding: 0.15rem 0.45rem;
  transform: rotate(6deg);
  text-transform: uppercase;
}

.time-sensitive-tag {
  color: var(--red) !important;
  border-color: var(--red) !important;
}
.stamp.stamp-cancelled {
  color: var(--muted);
  border-color: var(--muted);
}

.signup-countdown {
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--red);
  margin: 0 0 0.7rem;
}
.signup-countdown.countdown-expired { color: var(--muted); }

.mission-rendezvous {
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.8rem 1rem;
  margin-bottom: 0.9rem;
}
.mission-countdown {
  font-family: var(--display);
  font-size: 1.6rem;
  color: var(--red);
  letter-spacing: 0.02em;
  line-height: 1.2;
}
.mission-countdown.countdown-expired { color: var(--muted); }
/* One line, always (2026-07-30 fix) — a long location label used to wrap
   the "Open in Maps" link onto its own line, inconsistent with every other
   short one-line meta row on the card. The label truncates with an ellipsis
   instead; the now-short "(Maps)" link stays fixed at the end and never
   wraps or shrinks. */
.mission-location {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--white);
  margin: 0.3rem 0 0;
}
.mission-location-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
/* Docket framework: a docketed-but-not-yet-unlocked mission (Locked/Waiting,
   js/missions.js's unlockStateFor) explains why its proof button is missing
   right here, rather than leaving a silent gap where "Enter Field Code"/
   "Mark Complete" would normally be. Locked reads as a plain fact (muted);
   Waiting gets the same gold "later" treatment the rest of the app already
   uses for anything booked but not yet due (Design Goal 5). */
.mission-lock-line {
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--muted);
  margin: 0.5rem 0 0;
}
.mission-lock-line.is-waiting { color: var(--gold); }
/* Available-board meta lines (location/time) — plain readouts, same weight
   as the capacity/signup-countdown lines they sit alongside. */
.mission-meta-line {
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--muted);
  margin: 0 0 0.5rem;
}
.maps-link {
  color: var(--red);
  text-decoration: underline;
  white-space: nowrap;
  flex-shrink: 0;
}

.mission-preview-map {
  height: 140px;
  border-radius: var(--radius-sm);
  overflow: hidden;
  margin: 0.5rem 0;
  border: 1px solid var(--card-2);
  /* Same shimmer-while-loading base as .home-map/.field-map (see that
     comment) — previously unset here, so it fell through to Leaflet's own
     default #ddd container color, a mismatched light flash on this
     otherwise all-dark card. */
  background-color: #14181a;
}

/* ---------- Nearby map (compact + expanded) ----------
   Unused for now — the Open/Soon/Urgent/Full chip row is pulled from
   index.html/backend/index.html, kept here rather than deleted since pins
   still color by these same tiers (js/missions.js) and the row is coming
   back. */
.map-filter-chips {
  display: flex;
  gap: 0.4rem;
  overflow-x: auto;
  margin-bottom: 0.5rem;
}
.map-filter-chip {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  flex-shrink: 0;
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.02em;
  padding: 0.3rem 0.6rem;
  border-radius: 999px;
  border: 1px solid var(--card-2);
  background: var(--card);
  color: var(--muted);
  opacity: 0.45;
}
.map-filter-chip.is-active {
  opacity: 1;
  color: var(--white);
  background: var(--card-2);
}
.map-filter-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex-shrink: 0;
}
.dot-open { background: var(--teal); }
.dot-soon { background: var(--gold); }
.dot-urgent { background: var(--red); }
.dot-full { background: var(--muted); }

/* ---------- Map: full-bleed map + radar pulse (Map/Missions redesign) ---------- */
/* #tab-map gets padding:0 (see the Field Map section below, which already
   had this rule and now covers the player map too) since this whole view is
   position:fixed and ignores its parent's box anyway — same "fixed between
   the topbar and bottom-nav" technique #thread-detail-view already uses.
   #tab-missions used to need the same override back when it WAS the map —
   now it's a normal scrolling docket, so it just takes .tab-panel's default
   padding like every other list-based tab. */
.home-map-view {
  position: fixed;
  left: 0;
  right: 0;
  top: var(--topbar-h);
  bottom: var(--bottom-nav-h);
  z-index: 1;
  overflow: hidden;
}
.home-map {
  position: absolute;
  inset: 0;
  /* Base color is shared with .field-map/.mission-preview-map below; see
     that rule's comment for why it's no longer a flat near-black. */
  background-color: #14181a;
  /* Load-bearing, not decorative — without it, Leaflet's own internal panes
     (z-index up to 600-700, see leaflet.css) aren't scoped to their own
     stacking context, so they leak out and paint over the search pill and
     pull-up sheet despite both having a much higher z-index. Same bug, same
     fix, as the old compact Nearby map's Expand button once had. */
  isolation: isolate;
}
/* Shimmer placeholder while Leaflet tiles are loading (js/lib/map.js's
   createMap toggles .map-tiles-loading on/off via the tile layer's own
   "loading"/"load" events — that includes every invalidateSize()-triggered
   reflow, e.g. switching onto the Map tab, not just first paint). A flat
   near-black container background read as "broken/black squares" in the
   gaps between loaded tiles; this animated diagonal sweep reads as
   "loading" instead, and is what actually fixes the brief black flash
   right after a swipe lands on the Map tab (js/main.js's switchTab) — that
   flash was this same background showing through mid-reflow. */
@keyframes map-tile-shimmer {
  0% { background-position: -120% 0; }
  100% { background-position: 220% 0; }
}
.home-map.map-tiles-loading,
.field-map.map-tiles-loading,
.mission-preview-map.map-tiles-loading {
  background-image: linear-gradient(100deg, #14181a 30%, #262c2e 50%, #14181a 70%);
  background-size: 200% 100%;
  animation: map-tile-shimmer 1.6s ease-in-out infinite;
}

/* Normal-flow header (dossier chip + search) sitting above the map, not
   absolutely positioned — a fixed top offset here previously assumed the
   dossier chip's height instead of actually accounting for it, so the two
   silently overlapped (the chip is position:relative in normal flow,
   unaware of anything absolutely positioned over the same coordinates).
   Stacking this as one block guarantees no overlap regardless of either
   child's real rendered height. */
.home-map-header {
  position: relative;
  z-index: 2;
}
/* position:relative so the suggestions dropdown below can anchor directly
   under the input+button row, not the whole header. */
.home-map-search-wrap {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 1.25rem 0.7rem;
}
.home-map-search-input {
  flex: 1;
  min-width: 0;
  background: rgba(var(--card-rgb), 0.86);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: 999px;
  color: var(--white);
  font-family: var(--mono);
  /* 16px minimum — iOS Safari auto-zooms the viewport on focus below this,
     and a fixed-position full-bleed map screen doesn't recover cleanly from
     that zoom the way a normal scrolling page does. Same bug as the Field
     Map's own search input, just pre-existing here rather than new. */
  font-size: 1rem;
  padding: 0.6rem 0.9rem;
}
.home-map-search-input::placeholder { color: var(--muted); }
/* .home-map-search-input:focus is covered by the shared focus-outline rule
   near the top of this file — see docs/CODE-DOCTOR-AUDIT.md's "9× identical
   focus-outline rule" consolidation. */
/* Go button (2026-07-17) — a real location search needs an explicit commit
   action, not just "type and hope Enter works." Round, same glassy chip
   look as the input beside it, sized to match its 44px tap-target minimum. */
.home-map-location-go-btn {
  flex: none;
  width: 2.75rem;
  height: 2.75rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(var(--card-rgb), 0.86);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: 50%;
  color: var(--white);
  cursor: pointer;
}
.home-map-location-go-btn:active { transform: scale(0.94); }
/* Suggestions dropdown — anchored under the search row, above the map
   underneath it. A tap outside (js/missions.js's document-level listener)
   or picking a result both close it; typing under 3 characters never opens
   it at all (see js/missions.js's LOCATION_SEARCH_DEBOUNCE_MS gate). */
.home-map-location-suggestions {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: 0.35rem;
  background: rgba(var(--card-rgb), 0.96);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: var(--radius-lg);
  overflow: hidden;
  max-height: 60vh;
  overflow-y: auto;
  z-index: 3;
}
.home-map-location-suggestion {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  border-bottom: 1px solid rgba(244, 242, 236, 0.08);
  color: var(--white);
  font-family: var(--mono);
  font-size: 0.85rem;
  padding: 0.65rem 0.9rem;
  cursor: pointer;
}
.home-map-location-suggestion:last-child { border-bottom: none; }
.home-map-location-suggestion:active { background: rgba(244, 242, 236, 0.08); }
.home-map-location-suggestions .empty-state { padding: 0.7rem 0.9rem; margin: 0; }

/* Floating control cluster (Map/Missions redesign) — replaces the old
   pull-up sheet as the positioning anchor for recenter/zoom/AR/drop-tray
   now that there's no mission list living on this screen to pull up at all
   (that moved to the Missions tab's docket). A zero-height box pinned to
   the bottom edge of .home-map-view: children anchor off ITS bottom edge
   (bottom:100%) exactly the way they used to anchor off the sheet's real
   (dynamic, collapsed-vs-expanded) top edge — same anchor math, just against
   a fixed point instead of a moving one now that there's nothing left to
   expand/collapse. */
.home-map-controls {
  position: absolute;
  left: 0;
  right: 0;
  bottom: var(--space-2);
  height: 0;
  z-index: 3;
}
/* Field Map's own instance shifts up to clear the global Quick Drop FAB
   sharing this corner — see .field-map-actions' own comment for the full
   corner-cluster rationale. The player Home map (no FAB there) keeps the
   base position above, untouched. */
#field-map-controls { bottom: calc(var(--space-2) + 3.6rem); }
.home-map-recenter-btn {
  position: absolute;
  right: var(--space-2);
  bottom: 100%;
  margin-bottom: 0.6rem;
  z-index: 3;
  width: 2.2rem;
  height: 2.2rem;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(var(--card-rgb), 0.82);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--white);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset, 0 3px 10px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  transition: box-shadow 0.15s var(--ease), transform 0.1s var(--ease);
}
.home-map-recenter-btn:active { transform: scale(0.92); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); }
/* Follow-mode glow (2026-07-16) — shared by the Home map and Field Map's
   recenter buttons (js/missions.js's homeMapFollowing, js/admin.js's
   fieldMapFollowing). On = locked onto the live location dot and glowing
   teal; a manual drag turns it off (view stays put), and the button itself
   is the only way back on. Field Map's recenter button (2026-07-30 HQ
   console IA pass) now carries the .home-map-recenter-btn class directly —
   same component, two call sites — so this single selector already covers
   both; no .field-map-recenter-btn class exists to style separately anymore. */
.home-map-recenter-btn.is-following {
  color: var(--teal);
  box-shadow: 0 0 0 2px rgba(35, 211, 205, 0.6), 0 0 10px 3px rgba(35, 211, 205, 0.55), 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* .home-map-ar-btn moved to css/ar-radar.css — see docs/AR-ARCHIVE.md. */

/* ---------- Map Drop Tray (js/inventory.js, 2026-07-17) ---------- */
.home-map-drop-tray-btn {
  position: absolute;
  right: var(--space-2);
  bottom: 100%;
  margin-bottom: 3.4rem; /* middle slot, directly above .home-map-recenter-btn (2026-07-17: swapped with .home-map-ar-btn's old slot — used far more often than AR, so it sits closer to the thumb) */
  z-index: 3;
  width: 2.2rem;
  height: 2.2rem;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(var(--card-rgb), 0.82);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--white);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset, 0 3px 10px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  transition: box-shadow 0.15s var(--ease), transform 0.1s var(--ease);
}
.home-map-drop-tray-btn:active { transform: scale(0.92); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); }
/* Same glow language as .home-map-recenter-btn.is-following — "this control
   is doing something right now," not a separate visual vocabulary. */
.home-map-drop-tray-btn.is-active {
  color: var(--teal);
  box-shadow: 0 0 0 2px rgba(35, 211, 205, 0.6), 0 0 10px 3px rgba(35, 211, 205, 0.55), 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* z-index above .home-map-controls (3) so the tray overlays the control
   cluster while open, rather than the two fighting for the same bottom edge
   of #home-map-view. */
.map-drop-tray {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 5;
  max-height: 45%;
  background: var(--card);
  border-top: 1px solid var(--card-2);
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -14px 30px -12px rgba(0, 0, 0, 0.55);
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  padding: 0.9rem;
  box-sizing: border-box;
}
.map-drop-tray.hidden { display: none; }
.map-drop-tray-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.map-drop-tray-list {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  padding-bottom: 0.2rem;
}
.drop-tray-chip {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.25rem;
  min-width: 4.2rem;
  padding: 0.5rem 0.7rem;
  border-radius: var(--radius-md);
  border: 1px solid var(--card-2);
  background: rgba(255, 255, 255, 0.03);
  color: var(--white);
  cursor: pointer;
}
.drop-tray-chip-icon { font-size: 1.4rem; }
.drop-tray-chip-name {
  font-size: 0.72rem;
  text-align: center;
  white-space: nowrap;
}
.drop-tray-chip-detail {
  font-size: 0.62rem;
  text-align: center;
  line-height: 1.15;
  color: var(--muted);
  max-width: 5.6rem;
}
.drop-tray-chip.is-armed {
  border-color: var(--teal);
  box-shadow: 0 0 0 2px rgba(35, 211, 205, 0.5);
}
.map-drop-tray-hint {
  color: var(--muted);
  font-size: 0.8rem;
  margin: 0;
}

/* Mission text-search row — now lives in the Missions tab's All pane
   (Map/Missions redesign; the map itself carries only the real location
   search in its header, .home-map-search-input below). Plain field styling
   on the docket's own solid background, not the glassy floating-over-the-map
   treatment .home-map-search-input still uses. */
.home-mission-search-wrap { margin: 0.4rem 0 0.7rem; }
.home-mission-search-input {
  width: 100%;
  background: var(--card-2);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: 999px;
  color: var(--white);
  font-family: var(--mono);
  font-size: 1rem; /* 16px minimum, same iOS auto-zoom reasoning as .home-map-search-input */
  padding: 0.6rem 0.9rem;
}
.home-mission-search-input::placeholder { color: var(--muted); }
/* .home-mission-search-input:focus is covered by the shared focus-outline
   rule near the top of this file. */

/* Mission-code discovery (2026-07-28) — a second, smaller row right below
   the search bar, input + Redeem button side by side. */
.mission-code-form { display: flex; gap: 0.5rem; align-items: center; }
.mission-code-form .home-mission-search-input { flex: 1; }
.mission-code-form button { flex-shrink: 0; }
/* Redeem is a player-facing action typed while out at a show, not a dense
   HQ list row — needs the app's real 44px tap-target floor, which
   .btn-small's own `min-height: 0` reset (meant for compact HQ rows) would
   otherwise leave it under. Covers both #mission-code-form and the shared
   #badge-code-form (js/accounts.js), since both reuse this class. */
.mission-code-form .btn {
  min-height: 2.75rem;
  min-width: 2.75rem;
}

/* Radar-pulsing player marker (a Leaflet divIcon, not a circleMarker — see
   js/missions.js's playerMarkerFor for why). className:"home-radar-icon"
   replaces Leaflet's own default divIcon class entirely, so none of its
   built-in background/border styles apply here. */
.home-radar-icon { background: none; border: none; }
.home-radar-dot {
  position: relative;
  display: block;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--white);
  border: 2px solid var(--red);
  box-sizing: border-box;
}
.home-radar-ring {
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  border: 1px solid rgba(35, 211, 205, 0.55);
  animation: home-radar-pulse 3.2s ease-out infinite;
}
.home-radar-ring:nth-child(2) { animation-delay: 1.6s; }
@keyframes home-radar-pulse {
  0% { transform: scale(1); opacity: 0.85; }
  100% { transform: scale(11); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .home-radar-ring { animation: none; opacity: 0.35; }
}

/* ---------- Field Map (HQ) ---------- */
/* HQ's persistent first screen — same full-bleed "fixed between topbar and
   bottom-nav" treatment as the player Home map above, but its own separate
   Leaflet instance entirely (js/admin.js's renderFieldMap) since nothing
   about mission-pin clustering or drop-mode state needs to be shared with
   the player-facing map. */
#tab-map { padding: 0; }
/* #map-admin-view (2026-07-30 HQ console IA pass) — now a flex column, not
   a bare wrapper: #world-admin-subtabs (Map/Objects/Hubs) needs its own
   reserved row above the map, which a plain position:fixed .field-map-view
   pinned straight to var(--topbar-h) would otherwise paint straight over
   (a real bug caught in Playwright verification — the subtabs bar was
   there but unclickable, covered by the fixed map underneath). The fixed
   "bleed between topbar and bottom-nav" positioning that used to live on
   .field-map-view moved up to this element instead; .field-map-view itself
   is now just an ordinary flex child (position:absolute within its own
   .world-subtab-map slot) so it only ever fills the space flexbox actually
   gives it, leaving the subtabs row alone. */
#map-admin-view {
  position: fixed;
  left: 0;
  right: 0;
  top: var(--topbar-h);
  bottom: var(--bottom-nav-h);
  z-index: 1;
  display: flex;
  flex-direction: column;
}
#world-admin-subtabs {
  flex: 0 0 auto;
  padding: 0.6rem 1.25rem 0;
  margin-bottom: 0;
  background: var(--black);
}
.world-subtab-page { flex: 1 1 auto; min-height: 0; display: none; }
.world-subtab-page.is-active { display: flex; flex-direction: column; }
#world-subtab-map { position: relative; overflow: hidden; }
.field-map-view {
  position: absolute;
  inset: 0;
  overflow: hidden;
}
/* Objects/Hubs sub-tabs are ordinary scrolling content, not a fixed map —
   this is what used to be .tab-panel's own base padding before #tab-map
   zeroed it out for the Field Map's benefit above. Scrolls within its own
   flex slot (bounded by #map-admin-view's fixed top/bottom) rather than
   the whole-page scroll other tabs rely on, since this container is fixed-
   positioned, not part of the normal document flow. */
.world-subtab-scroll { padding: var(--space-5) 1.25rem var(--space-5); overflow-y: auto; }
.field-map {
  position: absolute;
  inset: 0;
  background-color: #14181a; /* shimmer base — see .home-map's comment */
  isolation: isolate; /* same Leaflet-pane-leak fix as .home-map above */
}
/* Field Map header (2026-07-30 HQ console IA pass) — the redundant "Field
   Map" title pill is gone (you already tapped World → Map to get here) and
   recenter/zoom moved down into the same floating control cluster the
   player-facing Home map uses (see below) instead of living up here next to
   a label. All that's left in the top overlay is search, which now reuses
   .home-map-search-wrap/.home-map-search-input/.home-map-location-go-btn
   directly — one component, two call sites — rather than a second
   hand-styled copy. Recentering still only ever calls setView, never places
   a pin (js/admin.js keeps that behavior; the shared classes are visual
   only). */
.field-map-header {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 2;
  padding: 0.9rem 1.25rem 0;
  pointer-events: none; /* only the real controls inside opt back in */
}
.field-map-header .home-map-search-wrap { pointer-events: auto; margin: 0; }

.field-map-hint {
  position: absolute;
  top: 4.4rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 2;
  background: rgba(225, 28, 46, 0.92);
  color: var(--white);
  font-family: var(--mono);
  font-size: var(--text-xs);
  border-radius: 999px;
  padding: 0.5rem 1rem;
  white-space: nowrap;
}
.field-map-hint .link-btn {
  color: var(--white);
  text-decoration: underline;
  font-family: var(--mono);
  font-size: var(--text-xs);
}

/* Drop button (2026-07-30) — the old three-across Drop Mission/Drop Item/
   Drop Sighting row is one "Drop" toggle now; tapping it reveals the same
   three options as a small menu instead of permanently occupying a third of
   the bottom edge. Picking one arms the exact same tap-the-map placement
   mode as before (js/admin.js's setFieldMapDropMode) and closes the menu —
   the menu is just a fold-out for buttons that already existed, not a new
   mechanism. */
/* Corner-cluster spacing (2026-07-30) — the global Quick Drop FAB (css/
   app.css, present on every tab) shares this exact bottom-right corner with
   the Field Map's own recenter/zoom (.home-map-controls, the same shared
   component the player Home map uses) and this Drop toggle — a real
   Playwright hit-test caught the previous positions overlapping badly
   enough to make the Drop toggle unclickable underneath the FAB, not just
   a look. Stacked bottom-to-top instead, all right-aligned: Quick Drop
   (closest to the thumb — reachable from every tab, not just here) →
   Recenter/Zoom (#field-map-controls below, shifted up to clear the FAB —
   scoped to that id, not the shared .home-map-controls class, so the
   player Home map's own lower position is untouched) → this Drop toggle,
   topmost (the least-frequently-reached of the three, since Quick Drop
   already covers 3 of its 4 options). */
.field-map-actions {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2;
  display: flex;
  justify-content: flex-end;
  padding: 0 1.25rem calc(7rem + var(--safe-bottom));
}
.field-map-drop-wrap { position: relative; }
.field-map-drop-toggle-btn {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  background: rgba(var(--card-rgb), 0.9);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: 999px;
  color: var(--gold);
  font-family: var(--mono);
  font-size: 0.8rem;
  padding: 0.75rem 1.1rem;
  cursor: pointer;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
}
.field-map-drop-toggle-btn:active { transform: scale(0.97); }
.field-map-drop-menu {
  position: absolute;
  right: 0;
  bottom: 100%;
  margin-bottom: 0.6rem;
  display: flex;
  flex-direction: column;
  background: rgba(var(--card-rgb), 0.96);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
  min-width: 9.5rem;
}
.field-map-drop-menu-item {
  background: none;
  border: none;
  border-bottom: 1px solid rgba(244, 242, 236, 0.1);
  color: var(--white);
  font-family: var(--mono);
  font-size: 0.8rem;
  text-align: left;
  padding: 0.65rem 0.9rem;
  cursor: pointer;
}
.field-map-drop-menu-item:last-child { border-bottom: none; }
.field-map-drop-menu-item:active { background: rgba(244, 242, 236, 0.08); }
/* Armed drop-mode — mission pins are red/urgent already, so Drop Mission
   borrows that same red rather than inventing a third "armed" color; Drop
   Item/Sighting borrow the teal "open" tier color instead so the two armed
   states are never confusable with each other at a glance. Same color
   contract the old three-button row used, just retargeted at the menu. */
.field-map-drop-menu-item.is-active {
  background: var(--red);
  color: var(--white);
}
.field-map-drop-menu-item-item.is-active {
  background: var(--teal);
  color: #0c0c0e;
}

/* Item pin div-icon (js/admin.js's itemDivIcon) — the emoji itself is the
   marker, unlike a mission's plain colored dot, since there's no urgency
   tier to encode for a flavor object. */
.field-map-item-icon span {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  font-size: 1.1rem;
  background: rgba(var(--card-rgb), 0.92);
  border: 1px solid rgba(244, 242, 236, 0.25);
  border-radius: 50%;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}

/* The Home map's zoom control (2026-07-16, third attempt) — Leaflet's own
   built-in control, in ANY of its four corners, collided with something
   full-width at that same corner: both top corners sit under the dossier
   chip/search header, both bottom corners sit under this map's own
   pull-up sheet. A fixed rem guess at the header's height, then a
   ResizeObserver-measured one, both still left it looking wrong on a real
   device (reported twice). Built as two plain buttons instead
   (js/missions.js calls homeMapInstance.zoomIn()/zoomOut() directly,
   Leaflet's own control never even created via zoomControl:false), anchored
   exactly like #home-map-recenter-btn right next to it — the one anchor
   already proven to work here, since it tracks the sheet's real height
   whether collapsed or expanded instead of guessing at anything.
   Field Map (2026-07-30) reuses this same class/component for its own zoom
   buttons instead of Leaflet's native control, which it used to keep — one
   fewer place to restyle a foreign-looking control, and it removes the
   #field-map-only leaflet-control-zoom override block that used to sit here. */
.home-map-zoom-controls {
  position: absolute;
  left: var(--space-2);
  bottom: 100%;
  margin-bottom: 0.6rem;
  z-index: 3;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.home-map-zoom-btn {
  width: 2.2rem;
  height: 2.2rem;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(var(--card-rgb), 0.82);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--white);
  font-family: var(--mono);
  font-size: 1.2rem;
  line-height: 1;
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset, 0 3px 10px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  transition: box-shadow 0.15s var(--ease), transform 0.1s var(--ease);
}
.home-map-zoom-btn:active { transform: scale(0.92); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); }

/* ---------- Combination-lock roller (js/lib/ui.js's initComboRoller) ----------
   Three independent 0-9 wheels — a real padlock's dial, not a phone keypad —
   shared by HQ dialing in (or scrambling) a Safe's combination and a player
   dialing in their guess to crack it (#modal-safe-crack). All markup below
   .combo-roller itself is built by the JS helper, not hand-authored here. */
.combo-roller {
  display: flex;
  justify-content: center;
  gap: 0.7rem;
  margin: 0.5rem 0;
}
.combo-roller-digit {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
}
.combo-roller-btn {
  width: 2.6rem;
  height: 2.1rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--card-2);
  background: var(--card);
  color: var(--white);
  font-size: 0.85rem;
  line-height: 1;
  cursor: pointer;
}
.combo-roller-btn:active { transform: scale(0.92); background: var(--card-2); }
.combo-roller-value {
  width: 2.6rem;
  height: 3rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  background: #0c0c0e;
  border: 1px solid var(--card-2);
  font-family: var(--mono);
  font-size: 1.7rem;
  font-variant-numeric: tabular-nums;
  color: var(--red);
}

/* Reward fields (Safe/Lockbox drop editor) — a plain field-inline row plus
   the shared item select, same shape as every other optional sub-group in
   this modal family. */
#item-reward-wrap .field-inline { margin-bottom: 0.5rem; }

/* Manage/remove modal (#modal-item-manage) — the detail block is plain
   stacked text (a note's real content, a safe's combo, a reward summary),
   not a form; loosen the default modal-card paragraph spacing so multiple
   <br />-separated lines read as a short list, not a single dense block. */
#item-manage-detail {
  font-family: var(--body);
  font-size: 0.9rem;
  color: var(--white);
  line-height: 1.6;
}

/* ---------- Case Files (Dossier, above Badges) ---------- */
/* Deliberately tiny — a single scrollable row of pill chips, not the grid
   of big folder icons Home used to show. */
.case-files-rail {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  padding: 0.3rem 0.1rem 0.5rem;
  margin-top: 0.4rem;
  scrollbar-width: none;
}
.case-files-rail::-webkit-scrollbar { display: none; }
.case-file-chip {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  max-width: 160px;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: 999px;
  padding: 0.35rem 0.7rem 0.35rem 0.4rem;
  cursor: pointer;
}
.case-file-chip-icon {
  flex-shrink: 0;
  width: 18px;
  height: 14px;
  background: var(--gold);
  border-radius: 2px 4px 4px 4px;
  position: relative;
}
.case-file-chip-icon::before {
  content: "";
  position: absolute;
  top: -3px;
  left: 0;
  width: 8px;
  height: 4px;
  background: var(--gold);
  border-radius: 2px 2px 0 0;
}
.case-file-chip-label {
  font-family: var(--mono);
  font-size: 0.62rem;
  color: var(--white);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Case File viewer (#modal-mission-file) — Mission / Roster / Debrief */
.cf-tabs {
  display: flex;
  gap: 0.4rem;
  margin: 0.8rem 0 0.9rem;
}
.cf-tab {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.03em;
  padding: 0.35rem 0.65rem;
  border-radius: 999px;
  border: 1px solid var(--card-2);
  background: var(--card);
  color: var(--muted);
  cursor: pointer;
}
.cf-tab.is-active {
  color: var(--white);
  background: var(--card-2);
  border-color: var(--red);
}
.cf-page {
  display: none;
  max-height: 50vh;
  overflow-y: auto;
}
.cf-page.is-active { display: block; }
.cf-debrief-text {
  font-size: 0.85rem;
  line-height: 1.55;
  color: var(--white);
  white-space: pre-wrap;
}

/* ---------- Admin location picker ---------- */
.location-picker {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.location-search-row {
  display: flex;
  gap: 0.5rem;
}
.location-search-row input {
  flex: 1;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  /* 16px minimum — iOS Safari auto-zooms the viewport on focus below this. */
  font-size: 1rem;
  padding: 0.7rem 0.8rem;
}
.location-map {
  height: 220px;
  border-radius: var(--radius-sm);
  overflow: hidden;
  border: 1px solid var(--card-2);
}
#mission-location-label {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: 1rem;
  padding: 0.7rem 0.8rem;
}
.field-hint {
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--muted);
  margin: -0.5rem 0 0;
  line-height: 1.4;
}

.mission-capacity {
  font-family: var(--mono);
  font-size: 0.75rem;
  color: var(--gold);
  margin: 0 0 0.7rem;
}
.mission-full-tag {
  font-family: var(--mono);
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ---------- Leaderboard ---------- */
.leaderboard-row {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.7rem 1rem;
}
.leaderboard-row.leaderboard-row-top3 {
  border-color: var(--gold);
  background: linear-gradient(90deg, rgba(217, 180, 90, 0.12), transparent);
}
.leaderboard-rank-medal { font-size: 1.1rem; margin-right: -0.3rem; }
.leaderboard-rank {
  font-family: var(--display);
  color: var(--muted);
  font-size: 1.1rem;
  width: 1.6rem;
  flex-shrink: 0;
}
.leaderboard-name {
  flex: 1;
  font-family: var(--mono);
  font-size: 0.9rem;
  color: var(--white);
}
.leaderboard-points {
  font-family: var(--mono);
  font-size: 0.85rem;
  color: var(--gold);
}

/* ---------- Modal ---------- */
.modal {
  position: fixed;
  inset: 0;
  background: rgba(8, 8, 10, 0.85);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
  z-index: 20;
}
@keyframes modal-overlay-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes modal-card-in {
  from { opacity: 0; transform: translateY(10px) scale(0.97); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal:not(.hidden) {
  animation: modal-overlay-in 0.18s var(--ease);
}
.modal:not(.hidden) .modal-card {
  animation: modal-card-in 0.22s var(--ease);
}
.modal-card {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  padding: var(--space-6);
  max-width: 360px;
  width: 100%;
  max-height: 85vh;
  overflow-y: auto;
  box-shadow: var(--shadow-modal);
}
.modal-card-wide { max-width: 480px; }

/* Bottom sheet variant — same open/close JS as every other .modal (just a
   classList.toggle("hidden")), just anchored to the bottom edge with its own
   slide-up entrance instead of the centered fade+scale every other modal
   uses. First use: the emoji reaction picker, opened via long-press on a
   message bubble — a native-feeling sheet reads truer there than a dialog
   floating in the middle of the screen. */
.modal-sheet { align-items: flex-end; padding: 0; }
.modal-sheet .modal-card {
  max-width: none;
  width: 100%;
  max-height: 70vh;
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  padding: var(--space-3) var(--space-5) calc(var(--space-5) + var(--safe-bottom));
}
.modal-sheet:not(.hidden) .modal-card {
  animation: sheet-card-in 0.24s var(--ease);
}
@keyframes sheet-card-in {
  from { transform: translateY(100%); }
  to { transform: translateY(0); }
}
/* background-clip:content-box + padding keeps the visible bar exactly the
   same tiny size while giving the actual drag/tap target real thumb-sized
   room around it — negative top margin cancels the added top padding so
   nothing shifts position. touch-action:none hands the whole gesture to
   js/lib/ui.js's drag handler instead of the browser's own scroll/pull. */
.sheet-handle {
  width: 2.2rem;
  height: 4px;
  border-radius: 2px;
  background: var(--card-2);
  background-clip: content-box;
  box-sizing: content-box;
  padding: 0.7rem 1.5rem;
  margin: calc(-1 * var(--space-3)) auto 0;
  touch-action: none;
  cursor: grab;
}
.modal-mission-title {
  font-family: var(--display);
  font-size: 1.05rem;
  margin: 0 0 1rem;
}
/* How to Install screen (2026-07-29) — each step its own card, same
   "reads as a real feature, not fine print" shape .push-card already
   established for the push-enable row, not a bare numbered list. */
.install-step {
  display: flex;
  align-items: flex-start;
  gap: 0.7rem;
  margin: 0 0 0.7rem;
  padding: 0.8rem 0.9rem;
  background: var(--card-2);
  border: 1px solid rgba(244, 242, 236, 0.1);
  border-radius: var(--radius-md);
}
.install-step-icon {
  flex-shrink: 0;
  width: 1.6rem;
  height: 1.6rem;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--gold);
  margin-top: 0.05rem;
}
.install-step-text {
  margin: 0;
  color: var(--white);
  font-family: var(--body);
  font-size: 0.9rem;
  line-height: 1.5;
}
.share-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.4rem;
  height: 1.4rem;
  border: 1.5px solid var(--gold);
  border-radius: 4px;
  font-size: 0.9rem;
  line-height: 1;
  color: var(--gold);
}
.code-input {
  width: 100%;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  font-size: 1rem;
  padding: 0.85rem 0.9rem;
  margin-bottom: 0.6rem;
}
.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.6rem;
  margin-top: 0.6rem;
}
.modal-actions-stacked { flex-direction: column; align-items: stretch; }
.admin-view-header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.6rem; }
.admin-view-header .label { margin: 0; }

/* Reusable expand-out section — same structural shape as the agent-profile
   Danger Zone's <details>/<summary> (css/app.css's
   .agent-profile-danger-zone/.danger-zone-summary), neutral-colored instead
   of red since most uses of this one aren't a warning. Used to collapse the
   Badges tab's Badge/Flair/Theme Library sections so a tab that's really
   three separate creation tools doesn't read as one long scroll. */
.admin-collapsible {
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
}
.section-summary {
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  padding: var(--space-3) var(--space-4);
}
.section-summary::-webkit-details-marker { display: none; }
.section-summary::before {
  content: "▸";
  font-size: 0.7em;
  color: var(--muted);
  transition: transform 0.15s var(--ease);
}
.admin-collapsible[open] > .section-summary::before { transform: rotate(90deg); }
.admin-collapsible-body { padding: 0 var(--space-4) var(--space-4); }
.admin-collapsible-add { display: block; width: 100%; margin-bottom: var(--space-3); }

/* Missions admin sub-tabs (All/Nearby/Remote/Scheduled/Archived) — a plain
   underline bar rather than .customize-tabs' sliding-indicator variant,
   which assumes exactly two items; this needs to hold five. Shared between
   index.html (HQ Mode) and backend/index.html — js/admin.js's
   renderAdminMissionsList() is the one implementation either app runs. */
.missions-admin-subtabs {
  display: flex;
  gap: var(--space-4);
  overflow-x: auto;
  margin-bottom: var(--space-3);
  border-bottom: 1px solid var(--card-2);
}
.missions-admin-subtab {
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--muted);
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
  padding: var(--space-2) 0.1rem;
  min-height: 44px;
  cursor: pointer;
}
.missions-admin-subtab.is-active { color: var(--white); border-bottom-color: var(--red); }

/* Missions tab swipe docket (Map/Missions redesign) — Pinned/All, same
   plain-underline-bar visual language as .missions-admin-subtabs just
   above rather than a third tab style, even though the two are unrelated
   components (one's the player docket, the other's HQ's mission CRUD).
   Swiping between pages is js/lib/swipe-gesture.js's attachSubtabSwipe,
   wired in js/missions.js. */
.missions-subtabs {
  display: flex;
  gap: var(--space-5);
  margin-bottom: var(--space-3);
  border-bottom: 1px solid var(--card-2);
}
.missions-subtab {
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--muted);
  font-family: var(--mono);
  font-size: var(--text-sm);
  letter-spacing: 0.03em;
  text-transform: uppercase;
  padding: var(--space-2) 0.1rem var(--space-3);
  min-height: 44px;
  cursor: pointer;
}
.missions-subtab.is-active { color: var(--white); border-bottom-color: var(--red); }
.missions-subtab-page { display: none; }
.missions-subtab-page.is-active { display: block; }
/* World tab's Map/Objects/Hubs sub-tab rules (.world-subtab-page,
   .world-subtab-scroll) live in the Field Map section above, alongside
   #map-admin-view/#world-admin-subtabs — the whole group is one flexbox
   layout, easier to read together than split across the file. */

/* Quick Drop FAB (2026-07-30) — a fixed screen-level control, sibling to
   .bottom-nav rather than nested in any one .tab-panel, so it survives
   every tab switch. HQ-only (body.hq-mode-active). `bottom` clears the
   real player-app .bottom-nav here; backend/backend.css overrides it since
   backend hides that bar entirely (console-nav replaces it at the top
   instead) and would otherwise leave a large dead gap under the FAB. Same
   glass-pill/fan-out-menu visual language as .field-map-drop-toggle-btn/
   .field-map-drop-menu above — a second call site of that look (this one
   position:fixed and screen-wide, that one position:absolute and scoped to
   the Field Map), not shared markup. */
.quick-drop-wrap {
  display: none;
  position: fixed;
  right: 1.1rem;
  bottom: calc(var(--bottom-nav-h) + 0.9rem + var(--safe-bottom));
  z-index: 24;
}
body.hq-mode-active .quick-drop-wrap { display: block; }
.quick-drop-fab-btn {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  background: rgba(var(--card-rgb), 0.92);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: 999px;
  color: var(--gold);
  font-family: var(--mono);
  font-size: 0.8rem;
  padding: 0.8rem 1.2rem;
  cursor: pointer;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.45);
}
.quick-drop-fab-btn:active { transform: scale(0.97); }
.quick-drop-menu {
  position: absolute;
  right: 0;
  bottom: 100%;
  margin-bottom: 0.6rem;
  display: flex;
  flex-direction: column;
  background: rgba(var(--card-rgb), 0.96);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(244, 242, 236, 0.14);
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
  min-width: 11rem;
}
.quick-drop-menu-item {
  background: none;
  border: none;
  border-bottom: 1px solid rgba(244, 242, 236, 0.1);
  color: var(--white);
  font-family: var(--mono);
  font-size: 0.8rem;
  text-align: left;
  padding: 0.7rem 0.95rem;
  cursor: pointer;
}
.quick-drop-menu-item:last-child { border-bottom: none; }
.quick-drop-menu-item:active { background: rgba(244, 242, 236, 0.08); }

/* People tab's Directory/Badges & Flair/Themes/Access sub-tabs (2026-07-30
   HQ console IA pass) — ordinary in-flow Mainframe content, not a fixed map
   like World's, so a plain display:none/block toggle is enough (same as
   .missions-subtab-page above); no flex/fixed-position complexity needed. */
.people-subtab-page { display: none; }
.people-subtab-page.is-active { display: block; }

/* Home tab's Needs You/Log sub-tabs (2026-07-30 HQ console IA pass) — same
   plain toggle as People's above; Log used to be its own top-level
   console-nav destination before folding in here. */
.home-subtab-page { display: none; }
.home-subtab-page.is-active { display: block; }

/* Pinned pane's In Progress/Awaiting Approval/Completed filter chips (Docket
   UI spec: avoid dumping every state into one undifferentiated scroll) —
   same pill treatment as .cf-tab (the Mission File's own Mission/Roster/
   Debrief tabs), reused here rather than inventing a second pill style. */
.missions-filter-chips {
  display: flex;
  gap: 0.4rem;
  margin-bottom: var(--space-3);
  overflow-x: auto;
}
.missions-filter-chip {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.03em;
  white-space: nowrap;
  padding: 0.35rem 0.65rem;
  border-radius: 999px;
  border: 1px solid var(--card-2);
  background: var(--card);
  color: var(--muted);
  cursor: pointer;
}
.missions-filter-chip.is-active {
  color: var(--white);
  background: var(--card-2);
  border-color: var(--red);
}

/* ---------- Admin panel ---------- */
.field select,
.field textarea,
#write-note-text,
#note-thread-reply,
#load-laundry-card-amount {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: 1rem;
  padding: 0.85rem 0.9rem;
  resize: vertical;
}
/* Not wrapped in a .field flex column like the admin-panel textareas above
   (these two sit bare in a plain-block .modal-card, see index.html) —
   width:100% has to be explicit instead of inherited from a flex parent. */
#write-note-text,
#note-thread-reply,
#load-laundry-card-amount {
  width: 100%;
  box-sizing: border-box;
  margin-bottom: 0.6rem;
}
.field-inline {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--muted);
}

/* Custom checkbox/radio — the stock OS control has zero dark-theme styling
   (the browser default light box/dot), which is the single most jarring
   unstyled element left in the app. appearance:none strips it down to a box
   we draw ourselves; the check/dot marks are plain CSS, no image assets. */
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  width: 1.1rem;
  height: 1.1rem;
  flex-shrink: 0;
  margin: 0;
  background: var(--card);
  border: 1.5px solid var(--card-2);
  cursor: pointer;
  position: relative;
  transition: background 0.15s var(--ease), border-color 0.15s var(--ease), transform 0.1s var(--ease);
}
input[type="checkbox"] { border-radius: var(--radius-xs); }
input[type="radio"] { border-radius: 50%; }
input[type="checkbox"]:checked,
input[type="radio"]:checked {
  background: var(--red);
  border-color: var(--red);
}
input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  left: 0.32rem;
  top: 0.12rem;
  width: 0.28rem;
  height: 0.52rem;
  border: solid var(--white);
  border-width: 0 2px 2px 0;
  transform: rotate(40deg);
}
input[type="radio"]:checked::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  width: 0.44rem;
  height: 0.44rem;
  border-radius: 50%;
  background: var(--white);
}
input[type="checkbox"]:active,
input[type="radio"]:active { transform: scale(0.9); }
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
  outline: 2px solid var(--red);
  outline-offset: 2px;
}

.admin-card {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  padding: var(--space-5);
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  margin-bottom: 1.25rem;
  box-shadow: var(--shadow-card);
}
.admin-section-gap { display: block; margin-top: 0.5rem; }

.checklist {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  max-height: 180px;
  overflow-y: auto;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.6rem 0.8rem;
}
.checklist label {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--mono);
  font-size: 0.85rem;
  color: var(--white);
}

.mission-admin-row {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.7rem;
  flex-wrap: wrap;
  position: relative; /* anchors .mission-kebab-menu under its kebab button */
}
.mission-inactive-tag {
  font-family: var(--mono);
  font-size: 0.65rem;
  color: var(--muted);
  border: 1px solid rgba(154, 152, 143, 0.35);
  border-radius: 3px;
  padding: 0.1rem 0.4rem;
  text-transform: uppercase;
}

/* One primary action ("Manage Roster") + a kebab overflow for everything
   riskier or rarer (Edit/Deactivate/Archive/Cancel Mission) — replaces five
   stacked full-width buttons per card (design-overhaul-7.15, Design Goal 3:
   scanning fifteen missions shouldn't mean reading sixty buttons). Modeled
   directly on the topbar's .settings-menu/.settings-menu-item — same
   anchored-dropdown shape, just per-card instead of a page-level singleton. */
.mission-action-primary { flex: 1; }
.mission-kebab-btn {
  min-width: 2.75rem;
  min-height: 2.75rem;
  flex-shrink: 0;
  font-size: 1.1rem;
  line-height: 1;
}
.mission-kebab-menu {
  position: absolute;
  top: calc(100% + 0.3rem);
  right: 0;
  min-width: 10rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-modal);
  overflow: hidden;
  z-index: 5;
}
.mission-kebab-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  border-bottom: 1px solid var(--card-2);
  color: var(--white);
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.03em;
  padding: 0.7rem 0.9rem;
  cursor: pointer;
}
.mission-kebab-menu-item:last-child { border-bottom: none; }
.mission-kebab-menu-item:active { background: var(--card-2); }
.mission-kebab-menu-item-danger { color: var(--red); }

.optional-group {
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: 8px;
  padding: 0.9rem 1rem;
  margin: -0.4rem 0 0.2rem;
  display: flex;
  flex-direction: column;
  gap: 0.8rem;
}

/* Mission-creation form (design-overhaul-7.15) — Place It / Timing &
   Capacity / Advanced are independent .admin-collapsible sections now
   (see index.html's #modal-mission-form), not one flat fold. Advanced is
   deliberately de-emphasized: audience targeting and the auto-checklist/
   auto-assign pair are rare, mostly-"system mission" concepts that shouldn't
   compete visually with Place It/Timing & Capacity for attention. */
.admin-collapsible-muted > .section-summary { color: var(--muted); }
.location-picker-legend {
  display: flex;
  gap: 0.9rem;
  font-family: var(--mono);
  font-size: 0.65rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.location-picker-legend span { display: inline-flex; align-items: center; gap: 0.3rem; }
.location-picker-legend i {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.mission-review-block { display: flex; flex-direction: column; gap: 0.6rem; }

/* ---------- Roster modal ---------- */
.roster-section { margin-bottom: 1.1rem; }
.roster-heading {
  display: block;
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 0.5rem;
}
.roster-list {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
/* Send Item picker's "Recent" / "All Connections" section tag (js/inventory
   .js's renderSendItemResults) — empty string when there's nothing to
   label (e.g. the very first open, before any recent chats exist), so it
   collapses to nothing rather than leaving a bare label over an empty
   list. */
#send-item-results-label {
  margin: 0.7rem 0 0.35rem;
}
#send-item-results-label:empty {
  display: none;
}

/* Browse grid on the Search tab (design-overhaul-7.13) — additive, tap-to-
   browse layer below #agent-search-input's results list. Fixed 3-up (not
   auto-fill) so cards stay wide enough to actually read a codename. */
.agent-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.6rem;
  margin-bottom: 0.3rem;
}
.agent-grid-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  padding: 0.7rem 0.4rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  transition: transform 0.1s var(--ease);
}
.agent-grid-card:active { transform: scale(0.96); }
.agent-grid-avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--card-2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.15rem;
}
.agent-grid-name {
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--white);
  max-width: 100%;
  text-align: center;
  line-height: 1.25;
  word-break: break-word;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.roster-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.5rem 0.7rem;
  min-height: 44px; /* tap-target floor, docs/HQ-REDESIGN.md §1 */
  box-sizing: border-box;
  transition: transform 0.1s var(--ease);
}
.roster-row:active { transform: scale(0.98); }
.roster-row-name {
  flex: 1;
  font-family: var(--mono);
  font-size: 0.85rem;
  color: var(--white);
}
.roster-declined-flag {
  font-family: var(--mono);
  font-size: 0.65rem;
  color: var(--red);
  text-transform: uppercase;
}

/* ---------- Inventory (Profile's third sub-tab, js/inventory.js) ---------- */
.inventory-item-card {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.6rem 0.8rem;
  margin-bottom: 0.5rem;
}
.inventory-item-icon {
  font-size: 1.6rem;
  width: 2.4rem;
  height: 2.4rem;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--card-2);
  border-radius: 50%;
}
/* A Playing Card's icon (js/inventory.js's renderInstancedCard) is its own
   real suit glyph, not the generic 🃏 def.icon — colored the way a real
   card's pips are. "Black" reads as var(--white) here instead of actual
   black, since a true-black glyph would vanish against .inventory-item-icon's
   own dark var(--card-2) circle. */
.inventory-card-suit-icon { font-size: 1.6rem; line-height: 1; }
.inventory-card-suit-red { color: var(--red); }
.inventory-card-suit-black { color: var(--white); }
.inventory-item-info {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}
.inventory-item-name {
  font-family: var(--mono);
  font-size: 0.85rem;
  color: var(--white);
}
.inventory-item-qty {
  color: var(--muted);
  font-size: 0.75rem;
}
.inventory-item-desc {
  font-size: 0.75rem;
  color: var(--muted);
}
/* Instanced item cards (box/card/note) carry more than one action — a plain
   .inventory-send-btn is never alone on those the way it is on a stackable
   card, so it needs its own wrapping row instead of sitting bare at the
   card's end. */
.inventory-item-actions {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  flex-shrink: 0;
}
.inventory-create-row {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 0.7rem;
}
.inventory-create-row .btn { flex: 1; }

/* ---------- Box contents (#modal-box-contents, js/inventory.js) ---------- */
.box-add-item-row {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.6rem;
}
.box-add-item-row select {
  flex: 1;
  min-width: 0;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: 0.9rem;
  padding: 0.6rem 0.7rem;
}

/* ---------- Note thread (#modal-note-thread, js/inventory.js) ---------- */
/* Invisible Ink's "night vision" toggle (2026-07-23) — same "hidden by
   default, even for an ink-holder" rule the corkboard's own toggle uses
   (js/profile-board.js, css/corkboard.css's .corkboard-nightvision-fab),
   just inline in the modal header here instead of overlaid on a stage.
   Hidden via .hidden (js/inventory.js's renderNoteThread) for any reader
   who doesn't own Invisible Ink at all — same total-mystery posture, the
   button itself has to show regardless of whether THIS note actually has a
   secret, or its mere presence would already be the tell. */
.note-thread-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
}
.note-thread-nightvision-btn {
  background: none;
  border: none;
  font-size: 1rem;
  line-height: 1;
  padding: 0.3rem 0.5rem;
  min-width: 44px; /* tap-target floor, docs/HQ-REDESIGN.md §1 — an icon-only
    button otherwise sat well under it here (padding alone was ~32px) */
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  cursor: pointer;
  color: var(--muted);
}
.note-thread-nightvision-btn.hidden { display: none; }
.note-thread-nightvision-btn.is-active {
  color: #ff6ec7;
  background: rgba(255, 20, 147, 0.18);
  box-shadow: 0 0 8px 1px rgba(255, 20, 147, 0.5);
}
.note-thread-list {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  max-height: 30vh;
  overflow-y: auto;
  margin-bottom: 0.6rem;
}
.note-thread-entry {
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.55rem 0.7rem;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
}
/* Grid-stacks the visible text and the (optional) secret div into the SAME
   cell (2026-07-23) — the secret overlays the visible note in place rather
   than sitting appended below it as a second block, and the cell still
   auto-sizes to whichever of the two is taller. */
.note-thread-original-entry {
  margin-bottom: 0.4rem;
  display: grid;
}
.note-thread-original-entry > * { grid-area: 1 / 1; }
.note-thread-author {
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.note-thread-text {
  font-size: 0.85rem;
  color: var(--white);
  white-space: pre-wrap;
  word-break: break-word;
}
/* Invisible Ink reveal (2026-07-23) — only ever rendered at all if the
   reader currently holds the ink (js/inventory.js's renderNoteThread), so
   there's no "locked" variant to design for. Grid-stacked over the plain
   text above (.note-thread-original-entry), not appended below it — a
   near-opaque violet/UV fill (rather than the faint tint a below-appended
   block could get away with) is what actually makes it read as a layer
   overlaying the note rather than the plain text just showing through
   messily underneath. */
.note-thread-secret {
  padding: 0.55rem 0.7rem;
  border: 1px dashed rgba(201, 182, 255, 0.55);
  border-radius: var(--radius-sm);
  background: rgba(60, 28, 110, 0.94);
  color: #e4d6ff;
  font-style: italic;
  font-size: 0.85rem;
  white-space: pre-wrap;
  word-break: break-word;
}

/* ---------- World Objects (2026-07-17) — the "find and clear stuff" list
   view over every mapItems entry, js/admin.js's renderWorldObjects(). ---------- */
.world-objects-filter-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.5rem 0;
}
.world-objects-filter-row select {
  flex: 1;
  min-width: 0;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: 1rem; /* 16px floor — iOS Safari auto-zoom-on-focus */
  padding: 0.5rem 0.6rem;
}
.world-objects-filter-row select:focus { outline: none; border-color: var(--red); }
.world-objects-bulk-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  margin-bottom: 0.5rem;
}
/* .btn-small's compactness reset exists for dense multi-button HQ list rows
   (see its own comment) — this button sits alone in a wide, uncrowded row
   with nothing forcing it small, so give it back the app's 44px floor. */
.world-objects-bulk-row .btn-small {
  min-height: 44px;
}
.world-objects-select-all-label {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--body);
  font-size: var(--text-sm);
  color: var(--muted);
}
.world-object-row .roster-row-name { cursor: pointer; }
.world-object-open .ops-audit-time {
  display: block;
  margin-top: 0.15rem;
}

/* ---------- Map-dropped item pins (js/missions.js's buildItemMarkers) ---------- */
/* className replaces Leaflet's own default divIcon class entirely, same as
   .home-radar-icon above. Out-of-range pins read as dimmer/inert; in-range
   ones get the same teal ring language the mission "open" tier already uses,
   so "you can actually pick this up right now" reads at a glance. */
.home-item-icon {
  background: none;
  border: none;
}
.home-item-icon-emoji {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--black);
  border: 1.5px solid var(--card-2);
  font-size: 1rem;
  opacity: 0.55;
}
.home-item-icon-in-range .home-item-icon-emoji {
  opacity: 1;
  border-color: #23d3cd;
  box-shadow: 0 0 0 3px rgba(35, 211, 205, 0.25);
}
/* AR Box (js/items.js's arAnchored flag) — a dashed gold ring instead of
   the plain solid ones above, so its pin reads as "different mechanic,"
   not just "in/out of range." Combines with .home-item-icon-in-range's
   own opacity/teal-ring rules (both classes can apply at once) rather than
   overriding them outright. */
.home-item-icon-scan .home-item-icon-emoji {
  border-style: dashed;
  border-color: var(--gold);
}
/* Receptacle highlight (js/inventory.js's Map Drop Tray, 2026-07-17) — an
   in-range trash can pin pulses gold while something's armed and this is a
   valid "drop it in here" target, same motion language .home-radar-ring
   already uses for the player's own position dot. Combines with
   .home-item-icon-in-range's teal ring rather than replacing it, same
   layering as .home-item-icon-scan just above. */
.home-item-icon-receptacle .home-item-icon-emoji {
  animation: home-item-receptacle-pulse 1s ease-in-out infinite;
}
@keyframes home-item-receptacle-pulse {
  0%, 100% { box-shadow: 0 0 0 3px rgba(217, 180, 90, 0.35); }
  50% { box-shadow: 0 0 0 7px rgba(217, 180, 90, 0.6); }
}
@media (prefers-reduced-motion: reduce) {
  .home-item-icon-receptacle .home-item-icon-emoji { animation: none; box-shadow: 0 0 0 4px rgba(217, 180, 90, 0.5); }
}

/* Mission map pins (Map Pin Visual Encoding spec) — a divIcon now instead of
   a bare circleMarker (js/missions.js's buildMapMarkers), so a tier-colored
   dot can carry badge overlays: className replaces Leaflet's own default
   divIcon background/border entirely, same convention .home-radar-icon and
   .home-item-icon already use. */
.mission-pin-icon { background: none; border: none; position: relative; }
.mission-pin-dot {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  margin: 3px auto 0;
  border-radius: 50%;
  border: 1.5px solid var(--black);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
  font-size: 0.6rem;
  line-height: 1;
}
/* A mission with a custom emoji (js/missions.js's missionPinFill) gets a
   slightly bigger dot than the plain default — 16px reads fine empty, but a
   glyph that small is illegible. Badge overlays (.mission-pin-badge) are
   positioned off the icon container, not the dot, so this doesn't disturb
   them. */
.mission-pin-dot-emoji {
  width: 20px;
  height: 20px;
  margin-top: 1px;
  font-size: 0.75rem;
}
.mission-pin-badge {
  position: absolute;
  top: -4px;
  font-size: 0.6rem;
  line-height: 1;
  white-space: nowrap;
  background: rgba(var(--card-rgb), 0.92);
  border-radius: 999px;
  padding: 1px 3px;
  color: var(--white);
}
.mission-pin-badge-flag { left: -6px; }
.mission-pin-badge-clock { right: -6px; }
.mission-pin-badge-group,
.mission-pin-badge-capacity { left: 50%; top: 100%; transform: translateX(-50%); margin-top: 1px; }

/* Soft radar ping (spec: "a mission on the map has little soft radar pings")
   — an expanding, fading ring centered under the pin/hub dot, tier-colored
   to match whatever it's pinging under. Placed before the dot in markup so
   the dot always paints on top of it. Done-by-me missions get a checkmark
   instead (below) and never render this at all — nothing left to draw the
   eye to once it's finished. */
@keyframes map-pin-ping {
  0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0.5; }
  100% { transform: translate(-50%, -50%) scale(2.4); opacity: 0; }
}
.mission-pin-ping,
.hub-pin-ping {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  animation: map-pin-ping 2.2s var(--ease) infinite;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .mission-pin-ping,
  .hub-pin-ping { animation: none; opacity: 0; }
}

/* A completed mission's marker (spec: "when it's done for the player, it
   shows as a check mark instead, and doesn't show pin") — quiet and muted
   rather than another loud tier-colored dot, since there's nothing left to
   do here. */
.mission-pin-check {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  margin: 2px auto 0;
  border-radius: 50%;
  background: rgba(20, 20, 22, 0.55);
  border: 1.5px solid rgba(255, 255, 255, 0.22);
  color: var(--teal);
  font-size: 0.72rem;
  font-weight: 700;
}

/* Hub pins (spec: "distinct icon type, represent a cluster of pickup-able
   missions rather than a single objective") — a fixed gold pin icon, never
   tier-colored like a mission pin, so it reads as a different KIND of pin at
   a glance rather than just another urgency color. Pings the same soft way
   a mission pin does whenever at least one mission reachable from this hub
   is still unfinished for the viewing player (js/missions.js's
   hubHasOpenMission). */
.hub-pin-icon { background: none; border: none; position: relative; }
.hub-pin-dot {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--black);
  border: 1.5px solid var(--gold);
  font-size: 1rem;
}
.hub-pin-ping { background: var(--gold); }

/* A location pin added from a chat thread's "Add to My Map" (js/messages.js's
   pinShareCardHtml, js/missions.js's buildLocationPinMarkers) — a plain
   teardrop marker rather than the circular chip every other home-map pin
   uses, so a temporary agent-sent pin reads visually distinct from a
   mission/hub/item fixture. */
.location-pin-icon { background: none; border: none; }
.location-pin-icon-emoji {
  display: block;
  font-size: 1.4rem;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}

/* A Character sighting (docs/PROPOSED.md §4, js/missions.js's
   buildSightingMarkers/js/admin.js's Field Map equivalent) — same teardrop-
   free "just the emoji" treatment as a location pin above, since this is
   also a temporary, no-claim-config flag rather than a persistent fixture. */
.sighting-icon { background: none; border: none; }
.sighting-icon-emoji {
  display: block;
  font-size: 1.4rem;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}

/* Grant Mission Credit row (roster modal's "everyone else" list) — same box
   treatment as .roster-row, but with the checkbox label and the retroactive-
   grant action as two ends of one flex row instead of one plain label. */
.roster-assign-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.5rem 0.7rem;
  min-height: 44px;
  box-sizing: border-box;
  margin-bottom: var(--space-1);
}
.roster-assign-row label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--mono);
  font-size: 0.85rem;
  color: var(--white);
}

/* Objectives builder (2026-07-18 rework) — the mission form's dynamic,
   typed replacement for the old fixed six-checkbox subtasks list. */
.mission-objectives-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  margin-bottom: var(--space-2);
}
.mission-objective-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.5rem 0.7rem;
  min-height: 44px;
  box-sizing: border-box;
}
.mission-objective-row select,
.mission-objective-row input {
  flex: 1 1 auto;
  min-width: 8rem;
}
.mission-objective-row .mission-objective-qty {
  flex: 0 1 5rem;
  min-width: 4rem;
}
.mission-objective-row .mission-objective-remove-btn {
  flex: 0 0 auto;
  margin-left: auto;
  /* .link-btn sizes itself to its own text, not this row's 44px floor
     (align-self: stretch doesn't reliably stretch a <button> in Chromium —
     the same gotcha already documented against the Roles panel's Revoke
     button) — set the floor explicitly instead of relying on the row. */
  min-height: 2.75rem;
  display: flex;
  align-items: center;
}

/* Add/Save Mission — the whole form's one payoff action, not a dense list
   row, so .btn-small's min-height: 0 reset (meant for compact HQ list rows)
   doesn't belong here. Scoped to these two ids rather than a blanket
   `.modal-actions .btn-small` override since other modals may have
   genuinely small confirm/cancel rows this fix isn't meant to touch. */
#mission-submit-btn,
#mission-cancel-edit {
  min-height: 2.75rem;
}

/* Same reasoning as #mission-submit-btn/#mission-cancel-edit above, applied
   to the other modal-footer actions that are a form's own one-or-two payoff
   buttons rather than a dense list row: the shared confirm-gate dialog
   every danger action routes through (Block, Cancel Trade, Delete Account),
   Report submission, and the Propose Trade wizard + Fulfill Trade picker —
   the latter two tapped on every single trade, the app's flagship feature.
   Still id-scoped rather than a blanket `.modal-actions .btn-small`
   override, same reason as above. */
#confirm-modal-cancel,
#confirm-modal-confirm,
#report-cancel-btn,
#report-submit-btn,
#modal-propose-trade-close,
#propose-trade-back-btn,
#propose-trade-next-btn,
#propose-trade-submit-btn,
#modal-fulfill-trade-close,
#fulfill-trade-accept-btn {
  min-height: 2.75rem;
  display: flex;
  align-items: center;
}

#modal-notify-message {
  width: 100%;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: 0.9rem;
  padding: 0.7rem 0.8rem;
  margin: 0.7rem 0;
  resize: vertical;
}

/* Full-width, bigger tap targets — a pending mission is the single most
   important decision a card can ask for, so its Accept/Decline get the same
   visual weight as a "primary CTA," not small inline buttons. */
.mission-pending-actions {
  display: flex;
  gap: 0.6rem;
}
.mission-pending-actions .btn {
  flex: 1;
  padding: 0.85rem 0.6rem;
  font-size: 0.82rem;
}

/* ---------- Threaded messages ---------- */
.thread-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  cursor: pointer;
}
.thread-row-name {
  font-family: var(--mono);
  font-size: 0.9rem;
  color: var(--white);
}

.thread-messages {
  display: flex;
  flex-direction: column;
  /* Tight base gap for grouped continuations — a fresh run (see
     .chat-bubble-group-start below) adds its own margin-top on top of this,
     so consecutive same-voice bubbles read as one connected block instead of
     evenly-spaced rows. */
  gap: 0.2rem;
  padding: 0.5rem 0;
  /* flex:1 (inside #thread-detail-view's fixed-height column below) instead
     of the old fixed max-height:55vh, which was a guess that left a dead gap
     under the reply row on any screen taller than whatever it was tuned
     against. min-height:0 is required for a flex child to actually scroll
     internally instead of forcing its parent to grow past it. */
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  /* A short thread has nothing to scroll locally, so without this a touch
     drag chains straight through to the document underneath and bounces
     the outer shell (see the html/body rule above). */
  overscroll-behavior-y: contain;
}
@keyframes chat-bubble-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}
.chat-bubble {
  max-width: 74%;
  padding: 0.6rem 0.85rem;
  border-radius: 14px;
  font-size: 0.9rem;
  line-height: 1.4;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  transition: transform 0.15s var(--ease), opacity 0.15s var(--ease);
  /* A pasted URL is one long unbroken token — without this it just pushes
     the bubble past its max-width (or overflows it) instead of wrapping
     onto a second line like the rest of the message text does. */
  overflow-wrap: anywhere;
  word-break: break-word;
  /* Without this, the long-press-to-react gesture (messages.js) races the
     browser's own press-and-hold text selection — the OS starts selecting
     bubble text at the same time our timer is counting up to open the emoji
     picker. -webkit-touch-callout also kills iOS's copy/lookup popover,
     which fires on the same gesture. Every native chat app (iMessage,
     Discord, Telegram) makes the same call: no text-select on a bubble,
     long-press is reserved for the action it already has. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  /* pan-y turned out not to be enough on its own — touch-action only ever
     governed pan/zoom per spec, never text selection, confirmed on-device
     still showing the native selection loupe/Copy-Translate pill despite
     this. messages.js's touchstart handler now preventDefaults the whole
     bubble instead (the one lever that actually works), which as an
     accepted trade-off ALSO takes scroll-starting-on-a-bubble down with it
     — touch-action: none here just matches that reality instead of still
     claiming panning is allowed when JS overrides it anyway. */
  touch-action: none;
}
/* Only genuinely new arrivals animate in — see messages.js's seenMsgIds
   tracking. Applying this unconditionally would replay across the entire
   history on every snapshot, since the whole thread re-renders on any
   message OR reaction change. */
.chat-bubble-in {
  animation: chat-bubble-in 0.25s var(--ease);
}
/* Held during a long-press, before the emoji picker opens — same "grabbed"
   feedback iOS/Discord give a bubble mid-press. */
.chat-bubble-pressing {
  transform: scale(0.97);
  opacity: 0.85;
}
.chat-bubble-group-start {
  margin-top: 0.45rem;
}
.chat-bubble-theirs {
  align-self: flex-start;
  background: var(--card);
  border: 1px solid var(--card-2);
  color: var(--white);
  border-bottom-left-radius: var(--radius-xs);
}
.chat-bubble-mine {
  align-self: flex-end;
  background: var(--red-dim);
  border: 1px solid var(--red);
  color: var(--white);
  border-bottom-right-radius: var(--radius-xs);
}
/* Not the last bubble in a consecutive same-voice run — round the tail
   corner back off so only the run's final bubble reads as "the end of the
   thought" (iMessage/Discord's grouping convention). */
.chat-bubble-theirs.chat-bubble-grouped { border-bottom-left-radius: 14px; }
.chat-bubble-mine.chat-bubble-grouped { border-bottom-right-radius: 14px; }
/* iMessage's treatment for a message that's just 1-2 emoji and nothing else
   (messages.js's emojiOnlyCount) — no bubble chrome, just the emoji itself,
   scaled way up. Compound selectors with .chat-bubble-theirs/-mine so this
   reliably beats their own background/border regardless of rule order. */
.chat-bubble-theirs.chat-bubble-emoji-only,
.chat-bubble-mine.chat-bubble-emoji-only {
  background: none;
  border: none;
  box-shadow: none;
  padding: 0;
  font-size: 3rem;
  line-height: 1.15;
}
.chat-bubble-time {
  display: block;
  font-family: var(--mono);
  font-size: 0.62rem;
  color: var(--muted);
  margin-top: 0.3rem;
}
/* Bare URLs auto-linked in message text — see messages.js's linkifyEscaped.
   Underlined gold rather than the bubble's own text color so a link reads
   as tappable against either bubble side. */
.chat-bubble-link {
  color: var(--gold);
  text-decoration: underline;
  text-underline-offset: 2px;
  /* A <span>, not a real <a> (see messages.js's linkifyEscaped) — no native
     tap/cursor behavior comes for free, so it's spelled out here. The parent
     .chat-bubble now covers touch-action/callout/select/touchstart-
     preventDefault at the whole-bubble level too, so this is belt-and-
     suspenders rather than this span's own special case at this point. */
  cursor: pointer;
  touch-action: none;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
.chat-bubble-link:focus-visible {
  outline: 2px solid var(--red);
  outline-offset: 2px;
}

.thread-reply-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.7rem;
}
.thread-reply-row input {
  flex: 1;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: 999px;
  color: var(--white);
  font-family: var(--body);
  /* 16px minimum — iOS Safari auto-zooms the viewport on focus below this. */
  font-size: 1rem;
  padding: 0.7rem 1.1rem;
  transition: border-color 0.2s var(--ease), background 0.2s var(--ease);
}
/* Everyday replies get a circular send icon (composer-bar feel); HQ's
   broadcast "Post" keeps its text label since that's a meaningfully
   different action, not a quick chat reply — see messages.js's openThread. */
.thread-reply-row .thread-send-icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
  padding: 0;
  border-radius: 50%;
  flex-shrink: 0;
}
/* Same circular treatment as the send button, ahead of the input instead
   of after it — sharing a mission is a distinct action from typing one. */
.thread-reply-row .thread-share-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
  padding: 0;
  border-radius: 50%;
  flex-shrink: 0;
}

/* Mission shared into a chat thread (js/messages.js's missionShareCardHtml)
   — an unfurled card, not a text bubble, so it drops the bubble's own
   background/padding and supplies its own. */
.chat-bubble.chat-bubble-mission-share {
  background: transparent;
  padding: 0;
  border: none;
}
.mission-share-card {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  padding: var(--space-3);
  min-width: 220px;
}
.mission-share-label {
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}
.mission-share-title {
  font-family: var(--display);
  font-size: 1rem;
  color: var(--white);
  margin: 0.2rem 0 0.4rem;
}
.mission-share-meta {
  display: flex;
  gap: var(--space-2);
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--gold);
  margin-bottom: var(--space-2);
}
.mission-share-picker-pts {
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--gold);
}

/* An item, a location pin, or a profile shared into a chat thread
   (js/messages.js's itemShareCardHtml/pinShareCardHtml/
   profileShareCardHtml) — same unfurled-card treatment as
   .mission-share-card above, just more sources for the same idea. */
.chat-bubble.chat-bubble-item-share,
.chat-bubble.chat-bubble-pin-share,
.chat-bubble.chat-bubble-trade-share,
.chat-bubble.chat-bubble-profile-share {
  background: transparent;
  padding: 0;
  border: none;
}
.item-share-card,
.pin-share-card,
.profile-share-card {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  padding: var(--space-3);
  min-width: 220px;
}
.item-share-label,
.pin-share-label,
.profile-share-label {
  font-family: var(--mono);
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}
.item-share-title,
.pin-share-title {
  font-family: var(--display);
  font-size: 1rem;
  color: var(--white);
  margin: 0.2rem 0 0.4rem;
}
.profile-share-row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0.35rem 0 0.6rem;
}
.profile-share-avatar {
  font-size: 1.6rem;
  line-height: 1;
}
.profile-share-info {
  min-width: 0;
}
.profile-share-name {
  font-family: var(--display);
  font-size: 1rem;
  color: var(--white);
  margin: 0;
  overflow-wrap: anywhere;
}
.profile-share-meta {
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--gold);
}
.pin-share-meta {
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--gold);
  margin-bottom: var(--space-2);
}
.pin-share-meta.pin-share-expired {
  color: var(--muted);
}
/* An item offer's status line (js/messages.js's itemShareCardHtml) — same
   treatment as .pin-share-meta, its own class only so the two can carry
   independent copy/color rules if either needs to diverge later. */
.item-share-meta {
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--gold);
}
/* Accept / Send Back — only rendered while the offer is still pending, and
   only for the recipient (see itemShareCardHtml). Stacked with the meta
   line's margin above them via the card's own padding, no separate rule
   needed. */
.item-share-actions {
  display: flex;
  gap: var(--space-2);
  margin-top: var(--space-2);
}
.item-share-actions .btn {
  flex: 1;
}

/* ---------- Fellow agents ---------- */
.fellow-agents {
  margin: 0.7rem 0;
  padding-top: 0.7rem;
  border-top: 1px solid var(--card-2);
}
/* Completed-mission roster, sealed behind a tap — same folded-<details>
   construct as .agent-profile-danger-zone/.danger-zone-summary, just
   restyled as a dossier-envelope affordance instead of a warning. The
   .fellow-agents block above renders unchanged INSIDE this once opened —
   only the wrapper differs from the still-active-mission case. */
.fellow-agents-envelope {
  margin: 0.7rem 0;
  padding-top: 0.7rem;
  border-top: 1px solid var(--card-2);
}
.fellow-agents-envelope .fellow-agents {
  margin: 0.5rem 0 0;
  padding-top: 0;
  border-top: none;
}
.fellow-agents-envelope-summary {
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--muted);
}
.fellow-agents-envelope-summary::-webkit-details-marker { display: none; }
.fellow-agents-envelope-summary::before {
  content: "✉";
  transition: transform 0.15s var(--ease);
}
.fellow-agents-envelope[open] .fellow-agents-envelope-summary::before { transform: rotate(8deg) scale(1.15); }
.fellow-agent-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  row-gap: 0.3rem;
  justify-content: space-between;
  padding: 0.35rem 0;
  font-family: var(--mono);
  font-size: 0.82rem;
  color: var(--white);
}
/* A long codename (no natural break points, e.g. "NiddryStreetSneaker")
   used to push the whole row wider instead of wrapping itself, so whether
   the actions group dropped to its own line — and how much gap it left —
   came out different row to row depending on name length. Letting the name
   shrink and wrap its own text, while the actions group holds its size, is
   what makes every row resolve the same way regardless of codename length. */
.fellow-agent-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}
.fellow-agent-status {
  font-size: 0.7rem;
  color: var(--muted);
  text-transform: uppercase;
  margin-right: 0.5rem;
}
/* Connect status and Message are independent facts now, not one button that
   changes meaning — this group holds up to two controls side by side. */
.fellow-agent-actions {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
  flex-shrink: 0;
}
.fellow-agent-connected {
  color: var(--success);
  font-weight: 700;
  margin-right: 0;
}
.fellow-agent-blocked {
  color: var(--red);
  font-weight: 700;
  margin-right: 0;
}
.inline-btn {
  margin-top: 0 !important;
  margin-left: 0.5rem;
}

/* ---------- Agent Profile action bar (js/accounts.js's
   buildAgentProfileActionBar) ---------- Connect/Connected ▾, Message, and
   ••• as three left-aligned peers (see #agent-profile-actions' own
   justify-content below) — Connected and ••• each open a small anchored
   menu instead of the full-screen "fan out choices" modal the rest of the
   app uses (modal-thread-action-sheet, modal-mission-choice) since these
   are 1-3 short text options right under the button that opened them, not
   a whole new destination. */
.agent-action-dropdown {
  position: relative;
  display: inline-flex;
}
/* position:fixed, not absolute (2026-08-01 fix) — the trigger sits inside
   .profile-card, which has overflow:hidden (clips the profile theme's
   background pattern to its rounded corners); an absolutely-positioned
   menu anchored to that same ancestor got clipped to almost nothing the
   instant it opened, caught in a real-browser verification pass. Fixed
   positioning plus appending the menu straight to <body> (js/accounts.js's
   attachAgentActionDropdownToggle) sidesteps every clipping ancestor
   entirely; left/top are set inline in JS from the trigger's own
   getBoundingClientRect() at open time instead of living here. z-index:30
   — needs to clear .agent-profile-screen's own 28 (the trigger always
   lives on that screen, which is position:fixed with a solid background,
   painting flat over anything behind it at a lower z-index — a second bug
   caught in the same verification pass), and stay under .modal's 40 so
   Report/Share Profile's own modals still layer above whichever dropdown
   item opened them. */
.agent-action-dropdown-menu {
  position: fixed;
  min-width: 9.5rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
  padding: 0.3rem;
  z-index: 30;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}
.agent-action-dropdown-menu.hidden {
  display: none;
}
.agent-action-dropdown-item {
  display: block;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: inherit;
  font-size: 0.85rem;
  padding: 0.55rem 0.6rem;
  min-height: 2.2rem;
  cursor: pointer;
}
.agent-action-dropdown-item:active {
  background: rgba(244, 242, 236, 0.08);
}
.agent-action-dropdown-item-danger {
  color: var(--red);
}
.agent-action-dropdown-trigger-caret {
  margin-left: 0.25rem;
  opacity: 0.7;
}

/* ---------- Toast stack (replaces native alert()) ---------- */
.toast-stack {
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(var(--bottom-nav-h) + 0.5rem);
  z-index: 40;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  padding: 0 1.25rem;
  pointer-events: none;
}
.toast {
  max-width: 420px;
  width: 100%;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-left: 3px solid var(--red);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-modal);
  color: var(--white);
  font-family: var(--body);
  font-size: var(--text-sm);
  line-height: 1.4;
  padding: var(--space-3) var(--space-4);
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.22s var(--ease), transform 0.22s var(--ease);
  pointer-events: auto;
}
.toast-text { flex: 1; }
.toast-action {
  flex-shrink: 0;
  background: none;
  border: none;
  color: var(--gold);
  font-family: var(--mono);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  cursor: pointer;
  padding: 0.3rem 0;
}
.toast-action:active { opacity: 0.6; }
.toast.toast-visible {
  opacity: 1;
  transform: translateY(0);
}
.toast-success { border-left-color: var(--gold); }
.toast-danger { border-left-color: var(--red); }

/* ---------- Loading spinner (2026-07-17) — a small reusable rotating-ring
   indicator for covering the gap between an action and its result: buying
   from the Spy Store, picking something up off the map, rifling through a
   container, taking an item out. js/lib/ui.js's withButtonSpinner (swaps a
   button's own content for the duration of an async action), loadingBlockHtml
   (a content-area placeholder, same tone as .empty-state), and
   showLoadingToast (for actions with no button left on screen to spin — a
   map pickup's confirm dialog closes immediately on tap, see showConfirm)
   all share this one visual instead of three different "please wait" looks. */
.spinner {
  display: inline-block;
  width: 18px;
  height: 18px;
  flex-shrink: 0;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  opacity: 0.85;
  animation: spinner-rotate 0.7s linear infinite;
}
.spinner-small { width: 14px; height: 14px; }
@keyframes spinner-rotate {
  to { transform: rotate(360deg); }
}
/* currentColor (not a fixed color) — matches whichever btn-primary/
   btn-outline/btn-danger text color is already calling it, no extra
   variants needed per button style. */
.btn-spinner-wrap {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 100%;
}
.loading-state {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.toast-loading { border-left-color: var(--gold); }
.toast-spinner { color: var(--gold); }

/* ---------- HQ Comms reactions (Mastodon Reaction, scoped to broadcast) ---------- */
.reaction-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.5rem;
}
.reaction-pill {
  display: inline-flex;
  align-items: center;
  font-family: var(--mono);
  font-size: var(--text-xs);
  background: var(--card-2);
  border: 1px solid var(--card-2);
  border-radius: 999px;
  color: var(--white);
  padding: 0.3rem 0.65rem;
  cursor: pointer;
  transition: transform 0.1s var(--ease), border-color 0.15s var(--ease), background 0.15s var(--ease);
}
.reaction-pill:active { transform: scale(0.9); }
.reaction-pill-mine {
  background: var(--red-dim);
  border-color: var(--gold);
  color: var(--gold);
}
/* Instant tap feedback, independent of the round trip to the server — see
   messages.js's thread-messages click handler, which (re)starts this on
   every tap regardless of whether the toggle ends up adding or removing. */
@keyframes reaction-pill-pop {
  0% { transform: scale(1); }
  40% { transform: scale(1.28); }
  100% { transform: scale(1); }
}
.reaction-pill-pop {
  animation: reaction-pill-pop 0.28s var(--ease);
}
/* Optimistic reactions (messages.js's toggleReactionOptimistic) — a tap
   shouldn't sit there looking stalled until the toggleReaction round trip
   lands. A brand-new pill (no one had reacted with this emoji yet) pops in
   from nothing; either way it pulses semi-transparent until the real
   snapshot confirms it. */
@keyframes reaction-pill-in {
  from { opacity: 0; transform: scale(0.3); }
  to { opacity: 1; transform: scale(1); }
}
@keyframes reaction-pill-pending-pulse {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 1; }
}
.reaction-pill-pending {
  animation: reaction-pill-pending-pulse 1.1s ease-in-out infinite;
}
.reaction-pill-pending-new {
  animation: reaction-pill-in 0.18s var(--ease), reaction-pill-pending-pulse 1.1s ease-in-out 0.18s infinite;
}
/* Emoji picker modal — a real curated grid, not 5 fixed reactions. Opened by
   long-pressing any bubble (see .chat-bubble-pressing below and messages.js's
   long-press handlers) — no persistent add button on the reaction row. */
.emoji-picker-grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 0.55rem;
  max-height: 50vh;
  overflow-y: auto;
  margin-bottom: var(--space-3);
  animation: chat-bubble-in 0.25s var(--ease);
}
.emoji-picker-option {
  font-size: 1.4rem;
  line-height: 1;
  background: var(--card-2);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  padding: 0.6rem 0;
  cursor: pointer;
  transition: transform 0.1s var(--ease);
}
.emoji-picker-option:active { transform: scale(0.9); }

/* ---------- Press-and-hold message action bar ---------- */
/* Deliberately its own thing, not a .modal — a .modal centers a card over a
   near-opaque backdrop, but this needs to hover right next to the bubble
   that triggered it (iMessage/Telegram's contextual pop, not a dialog).
   messages.js positions #message-action-panel with inline top/left computed
   from the bubble's own rect. */
.message-action-overlay {
  position: fixed;
  inset: 0;
  z-index: 25;
  background: rgba(8, 8, 10, 0.5);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s var(--ease);
}
.message-action-overlay:not(.hidden) {
  opacity: 1;
  pointer-events: auto;
}
.message-action-panel {
  position: absolute;
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
  width: max-content;
  max-width: min(340px, calc(100vw - 1.5rem));
  transform: scale(0.92);
  opacity: 0;
  transition: transform 0.16s var(--ease), opacity 0.16s var(--ease);
}
.message-action-overlay:not(.hidden) .message-action-panel {
  transform: scale(1);
  opacity: 1;
}
.message-action-reactions {
  display: flex;
  align-items: center;
  /* Wraps to a second row on the narrowest phones instead of clipping —
     8 quick reactions + "more" is a tight fit for one line as-is. */
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.25rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-lg);
  padding: 0.35rem 0.4rem;
  box-shadow: var(--shadow-modal);
}
.message-action-reaction {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.85rem;
  height: 1.85rem;
  font-size: 1.2rem;
  line-height: 1;
  background: none;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  transition: transform 0.1s var(--ease), background 0.1s var(--ease);
}
.message-action-reaction:active { transform: scale(0.85); background: var(--card-2); }
.message-action-more {
  font-family: var(--mono);
  font-size: 1.1rem;
  font-weight: 700;
  color: var(--muted);
  background: var(--card-2);
}
.message-action-menu {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  overflow: hidden;
  box-shadow: var(--shadow-modal);
}
.message-action-menu-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  background: none;
  border: none;
  color: var(--white);
  font-family: var(--body);
  font-size: 0.9rem;
  padding: 0.7rem 0.9rem;
  cursor: pointer;
  text-align: left;
}
.message-action-menu-item:active { background: var(--card-2); }
.message-action-menu-item .ui-icon { color: var(--muted); flex-shrink: 0; }
/* Report (js/messages.js's message action bar) — same red-for-a-rare/
   irreversible-feeling action treatment as .settings-menu-danger/
   .mission-kebab-menu-item-danger, just this menu's own class name since it
   isn't one of those two component's shared stylesheets. */
.message-action-menu-item-danger { color: var(--red); }
/* Bubble stays visibly "picked up" while the bar is open, same lifted feel
   as the pre-open .chat-bubble-pressing state. */
.chat-bubble-menu-open {
  transform: scale(0.97);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
}

/* ---------- Roles panel (HQ Control) ---------- */
.roles-grant-row {
  display: flex;
  gap: 0.5rem;
  margin: 0.5rem 0;
}
.roles-grant-row input {
  flex: 1;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--mono);
  /* 16px minimum — iOS Safari auto-zooms the viewport on focus below this. */
  font-size: 1rem;
  padding: 0.5rem 0.7rem;
}
/* ===== UPGRADE A: missions + admin console ===== */
/* Loaded after app.css, so plain later-cascade overrides work; selectors stay
   at or just above the specificity of the rules they replace. All colors and
   spacing come from :root tokens, so HQ mode's variable swap re-skins these
   for free. */

/* ---------- Status color system ---------- */
/* Open/browsable cards join the gold "you could act on this" family with
   awaiting-RSVP (deck: gold = pending/open, teal = committed) — app.css
   still ships the older teal mapping for state-available, overridden here. */
.mission-card.state-available { border-left-color: var(--gold); }
.state-available .mission-status { color: var(--gold); }
.state-available .status-dot { background: var(--gold); }

/* Active tab's three-state read (design-overhaul-7.13): self-paced missions
   keep the plain committed teal, a reserved rendezvous reads as a held gold
   commitment, and a rendezvous inside its live window reads as urgent red
   with a pulsing dot — same status-color language as everywhere else. */
.mission-card.state-active.substate-reserved { border-left-color: var(--gold); }
.state-active.substate-reserved .mission-status { color: var(--gold); }
.state-active.substate-reserved .status-dot { background: var(--gold); }
.mission-card.state-active.substate-live { border-left-color: var(--red); }
.state-active.substate-live .mission-status { color: var(--red); }
.state-active.substate-live .status-dot {
  background: var(--red);
  box-shadow: 0 0 6px var(--red);
  animation: live-dot-pulse 1.6s ease-in-out infinite;
}
@keyframes live-dot-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

/* Cancelled cards read as struck records, not just dimmed ones. */
.mission-card.state-cancelled .mission-title {
  text-decoration: line-through;
  color: var(--muted);
}

/* ---------- Capacity fill bar ---------- */
.cap-bar {
  display: block;
  height: 5px;
  border-radius: 3px;
  background: var(--card-2);
  overflow: hidden;
  margin: 0 0 var(--space-3);
}
.cap-fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--gold), var(--red));
  transition: width 0.4s var(--ease);
}
/* Tightened only when a bar follows, so bar-less minCapacity lines keep the
   stock rhythm. */
.mission-capacity.has-bar { margin-bottom: var(--space-1); }

/* ---------- Rendezvous / countdown box ---------- */
.mission-rendezvous {
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  box-shadow: inset 0 1px 6px rgba(0, 0, 0, 0.45);
}
/* Inside the dark box the label is a quiet caption, not the section-red
   .label default — the countdown is the loud element here. */
.mission-rendezvous .label {
  color: var(--muted);
  letter-spacing: 0.12em;
  margin-bottom: var(--space-1);
}
/* tabular-nums so the ticking digits hold their columns instead of the whole
   line reflowing every second (display face may ignore it; harmless then). */
.mission-countdown,
.signup-countdown {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}

/* ---------- Accept / Decline weight ---------- */
/* Solid success-green commit button; dark text via --black keeps contrast in
   both themes since HQ mode leaves --success untouched. */
.btn-success {
  background: var(--success);
  border-color: transparent;
  color: var(--black);
}
.mission-pending-actions .btn { flex: 1; }

/* ---------- "Just arrived" entrance ---------- */
@keyframes mission-slide-in {
  from { opacity: 0; transform: translateX(-12px); }
  to { opacity: 1; transform: translateX(0); }
}
@keyframes mission-ring-flash {
  from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--gold) 55%, transparent), var(--shadow-card); }
  to { box-shadow: 0 0 0 12px color-mix(in srgb, var(--gold) 0%, transparent), var(--shadow-card); }
}
.mission-card.mission-just-arrived {
  animation:
    mission-slide-in 0.5s var(--ease),
    mission-ring-flash 0.9s var(--ease) 0.1s backwards;
}

/* ---------- Completion moment ---------- */
/* Verified-stamp look from the deck: success-colored, slight counter-rotate,
   check mark added here so the JS keeps emitting plain "Complete". */
.mission-card.state-complete .stamp {
  color: var(--success);
  border-color: var(--success);
  background: var(--success-dim);
  transform: rotate(5deg);
}
.mission-card.state-complete .stamp::before { content: "\2713\00a0"; }

@keyframes mission-complete-pulse {
  from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--success) 60%, transparent), var(--shadow-card); }
  to { box-shadow: 0 0 0 16px color-mix(in srgb, var(--success) 0%, transparent), var(--shadow-card); }
}
.mission-card.mission-complete-pulse {
  animation: mission-complete-pulse 0.8s var(--ease);
}

/* "View Mission" from a shared mission_share chat card (js/missions.js's
   revealMission) — a gold ring, not the green success pulse above, since
   this means "look here," not "you finished this." */
@keyframes mission-highlight-pulse {
  from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--gold) 60%, transparent), var(--shadow-card); }
  to { box-shadow: 0 0 0 16px color-mix(in srgb, var(--gold) 0%, transparent), var(--shadow-card); }
}
.mission-card.mission-highlight-pulse {
  animation: mission-highlight-pulse 0.8s var(--ease) 2;
}

/* ---------- HQ console: dense mission rows (deck Screen 08) ---------- */
.admin-mission-card {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  padding: var(--space-4);
  box-shadow: var(--shadow-card);
}
.admin-mission-top {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-2);
}
.admin-mission-title {
  font-family: var(--mono);
  font-size: var(--text-base);
  font-weight: 600;
  color: var(--white);
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.status-tag {
  font-family: var(--mono);
  font-size: 0.56rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  padding: 2px 6px;
  border-radius: var(--radius-xs);
  flex-shrink: 0;
}
.status-tag.live { background: var(--success-dim); color: var(--success); }
.status-tag.draft { background: var(--card-2); color: var(--muted); }
.status-tag.cancelled { background: var(--red-dim); color: var(--red); }
.status-tag.closed { background: var(--card-2); color: var(--gold); }

/* One-line clamp: the console scans by title + chips; the full briefing
   lives in the Edit form. */
.admin-mission-brief {
  color: var(--muted);
  font-size: var(--text-sm);
  line-height: 1.4;
  margin: var(--space-1) 0 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.admin-mission-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--muted);
  margin-top: var(--space-2);
}
.fill-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  color: var(--gold);
}
.fill-chip .cap-bar {
  display: inline-block;
  width: 46px;
  margin: 0;
}
.admin-mission-card .mission-admin-row { margin-top: var(--space-2); }
/* Redesign schema chips (2026-07-18) — proof method, require-start,
   group/walk-up framing, hub, chain unlocks — a second meta row so the
   always-relevant capacity/points/audience row above never gets crowded
   out on a mission that happens to use several of the newer fields. */
.admin-mission-schema-chips { margin-top: 0; }
.schema-chip {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.02em;
  background: var(--card-2);
  color: var(--teal);
  padding: 2px 7px;
  border-radius: var(--radius-xs);
  border: 1px solid var(--card-2);
}
.admin-mission-tags,
.mission-tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-top: var(--space-2);
}
/* Player mission cards (2026-07-30) now render tags last, after the actions
   row, on every card that has any — not just the pre-pin browsing state.
   A top border + extra padding (same "trailing section, set apart from the
   card body" treatment .fellow-agents already uses) keeps them from reading
   as glued onto the bottom of the Pin/Complete/Unpin buttons. */
.mission-card > .mission-tags {
  margin-top: 0.9rem;
  padding-top: 0.7rem;
  border-top: 1px solid var(--card-2);
}
.mission-tag-chip {
  font-family: var(--mono);
  font-size: 0.62rem;
  letter-spacing: 0.02em;
  background: var(--card-2);
  color: var(--muted);
  padding: 2px 7px;
  border-radius: var(--radius-xs);
}
/* ---------- Checklist missions ---------- */
.mission-subtasks {
  list-style: none;
  margin: 0 0 0.9rem;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.subtask-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--muted);
  padding: 0.4rem 0.6rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
}
.subtask-check {
  width: 1.1rem;
  height: 1.1rem;
  flex-shrink: 0;
  border: 1.5px solid var(--card-2);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--success);
  font-size: 0.75rem;
}
.subtask-item.is-done {
  color: var(--white);
  border-color: var(--success);
}
.subtask-item.is-done .subtask-check {
  background: var(--success);
  border-color: var(--success);
  color: var(--black);
}
.subtask-label { flex: 1; }
.subtask-manual-btn { flex-shrink: 0; }
/* "How do I do this?" — currently only app_installed uses it (platform-
   specific install steps have no self-evident answer the way "write your
   bio" does). Native title= gives a hover tooltip on desktop for free;
   the click handler (js/missions.js) opens the real platform-specific
   install modal for everyone else, since touch devices have no hover. */
.subtask-info-btn {
  flex-shrink: 0;
  width: 1.3rem;
  height: 1.3rem;
  border-radius: 50%;
  border: 1px solid var(--card-2);
  background: transparent;
  color: var(--muted);
  font-family: var(--mono);
  font-size: 0.7rem;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.subtask-info-btn:hover,
.subtask-info-btn:focus-visible {
  color: var(--white);
  border-color: var(--muted);
}

.mission-lane-status {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.04em;
  color: var(--muted);
  white-space: nowrap;
}
.mission-lane-status-action {
  color: var(--red);
  cursor: pointer;
  -webkit-user-select: none;
  user-select: none;
}
/* ---------- Nearby / Remote lane heads (Home redesign) ---------- */
.mission-lane-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  margin-bottom: 0.5rem;
}
.mission-lane-head .label { margin-bottom: 0; }
.mission-distance {
  color: var(--gold);
  font-weight: 600;
}
/* Drafts recede so the live board pops; cancelled keep full contrast — the
   red tag is the signal and an operator may still need to read them. */
.admin-mission-card.is-draft { opacity: 0.55; }
.admin-mission-card.is-cancelled .admin-mission-title {
  text-decoration: line-through;
  color: var(--muted);
}
/* ===== UPGRADE B: messages + notifications ===== */
/* Loaded after app.css, so everything here wins by cascade order without
   needing !important. Only existing custom properties are used, so HQ mode's
   body-level token overrides re-theme all of this for free. */

/* ---------- Thread list — iMessage-style rows (deck screen 05) ---------- */
/* messages.js now renders avatar chip + stacked name/preview + right-aligned
   time/dot; the base .thread-row's space-between layout is replaced rather
   than patched, and rows get a touch more breathing room than the generic
   roster-row padding since each one carries two lines. */
#thread-list .thread-row {
  justify-content: flex-start;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
}
.thread-avatar {
  width: 38px;
  height: 38px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.15rem;
  line-height: 1;
  background: var(--card-2);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
}
.thread-row-main {
  flex: 1;
  min-width: 0; /* lets the preview actually truncate inside flex */
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}
#thread-list .thread-row-name {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  font-family: var(--mono);
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--white);
  overflow: hidden;
  white-space: nowrap;
}
.thread-row-preview {
  font-family: var(--body);
  font-size: var(--text-sm);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.thread-row-preview-empty {
  font-style: italic;
  opacity: 0.7;
}
.thread-row-side {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-1);
  flex-shrink: 0;
}
.thread-row-time {
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--muted);
  white-space: nowrap;
}
.thread-unread-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--red);
}
/* Unread rows brighten the preview — the iMessage triple signal (bold name /
   brighter preview / dot) rather than a count badge on the row. */
.thread-row-unread .thread-row-preview {
  color: var(--white);
}

/* Broadcast pinned channel — red-tinted sweep + red-ringed 📡 chip so HQ
   Comms reads as "the whole network," not the most recent DM. */
#thread-list .thread-row-broadcast {
  background: linear-gradient(90deg, var(--red-dim), var(--card));
  border-color: var(--red-dim);
}
.thread-row-broadcast .thread-avatar {
  background: var(--red-dim);
  border-color: var(--red);
}

/* ---------- Thread detail (deck screen 06) ---------- */
/* Standard messenger header: back arrow, avatar, name (+ optional subhead)
   all in one row, actions pushed to the far end — replaces the old layout
   where the name sat on its own label line below a row that held nothing
   but Back and the admin buttons. */
.thread-detail-header {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-bottom: var(--space-3);
  margin-bottom: var(--space-3);
  border-bottom: 1px solid var(--card-2);
}
/* Was a raw &larr; character doing double duty as button text — no real tap
   target, no visual weight to match the avatar beside it. A real icon-button
   (same 44px-minimum, press-scale language every other icon control in the
   app already uses) instead of an oversized text glyph. */
.thread-detail-back-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.4rem;
  height: 2.4rem;
  flex-shrink: 0;
  margin-left: -0.5rem; /* optically aligns the glyph, not the full tap box, with the header's edge */
  padding: 0;
  background: none;
  border: none;
  border-radius: 50%;
  color: var(--white);
  cursor: pointer;
  transition: background 0.15s var(--ease), transform 0.1s var(--ease);
}
.thread-detail-back-btn:active {
  background: var(--card-2);
  transform: scale(0.9);
}
.thread-detail-avatar {
  width: 34px;
  height: 34px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.05rem;
  background: var(--card-2);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
}
.thread-detail-avatar-broadcast {
  background: var(--red-dim);
  border-color: var(--red);
}
.thread-detail-titles { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
.thread-detail-name {
  font-family: var(--body);
  font-weight: 700;
  font-size: var(--text-base);
  color: var(--white);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Used to be a dotted underline, which reads like a broken link rather than
   a tappable header — every other tap target in the app signals itself with
   press feedback, not static underlining, so this matches that instead.
   text-decoration: none is the actual override — the base .thread-title-clickable
   rule above sets it, and CSS cascades per-property, so leaving it unset
   here (as this rule used to) never actually cancelled it out. */
.thread-detail-name.thread-title-clickable {
  cursor: pointer;
  text-decoration: none;
  transition: opacity 0.1s var(--ease);
}
.thread-detail-name.thread-title-clickable:active { opacity: 0.6; }
.thread-detail-sub {
  font-family: var(--mono);
  font-size: 0.66rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--red);
}
.thread-detail-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }

/* HQ Comms hero card — pulled out of the thread list entirely so it reads as
   its own identity, not the pinned first DM. */
.hq-comms-card {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  text-align: left;
  background: linear-gradient(90deg, var(--red-dim), var(--card));
  border: 1px solid var(--red-dim);
  border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-4);
  margin-bottom: var(--space-3);
  cursor: pointer;
  font: inherit;
  color: inherit;
}
.hq-comms-card-icon {
  width: 38px;
  height: 38px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--red-dim);
  border: 1px solid var(--red);
  border-radius: var(--radius-md);
  color: var(--red);
}
.hq-comms-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.hq-comms-card-title {
  font-family: var(--mono);
  font-weight: 700;
  font-size: var(--text-sm);
  letter-spacing: 0.08em;
  color: var(--white);
}
.hq-comms-card-preview {
  font-family: var(--body);
  font-size: var(--text-sm);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.hq-comms-card-side { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-1); flex-shrink: 0; }

/* Incoming sender tags go WhatsApp-group style: bare red mono name, no chip
   border. Own-bubble persona tags (Agent X's HQ/AGENT X labels in DMs) keep
   the original bordered-chip look from app.css untouched. */
.chat-bubble-theirs .chat-bubble-persona {
  border: none;
  padding: 0;
  margin: 0 0 0.25rem;
  color: var(--red);
  letter-spacing: 0.08em;
}

/* ---------- Notifications feed (deck screen 07) ---------- */
/* Bigger glanceable icon, text + relative time stacked beside it. */
.notification-row-icon {
  font-size: 1.25rem;
  line-height: 1.35;
}
.notification-row-time {
  display: block;
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--muted);
  margin-top: 0.2rem;
}
/* ===== UPGRADE C: social + motion + voice ===== */

/* ---------- Podium leaderboard (deck: "Leaderboard & Badges" bonus screen) ----------
   Top 3 as a silver|gold|bronze stage instead of three more identical rows —
   the center slot is taller and gold-ringed so #1 reads as a prize, not a
   list index. Everything colors from the existing tokens, so HQ mode's
   custom-property swap re-themes the stage with no extra rules. */
.lb-podium {
  display: flex;
  align-items: flex-end;
  gap: var(--space-2);
}
.lb-podium-slot {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.2rem;
  background: var(--card-2);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md) var(--radius-md) var(--radius-xs) var(--radius-xs);
  padding: var(--space-3) var(--space-1) var(--space-2);
  text-align: center;
  box-shadow: var(--shadow-card);
}
.lb-podium-1 {
  /* Extra top padding is what physically raises the gold slot — flex-end
     alignment does the rest, no absolute positioning needed. Ring matches
     .badge-card-new's hardcoded gold-alpha precedent. */
  padding-top: var(--space-6);
  border-color: var(--gold);
  box-shadow: var(--shadow-card), 0 0 0 1px rgba(217, 180, 90, 0.35);
}
.lb-podium-medal { font-size: 1.35rem; line-height: 1; }
.lb-podium-1 .lb-podium-medal { font-size: 1.7rem; }
.lb-podium-name {
  font-family: var(--mono);
  font-size: 0.66rem;
  color: var(--white);
  overflow-wrap: anywhere;
}
.lb-podium-pts {
  font-family: var(--mono);
  font-size: 0.62rem;
  color: var(--gold);
}
/* Slots rise in a quick stagger each time the modal opens — display:none ->
   flex on the parent restarts these for free, no JS involved. */
@keyframes lb-podium-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
.modal:not(.hidden) .lb-podium-slot { animation: lb-podium-in 0.25s var(--ease) backwards; }
.modal:not(.hidden) .lb-podium-slot:nth-child(1) { animation-delay: 0.05s; }
.modal:not(.hidden) .lb-podium-slot:nth-child(2) { animation-delay: 0.12s; }
.modal:not(.hidden) .lb-podium-slot:nth-child(3) { animation-delay: 0.08s; }

/* "That's me" — the one placement anyone actually scans a leaderboard for.
   Subtle red wash + a mono YOU tag rather than anything shouty. */
.lb-podium-slot.lb-self { background: linear-gradient(180deg, var(--red-dim), var(--card-2)); }
.leaderboard-row.lb-self {
  background: var(--red-dim);
  border-color: var(--red);
}
.lb-you-tag {
  font-family: var(--mono);
  font-size: 0.55rem;
  letter-spacing: 0.08em;
  color: var(--red);
  border: 1px solid var(--red);
  border-radius: 3px;
  padding: 0 0.3rem;
  margin-left: 0.35rem;
  vertical-align: 1px;
  white-space: nowrap;
}

/* ---------- Richer discover/search agent rows (deck Screen 02) ----------
   A codename alone made a 40-agent directory read as an undifferentiated
   wall (STUDIO_POLISH_REVIEW §3) — the secondary line gives every row one
   scannable data point, and Discover flags who literally just walked in. */
.agent-row { cursor: pointer; }
.agent-row .roster-row-name {
  display: flex;
  align-items: center;
  min-width: 0;
}
.agent-row .agent-avatar {
  /* Avatar as a chip (deck's 34px square) instead of a bare emoji floating
     against the codename — gives every row the same left rhythm even when
     an agent never picked an avatar. */
  width: 2.1rem;
  height: 2.1rem;
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--card-2);
  border-radius: var(--radius-sm);
  margin-right: var(--space-2);
  font-size: 1.05rem;
}
.agent-row-text {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
}
.agent-row-codename { min-width: 0; }
.agent-row-sub {
  /* Body face, not the row's mono — meta reads quieter than the codename. */
  font-family: var(--body);
  font-size: var(--text-xs);
  color: var(--muted);
}
.pill-new {
  font-family: var(--mono);
  font-size: 0.6rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
  color: var(--teal);
  border: 1px solid rgba(35, 211, 205, 0.4);
  background: var(--teal-dim);
  flex-shrink: 0;
}

/* ---------- Profile tab polish ----------
   The tab itself already slides in via .tab-panel:not(.hidden); the avatar
   getting its own beat on top of that makes the profile land as "your card
   being dealt," not just another panel swap. */
@keyframes pp-avatar-in {
  from { opacity: 0; transform: scale(0.88); }
  to { opacity: 1; transform: scale(1); }
}
#tab-profile:not(.hidden) .profile-card-avatar { animation: pp-avatar-in 0.3s var(--ease); }

/* Link reads as a tappable chip (appmock treatment), not a bare gold string. */
.profile-card-link {
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: 999px;
  padding: 0.25rem 0.7rem;
}

/* ---------- Press feedback ----------
   .roster-row:active (which thread rows inherit) and .btn:active already
   exist in app.css — badges were the one tappable card left with no press
   feel, despite tap-for-blurb being their whole interaction. */
.badge-card,
.badge-manage-card,
.flair-manage-card { transition: transform 0.1s var(--ease); }
.badge-card:active,
.badge-manage-card:active,
.flair-manage-card:active { transform: scale(0.96); }

/* Empty-state inside any badge grid spans the full grid width — otherwise it
   wraps inside a single 110px column and reads as a broken layout. */
.badge-grid .empty-state { grid-column: 1 / -1; }
/* Badge Library's category headings (js/accounts.js's renderBadgeLibrary) —
   the grid's own auto-fill columns would otherwise squeeze a heading into a
   single 110px card-width cell instead of spanning the row above its group. */
.badge-grid .badge-category-heading { grid-column: 1 / -1; margin: 0.5rem 0 0; }
.badge-grid .badge-category-heading:first-child { margin-top: 0; }

/* ===== REWORK: dossier count, one-way comms, compose chips, profile screen, HQ tabs ===== */

/* Emoji avatar and codename breathe apart; the h2 was letting them touch. */
.codename { display: flex; align-items: center; gap: 0.5rem; }

/* Dossier chip, collapsed — codename+avatar on the left (via .dossier-row's
   space-between), Spy Points stacked and right-aligned on the other side —
   this chip only ever shows the one number worth glancing at from Home. */
.dossier-points {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  line-height: 1.15;
}
.dossier-points span {
  font-family: var(--mono);
  font-size: 0.56rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 0.15rem;
}
.dossier-stat-link {
  background: none;
  border: none;
  font: inherit;
  cursor: pointer;
  transition: transform 0.1s var(--ease);
}
.dossier-stat-link:active { transform: scale(0.95); }

/* Contacts stat reuses .dossier-stat-link for its button reset (opens the
   Contacts screen), but that reset also clears the divider border every
   other .profile-card-stat in this row keeps — restore it here so Contacts
   still matches its Missions/Points siblings. */
#profile-card-contacts-btn { border-right: 1px solid var(--card-2); }

/* Tab panels whose first child is a bare section label were starting flush
   against the topbar — give headed panels real breathing room. */
.tab-panel-headed { padding-top: var(--space-5); }

/* HQ View is an ops console, not a person: no self-profile tab. This used to
   free up the 5th bottom-nav slot for Badges (an ops-only tool shown only in
   HQ View, same mutual-exclusivity idea as Instagram's account-switcher
   pattern) — Badges is gone as its own tab now (2026-07-30 HQ console IA
   pass, folded into a People sub-tab instead), so this slot just goes empty
   in HQ Mode rather than being reclaimed by anything else; HQ's own
   navigation there is Map/Missions/Messages/Mainframe (4 icons), same as it
   already was minus the one Badges used to occupy. */
body.hq-mode-active .bottom-nav .tab[data-tab="profile"] { display: none; }

/* Notifications (unread threads, pending RSVPs, connect requests) are all
   personal-agent concepts — HQ Control isn't a person and shouldn't have a
   feed of "things that happened to you." */
body.hq-mode-active .notifications-feed-btn { display: none; }

/* ---------- Full-screen agent profile ---------- */
.agent-profile-screen {
  position: fixed;
  inset: 0;
  z-index: 28; /* above bottom-nav (25); modals opened from here sit higher (below) */
  background: var(--black);
  overflow-y: auto;
  padding-top: var(--safe-top);
}
/* Drill-in screens (Agent Profile, Badge Library — both use this class) had
   zero entrance motion, just an instant snap. Tabs settle in from below
   (tab-panel-in); these are a different kind of navigation — going one level
   deeper, not switching lanes — so they get a distinct push-from-the-side
   motion instead of reusing the same vertical fade everywhere. */
@keyframes screen-push-in {
  from { opacity: 0; transform: translateX(18px); }
  to { opacity: 1; transform: translateX(0); }
}
.agent-profile-screen:not(.hidden) {
  animation: screen-push-in 0.24s var(--ease);
}
/* Save's exit motion (js/accounts.js's closeBoardEditor) — the reverse of
   screen-push-in above, played BEFORE the screen is hidden so the board
   visibly scrolls back to its resting position instead of just cutting to
   nothing. Board Editor is the only screen in this family that needs an
   exit animation at all (see closeBoardEditor's comment on why relocating
   the board out has to wait for it) — every other .agent-profile-screen
   still closes with a plain instant .hidden toggle, unchanged. */
@keyframes screen-push-out {
  from { opacity: 1; transform: translateX(0); }
  to { opacity: 0; transform: translateX(18px); }
}
.board-editor-screen.board-editor-screen-exit {
  animation: screen-push-out 0.2s var(--ease) forwards;
}
.agent-profile-topbar {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.9rem 1.25rem;
  background: rgba(8, 8, 10, 0.92);
  backdrop-filter: blur(6px);
  border-bottom: 1px solid var(--card-2);
}
.agent-profile-topbar .link-btn { margin-top: 0; }
.agent-profile-body { padding: var(--space-5) 1.25rem calc(2rem + var(--safe-bottom)); }
/* Same whole-screen profile-color treatment as #tab-profile, scoped to the
   agent-profile body specifically so the Badge Library screen (which shares
   .agent-profile-body but has no id) never picks up anyone's color. */
#agent-profile-content {
  background-color: var(--profile-color, transparent);
  background-image: var(--profile-pattern, none);
  background-position: var(--profile-pattern-position, 0 0);
  background-size: var(--profile-pattern-size, auto);
  background-repeat: var(--profile-pattern-repeat, repeat);
  transition: background-color 0.25s var(--ease);
  position: relative;
  z-index: 0;
}
/* Already has top breathing room via .agent-profile-body's own padding —
   just rounding to match #tab-profile's textured treatment above. */
#agent-profile-content.profile-theme-textured {
  border-top-left-radius: var(--radius-lg);
  border-top-right-radius: var(--radius-lg);
}

/* AR Radar/AR Box screen styling moved to css/ar-radar.css — see
   docs/AR-ARCHIVE.md. */

.agent-profile-actions { display: flex; gap: 0.6rem; justify-content: flex-start; margin-top: var(--space-4); flex-wrap: wrap; }
.agent-profile-hq-section { margin-top: var(--space-5); }
/* Visually set apart from the rest of the dossier — these two actions are
   real, irreversible-or-close-to-it impacts on someone else's account. */
.agent-profile-points-zone {
  margin-top: var(--space-5);
  padding: var(--space-4);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
}
.points-quick-row {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: var(--space-3);
}
.points-quick-row .btn { flex: 1; min-width: 3.5rem; }
.points-custom-row { display: flex; gap: 0.5rem; }
.points-custom-row input {
  flex: 1;
  min-width: 0;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  /* 16px floor — iOS Safari auto-zooms the viewport on focus below this,
     same reasoning as thread-reply-row's input. */
  font-size: 1rem;
  padding: 0.6rem 0.8rem;
}
/* Folded shut by default (js/accounts.js renders this as a <details> with
   no "open" attribute) — Danger Zone is a destination you choose to open,
   not a peer of the stat row/badge grid sitting in the same scroll. */
.agent-profile-danger-zone {
  margin-top: var(--space-5);
  border: 1px solid var(--red-dim);
  border-radius: var(--radius-md);
}
.danger-zone-summary {
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  padding: var(--space-3) var(--space-4);
  color: var(--red);
}
.danger-zone-summary::-webkit-details-marker { display: none; }
.danger-zone-summary::before {
  content: "▸";
  font-size: 0.7em;
  transition: transform 0.15s var(--ease);
}
.agent-profile-danger-zone[open] .danger-zone-summary::before { transform: rotate(90deg); }
.danger-zone-body { padding: 0 var(--space-4) var(--space-4); }
.danger-zone-body .btn { display: block; width: 100%; margin-bottom: var(--space-2); }
.danger-zone-body .field-hint { margin: 0 0 var(--space-4); }
.danger-zone-body .field-hint:last-child { margin-bottom: 0; }
/* Quoted-message preview inside #modal-report (js/relationships.js's
   openReportModal) — only shown when reporting a specific message rather
   than an agent generally, so the reporter can confirm what they're about
   to send before it goes to HQ. */
.report-modal-quote {
  border-left: 2px solid var(--card-2);
  background: var(--black);
  border-radius: var(--radius-sm);
  padding: 0.5rem 0.7rem;
  margin: 0 0 0.6rem;
  font-family: var(--body);
  font-size: var(--text-sm);
  font-style: italic;
  color: var(--muted);
  max-height: 6rem;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
/* System panel's Reports list (js/admin.js's renderSystemPanel) — reuses
   .report-modal-quote for the same reported-message excerpt, just under a
   roster-row instead of inside a modal, so the two need their own margin-0
   variant rather than the modal spacing above. */
.system-report-entry { display: flex; flex-direction: column; gap: 0.3rem; }
.system-report-entry .report-modal-quote { margin: 0; }
.system-report-entry .field-hint { margin: 0; }
/* Modals must layer over the full-screen profile (grant badge opens from it). */
.modal { z-index: 40; }
.toast-stack { z-index: 50; }

/* Grappling Claw's "reaching for it" effect (2026-07-24, js/lib/ui.js's
   playClawDeployEffect) — a chain-and-hook traveling from the player's own
   position to the item's, played once a map pin is only in reach because
   the claw is owned. Appended to #home-map's own container (that
   selector's own comment explains its `isolation: isolate`), positioned at
   the FROM point in map-container pixel coordinates — --claw-length/
   --claw-angle/--claw-dx/--claw-dy are all set inline per-call by
   playClawDeployEffect since they depend on the two real points converted
   via Leaflet's own latLngToContainerPoint, not anything CSS alone knows.
   z-index 650 is deliberately inside Leaflet's own 200(tiles)-700(popups)
   pane range — high enough to paint over the marker pane (600, both the
   player's own radar dot and the item's pin) without fighting an open
   popup (700), scoped to #home-map's isolated context so it can never
   leak out and outrank the topbar/bottom-nav the way an unscoped z-index
   this high normally would. */
.claw-deploy-fx {
  position: absolute;
  z-index: 650;
  width: 0;
  height: 0;
  pointer-events: none;
}
.claw-deploy-chain {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--claw-length);
  height: 3px;
  transform-origin: 0 50%;
  transform: rotate(var(--claw-angle));
  background-image: repeating-linear-gradient(to right, #d5d5d5 0 5px, transparent 5px 10px);
  animation:
    claw-chain-extend 0.5s ease-out forwards,
    claw-chain-fade 0.3s ease-in 0.55s forwards;
}
.claw-deploy-hook {
  position: absolute;
  left: 0;
  top: 0;
  font-size: 1.6rem;
  line-height: 1;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.55));
  animation:
    claw-hook-travel 0.55s ease-out forwards,
    claw-hook-fade 0.3s ease-in 0.5s forwards;
}
@keyframes claw-chain-extend {
  from { clip-path: inset(0 100% 0 0); }
  to { clip-path: inset(0 0 0 0); }
}
@keyframes claw-chain-fade { to { opacity: 0; } }
@keyframes claw-hook-travel {
  from { transform: translate(-50%, -50%); }
  to { transform: translate(calc(var(--claw-dx) - 50%), calc(var(--claw-dy) - 50%)); }
}
@keyframes claw-hook-fade { to { opacity: 0; } }

/* ---------- Compose (IG-style To: row with chips) ---------- */
.compose-to-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.35rem;
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  padding: 0.5rem 0.7rem;
  margin-bottom: 0.15rem;
}
.compose-to-label {
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
#compose-chips { display: contents; }
.compose-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  background: var(--red-dim);
  border: 1px solid rgba(225, 28, 46, 0.45);
  color: var(--white);
  font-family: var(--mono);
  font-size: 0.72rem;
  border-radius: 999px;
  padding: 0.2rem 0.35rem 0.2rem 0.6rem;
}
.compose-chip-all { background: rgba(217, 180, 90, 0.16); border-color: rgba(217, 180, 90, 0.5); color: var(--gold); }
.compose-chip-group { background: var(--teal-dim); border-color: rgba(35, 211, 205, 0.45); color: var(--teal); }
.compose-chip-x {
  background: none;
  border: none;
  color: inherit;
  opacity: 0.65;
  font-size: 0.8rem;
  line-height: 1;
  padding: 0 0.15rem;
  cursor: pointer;
}
.compose-chip-x:active { opacity: 1; }
#compose-to-input {
  flex: 1;
  min-width: 7rem;
  background: none;
  border: none;
  outline: none;
  color: var(--white);
  font-family: var(--body);
  /* 16px minimum — iOS Safari auto-zooms the viewport on focus below this. */
  font-size: 1rem;
  padding: 0.25rem 0;
}

/* ---------- Propose Trade wizard chrome (2026-07-23) ---------- */
/* A segmented progress bar (Stories-style: one pill per step) replacing the
   bare "(Step X of 4)" text the modal's h2 used to carry inline — js/trades.js's
   renderProposeStep toggles .is-done/.is-current per segment. */
.trade-wizard-progress {
  display: flex;
  gap: 0.3rem;
  margin: 0.7rem 0 0.4rem;
}
.trade-wizard-seg {
  flex: 1;
  height: 4px;
  border-radius: 999px;
  background: var(--card-2);
  transition: background 0.2s var(--ease);
}
.trade-wizard-seg.is-done,
.trade-wizard-seg.is-current { background: var(--red); }
.trade-wizard-step-name {
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0 0 var(--space-5);
}
/* Review step (step 4) — each side of the trade gets its own boxed chip row
   (reusing .compose-to-row/.compose-chip, the same look steps 2/3 build the
   offer with) instead of one dense comma-joined sentence. */
.trade-review-row { margin-bottom: var(--space-4); }
.trade-review-row:last-child { margin-bottom: 0; }
.trade-review-row .label { margin-bottom: 0.3rem; }
.trade-review-chips { margin-bottom: 0; }

/* Fulfill Trade picker (#modal-fulfill-trade) — one section per ambiguous
   want entry, same roster-list-of-instance-rows look Propose Trade's own
   "You Give" step already uses. */
.fulfill-trade-section { margin-bottom: var(--space-4); }
.fulfill-trade-section:last-child { margin-bottom: 0; }
.fulfill-trade-section .label { margin-bottom: 0.2rem; }
.fulfill-trade-section .field-hint { display: block; margin-bottom: 0.4rem; }

.compose-suggestions {
  border: 1px solid var(--card-2);
  border-radius: var(--radius-md);
  overflow: hidden;
  margin-bottom: 0.5rem;
  box-shadow: var(--shadow-card);
}
.compose-suggestion {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  width: 100%;
  text-align: left;
  background: var(--card);
  border: none;
  border-bottom: 1px solid var(--card-2);
  color: var(--white);
  font-family: var(--mono);
  font-size: 0.78rem;
  padding: 0.6rem 0.8rem;
  cursor: pointer;
}
.compose-suggestion:last-child { border-bottom: none; }
.compose-suggestion:active { background: var(--card-2); }
.compose-suggestion .compose-suggestion-sub {
  margin-left: auto;
  color: var(--dim, var(--muted));
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

/* ===== FEEL PACK: optimistic sends + unread fence ===== */
/* Sending state (iMessage): your bubble exists the moment you tap Send. A
   flat dimmed opacity read as stalled/broken rather than "in flight" — a
   slow breathing pulse says "still working on it" instead. */
@keyframes chat-bubble-pending-pulse {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 0.85; }
}
.chat-bubble-pending {
  animation: chat-bubble-in 0.25s var(--ease), chat-bubble-pending-pulse 1.3s ease-in-out infinite;
}
.chat-bubble-pending .chat-bubble-time { font-style: italic; }

/* Discord's "NEW MESSAGES" fence — a red line with a small tag, dropped
   exactly where you left off when the thread opens with unread messages. */
.chat-new-fence {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0.7rem 0;
}
.chat-new-fence::before,
.chat-new-fence::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--red);
  opacity: 0.55;
}
.chat-new-fence span {
  font-family: var(--mono);
  font-size: 0.56rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--red);
}

/* ===== OPS HOME: HQ Mode's triage landing view (docs/HQ-REDESIGN.md §1) ===== */
/* A triage card resolves into an existing screen (roster modal, thread
   view) on tap — reusing what already exists rather than a new generic
   bottom-sheet component, so it's a real button, not a static row. */
.ops-card {
  display: block;
  width: 100%;
  text-align: left;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--white);
  font-family: var(--body);
  font-size: var(--text-sm);
  padding: 0.7rem 0.85rem;
  min-height: 44px;
  box-sizing: border-box;
  margin-bottom: 0.5rem;
  cursor: pointer;
}
/* reportSummaryHtml (js/admin.js) drops a .field-hint straight into a
   report triage card (Needs You's Reports filter) for the reporter's own
   note. .field-hint's -0.5rem top margin is tuned for its usual home right
   under a form field, not for a plain paragraph stacked under other text —
   inside .ops-card that negative margin pulled the note up into the
   headline line above it, overlapping the two. Same zeroing-out
   .system-report-entry already does for the identical markup in the Log
   panel's full report list, below. */
.ops-card .field-hint {
  margin: 0.3rem 0 0;
}
.ops-card:active { transform: scale(0.98); }
/* Under-subscribed-mission cards get a small adjacent dismiss control (see
   js/admin.js's dismissOpsMission) — a flex row wrapper instead of a
   nested button so the tap targets stay distinct: the card itself still
   opens the roster, the × next to it snoozes the card for a while. */
.ops-card-row { display: flex; align-items: stretch; gap: 0.4rem; margin-bottom: 0.5rem; }
.ops-card-row .ops-card { margin-bottom: 0; flex: 1; }
/* Non-interactive variant for a card whose only actions live in an adjacent
   button (the reports triage row) — same "no button press styling" reason
   .ops-card-urgent already carves out below, just without forcing its red
   warning tone (a filed report isn't inherently alarming the way the
   offline-queue banner is). */
.ops-card-static { cursor: default; }
.ops-card-static:active { transform: none; }
.ops-card-dismiss {
  flex: 0 0 auto;
  /* 44px tap-target floor (docs/HQ-REDESIGN.md §1) — was 2.2rem (35.2px),
     too narrow for a control this easy to miss next to a much larger card. */
  min-width: 2.75rem;
  min-height: 2.75rem;
  background: var(--card);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--muted);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
}
.ops-card-dismiss:active { transform: scale(0.94); }
/* Non-interactive — the offline-queue banner isn't a link to anywhere,
   just a status line, so it doesn't get button focus/press styling. */
.ops-card-urgent {
  cursor: default;
  border-color: var(--red);
  background: var(--red-dim);
  color: var(--white);
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.02em;
}
/* Recruit Agents banner (js/referrals.js) — a gold-accented callout, distinct
   from .ops-card's HQ-only tone since every player sees this one. Same
   "card + adjacent dismiss button" shape as .ops-card-row/.ops-card-dismiss,
   just a single fixed banner instead of a repeating list. */
.recruit-banner {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  background: var(--card);
  border: 1px solid var(--gold);
  border-radius: var(--radius-sm);
  padding: 0.7rem 0.85rem;
  margin-bottom: 0.8rem;
}
.recruit-banner-text {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
}
.recruit-banner-text strong {
  font-family: var(--mono);
  font-size: var(--text-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--gold);
}
.recruit-banner-text span {
  font-size: var(--text-sm);
  color: var(--white);
  line-height: 1.35;
}
.recruit-banner-actions {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex: 0 0 auto;
}
.recruit-banner-dismiss {
  width: 2.2rem;
  height: 2.2rem;
  background: transparent;
  border: none;
  color: var(--muted);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
}
.recruit-banner-dismiss:active { transform: scale(0.94); }

/* Referral Code modal (js/referrals.js) — a big, letter-spaced, copyable
   code display, same monospace/uppercase treatment as .code-input but a
   read-only div rather than an input (nothing to type here). */
.referral-code-display {
  background: var(--black);
  border: 1px solid var(--card-2);
  border-radius: var(--radius-sm);
  color: var(--gold);
  font-family: var(--mono);
  font-size: 1.4rem;
  font-weight: 700;
  letter-spacing: 0.2em;
  text-align: center;
  padding: 0.9rem;
  margin-bottom: 0.6rem;
  -webkit-user-select: all;
  user-select: all;
}
.ops-audit-row {
  display: flex;
  justify-content: space-between;
  gap: 0.6rem;
  font-family: var(--body);
  font-size: var(--text-sm);
  color: var(--muted);
  margin: 0.3rem 0;
}
.ops-audit-time {
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--muted);
  white-space: nowrap;
}
