/* Theme palettes -- three selectable via [data-theme] on <html> (see appearance.js, and the
   Appearance tab in the Settings modal). This unscoped :root block IS the "planeswalker" theme,
   kept as the default/fallback so the app renders correctly even before appearance.js runs (or
   if it fails to load) rather than flashing unstyled. Every value below was chosen and
   contrast-checked as a full palette (see the dev-only contrast script referenced in the
   redesign notes) -- don't tweak one color here without re-checking text/accent pairs across all
   three themes. */
:root {
  --bg: #121018;
  --bg-panel: #1a1626;
  --bg-input: #0e0c14;
  --border: #332c48;
  --text: #eeecf7;
  --text-dim: #a49dc2;
  --accent: #8b6ef2;
  --accent-hover: #a68bf7;
  --accent-dim: #4a3f7a;
  --on-accent: #0e0c14; /* text color for content painted on top of --accent (e.g. .btn-primary) --
    themed separately from --text since a light accent (foil, eldrazi) needs dark text instead */
  --ok: #4caf7d;
  --warn: #e0a63c;
  --error: #e56b6b;
  --danger: #d9534f; /* not themed -- a separate, rarely-used destructive-action color, unrelated
    to any theme's accent/error hues */
  --neon-green: #39ff6a; /* Game Changers glow + the Fluffy side of the Spicy/Fluffy score --
    intentionally NOT themed (no [data-theme] block below redefines it) so this specific green/red
    pair always reads the same regardless of theme, unlike --neon (which IS themed per-palette). */
  --neon-red: #ff3b3b; /* the Spicy side of the Spicy/Fluffy score -- see --neon-green above */
  --neon-blue: #3fd8ff; /* Play nav tab + home button glow -- intentionally NOT themed, same
    reasoning as --neon-orange below: the Play landmark should read identically in every palette. */
  --neon-orange: #ff8c1a; /* Synergy Matrix nav tab + home button glow -- intentionally NOT themed,
    same reasoning as --neon-green/--neon-red above */
  --firefly-glow: #d4ff4a; /* Inbox tab's new-message firefly (see notifications.js's
    spawnInboxFirefly + .inbox-firefly below) -- a bioluminescent yellow-green, intentionally NOT
    themed for the same reason as --neon-green/--neon-red above */

  /* Presence dots (see presence.js) -- green/yellow/red/gray, intentionally NOT themed, same
     reasoning as --neon-green/--neon-red above: "online" has to read as green regardless of which
     appearance theme is active, the same way a mana symbol's color doesn't shift per theme. */
  --status-online: #3ba55c;
  --status-away: #f0b232;
  --status-dnd: #ed4245;
  --status-offline: #80848e;

  /* Build tab's WUBRG color-filter toggle buttons (see .mana-toggle below) -- one fixed glow color
     per mana color, intentionally NOT themed (same reasoning as --neon-green/--neon-red above):
     a card's color identity is a fixed game concept, not a UI accent, so these read the same
     regardless of which appearance theme is active. --mana-c (colorless) stands in for the {C}
     symbol -- a cool silver rather than a hue, since colorless has no color of its own. */
  --mana-w: #ffe066;
  --mana-u: #2fb8ff;
  --mana-b: #b565ff;
  --mana-r: #ff3b3b;
  --mana-g: #39ff6a;
  --mana-c: #c7d0dc;
  --radius: 8px;

  /* -rgb triplets for every color above that gets used as an rgba() alpha variant somewhere in
     this sheet (badge backgrounds, hover states, the focus ring below) -- keeps those derivations
     in one place instead of hand-picking a new rgba() literal per rule. */
  --accent-rgb: 139, 110, 242;
  --error-rgb: 229, 107, 107;
  --ok-rgb: 76, 175, 125;
  --warn-rgb: 224, 166, 60;
  --danger-rgb: 217, 83, 79;

  /* Spacing scale -- replaces the ad hoc 4/6/8/9/10/12/14/16/18/20/24/28px literals previously
     scattered through this file with no consistent rhythm. */
  --space-2xs: 4px;
  --space-xs: 6px;
  --space-sm: 8px;
  --space-md: 12px;
  --space-lg: 16px;
  --space-xl: 20px;
  --space-2xl: 24px;
  --space-3xl: 32px;

  /* Type scale -- replaces literal font-size values (10/10.5/11/12/12.5/13/13.5/14/15/16/17px)
     that had no naming/system behind them. In rem (relative to html's root font-size, see the
     Appearance tab's font-size control) so the whole scale grows/shrinks together. */
  --font-2xs: 0.7857rem;
  --font-xs: 0.8571rem;
  --font-sm: 0.9286rem;
  --font-md: 1rem;
  --font-lg: 1.0714rem;
  --font-xl: 1.1429rem;

  /* Border-radius scale -- var(--radius) (8px) already covered panels/modals/tabs; this fills in
     the other radii that were hardcoded per-rule instead of tokenized. */
  --radius-xs: 4px;
  --radius-sm: 6px;
  --radius-lg: 10px;
  --radius-pill: 999px;

  /* Visible keyboard-focus ring (WCAG 2.4.7) -- a border-color change alone isn't a strong enough
     indicator on its own, so form fields pair it with this box-shadow; buttons/tabs/other controls
     use the plain outline further down. */
  --focus-ring: 0 0 0 3px rgba(var(--accent-rgb), 0.45);

  --shadow-modal: 0 12px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3);

  /* "Balanced" timing/easing -- smooth, no bounce/overshoot, a touch slower than pure
     micro-interaction speed but well short of a deliberately slow "cinematic" feel. */
  --ease: cubic-bezier(.3, .6, .3, 1);
  --transition-fast: 190ms var(--ease);
  --transition-base: 300ms var(--ease);

  /* Loading mascot scene (see loadingUI.js) -- not themed, same reasoning as --danger: these are
     fixed material colors (hair, oak wood, leather, candlelight, paper, glasses lenses), not UI
     accents, so they stay constant across all three themes rather than living in the
     [data-theme="..."] blocks below. */
  --hair: #cfc9dd;
  --wood: #8a6a45;
  --wood-dark: #5f4429;
  --wood-light: #a98454;
  --paper: #efe8d8;
  --lens: #6b6b78;
  --leather: #6b2f2c;
  --leather-dark: #481f1d;
  --flame: #ffb347;

  /* "The Cards" section heading's neon glow (see .the-cards-section below) -- picked per-theme as
     a hue roughly opposite this theme's --accent, so it reads as a deliberately different, eye-
     catching color against that theme's background rather than just a brighter version of the
     accent already used everywhere else. Violet bg/accent here -> neon lime. */
  --neon: #4dff8a;
}

/* Softened from this theme's original values -- the near-black bg paired with a fairly saturated,
   bright gold accent read as harsh/eye-straining over extended use. Lightened the bg a touch
   (charcoal-brown instead of near-black) and pulled the accent down to a more muted antique-brass,
   which keeps the "foil border" identity without the glare. */
:root[data-theme="foil"] {
  --bg: #1c1811;
  --bg-panel: #241f16;
  --bg-input: #171307;
  --border: #40361f;
  --text: #ece3d0;
  --text-dim: #ac9c81;
  --accent: #b9915a;
  --accent-hover: #cba572;
  --accent-dim: #5f4d2e;
  --on-accent: #1c1811;
  --ok: #93a86a;
  --warn: #cf9752;
  --error: #c98170;
  --accent-rgb: 185, 145, 90;
  --error-rgb: 201, 129, 112;
  --ok-rgb: 147, 168, 106;
  --warn-rgb: 207, 151, 82;
  /* Warm bronze/gold bg+accent here -> cool neon cyan for max contrast. */
  --neon: #2ff0e0;
}

/* Softened the same way as Foil Bronze above -- the original accent (#3ecfd6) was a very bright,
   highly saturated cyan against a near-black bg, which is the single harshest color pairing across
   all three themes since --accent is used everywhere (borders, links, buttons, focus rings).
   Lightened the bg slightly and desaturated/dimmed the cyan toward a calmer teal. */
:root[data-theme="eldrazi"] {
  --bg: #101718;
  --bg-panel: #172223;
  --bg-input: #0c1213;
  --border: #2c3f41;
  --text: #dceaea;
  --text-dim: #93aeae;
  --accent: #4fb0b5;
  --accent-hover: #6cc3c7;
  --accent-dim: #285356;
  --on-accent: #101718;
  --ok: #5cad8a;
  --warn: #cf9c4c;
  --error: #cf6d69;
  --accent-rgb: 79, 176, 181;
  --error-rgb: 207, 109, 105;
  --ok-rgb: 92, 173, 138;
  --warn-rgb: 207, 156, 76;
  /* Cool cyan bg+accent here -> warm neon magenta for max contrast. */
  --neon: #ff4fe0;
}

/* Light-mode variant of each theme above -- toggled independently of theme choice (see the
   Appearance tab's light/dark switch and appearance.js's applyMode) via [data-mode="light"]
   alongside [data-theme]. Each keeps that theme's accent hue but re-picked at a darker value than
   its dark-mode counterpart, since a hue calibrated to pop against a near-black background is
   usually too washed-out for 4.5:1 text contrast against a light one -- every pairing below
   (text/bg, accent/bg, accent/white) was checked against WCAG AA before picking these. --neon is
   re-picked per mode too, same reasoning as --accent above -- the dark-mode neon values are light,
   highly-saturated colors meant to pop off a near-black background, and would wash out to near-
   invisible against --bg-panel: #ffffff (the "The Cards" glow text, chart.js's sparkle) without a
   darker light-mode value here. --hair gets a light-mode override for the same reason (the loading
   mascot's hair strokes render straight over --bg-panel with no other backing shape, so a light
   lavender that's fine against a near-black panel disappears against a white one); --wood/--paper/
   etc. are left as-is in every mode since those sit inside shapes with their own opaque fills/
   strokes (desk, cabinets, book) that don't depend on the surrounding panel color for contrast. */
:root[data-mode="light"] {
  --bg: #f5f3fb;
  --bg-panel: #ffffff;
  --bg-input: #ffffff;
  --border: #d9d3ee;
  --text: #221c35;
  --text-dim: #6b6285;
  --accent: #6b4fd6;
  --accent-hover: #7f66e0;
  --accent-dim: #cfc3f2;
  --on-accent: #ffffff;
  --ok: #2f8f5c;
  --warn: #9c6708;
  --error: #c94f4f;
  --accent-rgb: 107, 79, 214;
  --error-rgb: 201, 79, 79;
  --ok-rgb: 47, 143, 92;
  --warn-rgb: 156, 103, 8;
  /* Violet bg/accent here -> a darker forest green (dark mode's neon lime, re-darkened for 4.5:1+
     against a white panel instead of a near-black one). */
  --neon: #15773f;
  --hair: #8039a3;
}

:root[data-theme="foil"][data-mode="light"] {
  --bg: #faf6ec;
  --bg-panel: #ffffff;
  --bg-input: #ffffff;
  --border: #e6dcc0;
  --text: #2b2313;
  --text-dim: #7a6b48;
  --accent: #86642a;
  --accent-hover: #977231;
  --accent-dim: #e8d9b5;
  --on-accent: #ffffff;
  --ok: #5f7a3a;
  --warn: #916017;
  --error: #a8503f;
  --accent-rgb: 134, 100, 42;
  --error-rgb: 168, 80, 63;
  --ok-rgb: 95, 122, 58;
  --warn-rgb: 145, 96, 23;
  /* Warm bronze/gold bg+accent here -> a darker teal (dark mode's neon cyan, re-darkened). */
  --neon: #0d7a75;
  --hair: #8a7550;
}

:root[data-theme="eldrazi"][data-mode="light"] {
  --bg: #eef6f6;
  --bg-panel: #ffffff;
  --bg-input: #ffffff;
  --border: #cfe3e3;
  --text: #12262a;
  --text-dim: #4d6c6f;
  --accent: #1b7075;
  --accent-hover: #228086;
  --accent-dim: #bfe1e2;
  --on-accent: #ffffff;
  --ok: #337a5c;
  --warn: #8f6519;
  --error: #ad5450;
  --accent-rgb: 27, 112, 117;
  --error-rgb: 173, 84, 80;
  --ok-rgb: 51, 122, 92;
  --warn-rgb: 143, 101, 25;
  /* Cool cyan bg+accent here -> a darker magenta (dark mode's neon magenta, re-darkened). */
  --neon: #9c1f88;
  --hair: #4d7a7d;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

/* Visually-hidden but still screen-reader-accessible -- for accessible names that shouldn't also
   render as visible label text (e.g. a filter input whose placeholder already shows the sighted
   affordance). */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

* { box-sizing: border-box; }

/* The actual root size every `rem` value in this file is relative to -- can't be expressed in
   rem itself (on the root element, 1rem resolves to the browser's own default of 16px, not this
   app's 14px baseline, since there's no ancestor for it to inherit from). This is only the
   fallback for before appearance.js runs (or if it fails to load) -- the Appearance tab's font-size
   slider/number input (see appearance.js's applyFontSize) sets an inline style on <html> instead of
   an attribute here, since it's a free px value rather than a handful of presets a CSS selector
   could match on. */
html { font-size: 14px; }

/* No rule for the default font -- the var(--font-family, ...) fallback on html/body above already
   supplies the system-UI stack when this attribute is absent (see appearance.js). CSP blocks font
   CDNs (script-src/style-src 'self'), so every option here is a system font stack, not a
   downloaded typeface. */
html[data-font="serif"] { --font-family: Georgia, "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, serif; }
html[data-font="rounded"] { --font-family: "Trebuchet MS", "Century Gothic", "Segoe UI", sans-serif; }
html[data-font="mono"] { --font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; }

html, body {
  height: 100%;
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-family, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif);
}

body {
  display: flex;
  flex-direction: column;
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 16px;
  border-bottom: 1px solid var(--border);
  background: var(--bg-panel);
  flex-shrink: 0;
  -webkit-app-region: drag;
}

.app-footer {
  text-align: center;
  padding: 16px 12px;
  font-size: 0.75rem;
  color: var(--text-dim);
}

.topbar-title {
  font-weight: 600;
  font-size: 1.0714rem;
  display: flex;
  align-items: center;
  gap: 8px;
}

.title-icon { font-size: 1.1429rem; }

.topbar button { -webkit-app-region: no-drag; }

.tabs {
  display: flex;
  gap: 4px;
  padding: 8px 16px 0 16px;
  border-bottom: 1px solid var(--border);
  background: var(--bg-panel);
  flex-shrink: 0;
}

.tab {
  position: relative;
  background: transparent;
  border: none;
  color: var(--text-dim);
  padding: 8px 16px;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  cursor: pointer;
  font-size: 0.9286rem;
  font-weight: 500;
  transition: color var(--transition-fast);
}

.tab:hover { color: var(--text); }

.tab.active { color: var(--text); }

.tab.active::after {
  content: '';
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: -1px;
  height: 2px;
  background: var(--accent);
  border-radius: 2px 2px 0 0;
}

/* "Build / Edit Deck" tab while a real build/edit is in flight, and "Card Ideas" while
   suggestions are being generated -- see app.js's setNavTabBusy(). Cycles the outline through the
   five mana colors (--mana-w/u/b/r/g, already fixed/un-themed -- see those vars' own comments)
   rather than a single glow color, so it reads as something being conjured rather than a generic
   busy spinner. Not themed for the same reason the mana-toggle glows and Synergy Matrix tab
   aren't -- these colors are a fixed game concept, not a UI accent. */
.tab.is-building {
  color: var(--text);
  animation: build-tab-text-glow 1.8s ease-in-out infinite;
}

.tab.is-building::before {
  content: '';
  position: absolute;
  inset: 2px;
  border: 2px solid var(--mana-u);
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  pointer-events: none;
  animation: build-tab-outline-pulse 2.5s linear infinite;
}

@keyframes build-tab-outline-pulse {
  0%, 100% {
    border-color: var(--mana-w);
    box-shadow: 0 0 5px var(--mana-w), 0 0 12px color-mix(in srgb, var(--mana-w) 65%, transparent);
  }
  20% {
    border-color: var(--mana-u);
    box-shadow: 0 0 5px var(--mana-u), 0 0 12px color-mix(in srgb, var(--mana-u) 65%, transparent);
  }
  40% {
    border-color: var(--mana-b);
    box-shadow: 0 0 5px var(--mana-b), 0 0 12px color-mix(in srgb, var(--mana-b) 65%, transparent);
  }
  60% {
    border-color: var(--mana-r);
    box-shadow: 0 0 5px var(--mana-r), 0 0 12px color-mix(in srgb, var(--mana-r) 65%, transparent);
  }
  80% {
    border-color: var(--mana-g);
    box-shadow: 0 0 5px var(--mana-g), 0 0 12px color-mix(in srgb, var(--mana-g) 65%, transparent);
  }
}

@keyframes build-tab-text-glow {
  0%, 100% { text-shadow: 0 0 3px color-mix(in srgb, var(--accent) 60%, transparent); }
  50% { text-shadow: 0 0 8px var(--accent), 0 0 16px color-mix(in srgb, var(--accent) 60%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .tab.is-building,
  .tab.is-building::before {
    animation: none;
  }
  .tab.is-building::before {
    border-color: var(--accent);
    box-shadow: 0 0 6px var(--accent);
  }
}

/* #app-shell (index.html) wraps .tabs + .content and sits directly in body's flex column, but had
   no display/flex rule of its own -- it defaulted to a plain block box that shrink-wraps its
   content's natural height. That silently broke .content's "flex: 1" below (a flex property only
   does anything when the element's own parent is a flex container) so .content was never actually
   viewport-height-constrained; it just grew as tall as whatever tab was open, and any short tab
   left dead space below it inside the fixed-height body instead of that tab's own content
   stretching down to fill the window. Making #app-shell itself a flex column fixes that for every
   tab at once, not just My Collection (see #tab-collection.active below for the collection-list
   fix this enables). */
#app-shell {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.content {
  flex: 1;
  overflow: auto;
  padding: 20px;
}

.tab-panel { display: none; }
.tab-panel.active { display: block; }

.two-col {
  display: grid;
  grid-template-columns: minmax(320px, 420px) 1fr;
  gap: 20px;
  align-items: start;
  height: 100%;
}

.panel {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 18px;
}

.form { display: flex; flex-direction: column; gap: 14px; }

/* Submit + "Clear all fields" side by side at the bottom of the Build/Edit form -- see app.js's
   clearBuildFields(). */
.form-actions { display: flex; gap: 10px; align-items: center; }
.form-actions .btn-primary { flex: 1; }

/* Houses the "Your build inputs" recap + Decklist sections once a build/edit result exists (see
   app.js's renderBuildResult) -- lives in the form's own flex column so it inherits the same 14px
   gap as the fields above it. :empty keeps it from reserving that gap before any result exists. */
.build-side-extra { display: flex; flex-direction: column; gap: 14px; }
.build-side-extra:empty { display: none; }

/* Wraps Mana Curve + Color Breakdown once they're moved into the sidebar (see chart.js's
   buildAnalysisBlock/app.js's renderBuildResult) -- same gap treatment as .build-side-extra's own
   direct children, since this is just a plain div, not itself part of that flex column's gap. */
.build-side-charts { display: flex; flex-direction: column; gap: 14px; }

.field { display: flex; flex-direction: column; gap: 6px; }

.field-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.field-label {
  font-size: 0.8929rem;
  font-weight: 600;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.field-hint {
  font-weight: 400;
  text-transform: none;
  letter-spacing: normal;
  color: var(--text-dim);
}

input[type="text"], input[type="password"], input[type="number"], select, textarea {
  background: var(--bg-input);
  border: 1px solid var(--border);
  color: var(--text);
  border-radius: var(--radius-sm);
  padding: 8px 10px;
  font-size: 0.9643rem;
  font-family: inherit;
  width: 100%;
}

textarea {
  resize: vertical;
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  font-size: 0.8929rem;
  line-height: 1.5;
}

input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: var(--focus-ring);
}

.btn {
  border: none;
  border-radius: var(--radius-sm);
  padding: 9px 16px;
  font-size: 0.9643rem;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition-fast);
}

/* Keyboard-only focus ring (WCAG 2.4.7) -- :focus-visible so mouse clicks don't also show it.
   Covers every interactive element that isn't a plain text field (those get the box-shadow ring
   above instead, since a solid outline would clash with their border). */
.btn:focus-visible,
.tab:focus-visible,
.settings-tab:focus-visible,
.view-toggle-btn:focus-visible,
.card-qty-btn:focus-visible,
.collapsible-header:focus-visible,
a:focus-visible,
button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.btn-primary { background: var(--accent); color: var(--on-accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { background: var(--accent-dim); cursor: not-allowed; }

.btn-secondary { background: var(--border); color: var(--text); }
.btn-secondary:hover { background: #3a3f4c; }

.btn-ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--border); }
.btn-ghost:hover { color: var(--text); border-color: var(--text-dim); }

.file-row { display: flex; align-items: center; gap: 8px; }
.file-row input { flex: 1; }

.file-name {
  font-size: 0.8571rem;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  flex: 1;
}

/* Wraps a sub-group of .field elements inside the build form (the "new build" fields, and the
   "existing decklist" field) so they can be shown/hidden together based on the build-mode select,
   while keeping the same vertical rhythm as .form's own top-level gap -- see app.js's
   updateBuildModeFields(). */
.field-group { display: flex; flex-direction: column; gap: 14px; }

/* WUBRG(+C) color-filter toggles under the Build tab's Theme/colors/archetype field -- see app.js's
   build-color-toggle wiring. Off by default (plain outline pip); toggling one on lights it up with
   that color's neon glow (--mana-w/u/b/r/g/c, defined per-color and un-themed, see :root above) so
   active colors are readable at a glance against any of the three appearance themes. Picking a
   commander from the autocomplete auto-syncs these to its color identity, but they stay editable
   by hand afterward -- e.g. to add an off-color splash the commander itself doesn't grant. */
.mana-toggle-row { display: flex; gap: 8px; flex-wrap: wrap; }

.mana-toggle {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 2px solid var(--border);
  background: var(--bg-input);
  color: var(--text-dim);
  font-weight: 700;
  font-size: 0.9286rem;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background var(--transition-fast), border-color var(--transition-fast),
    color var(--transition-fast), box-shadow var(--transition-fast);
}

.mana-toggle:hover { border-color: var(--text-dim); color: var(--text); }

.mana-toggle--w.active { --mana-color: var(--mana-w); }
.mana-toggle--u.active { --mana-color: var(--mana-u); }
.mana-toggle--b.active { --mana-color: var(--mana-b); }
.mana-toggle--r.active { --mana-color: var(--mana-r); }
.mana-toggle--g.active { --mana-color: var(--mana-g); }
.mana-toggle--c.active { --mana-color: var(--mana-c); }

.mana-toggle.active {
  background: color-mix(in srgb, var(--mana-color) 22%, var(--bg-input));
  border-color: var(--mana-color);
  color: var(--mana-color);
  text-shadow: 0 0 6px var(--mana-color), 0 0 12px color-mix(in srgb, var(--mana-color) 70%, transparent);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--mana-color) 55%, transparent),
    0 0 10px color-mix(in srgb, var(--mana-color) 65%, transparent),
    0 0 20px color-mix(in srgb, var(--mana-color) 40%, transparent);
}

/* Format toggle -- the Build/Edit Deck tab's Commander/Standard switch (#build-format-toggle) and
   My Decks' All/Commander/Standard filter (#decks-format-filter). Both reuse .mana-toggle-row for
   layout (flex + gap) but need a text-label pill rather than a round WUBRG pip, so this gets its
   own button style -- same look as .view-toggle-btn (List/Grid/Card) for visual consistency with
   that other toolbar toggle. */
.format-toggle {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  color: var(--text-dim);
  border-radius: var(--radius-sm);
  padding: 6px 14px;
  font-size: 0.8571rem;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}

.format-toggle:hover { color: var(--text); border-color: var(--text-dim); }

.format-toggle.active {
  background: var(--accent);
  color: var(--on-accent);
  border-color: var(--accent);
}

/* Small format badge shown per-deck in My Decks (see decks.js's buildDeckMetaLine) -- distinct
   from the toggle buttons above, this is read-only labeling inline in a deck's meta row. */
.format-badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: var(--radius-sm);
  font-size: 0.7857rem;
  font-weight: 600;
  border: 1px solid var(--border);
  color: var(--text-dim);
}

.format-badge--standard {
  border-color: var(--accent);
  color: var(--accent);
}

.hidden { display: none !important; }

.results {
  min-height: 300px;
  max-height: calc(100vh - 140px);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* Caps the left form column to the same viewport-relative height as .results (right column,
   above) and gives it its own scrollbar. Without this, .form has no height limit -- once its
   content (build inputs + decklist + mana curve, etc.) grows taller than the viewport, .content's
   own overflow:auto kicks in and scrolls the WHOLE row, including the already-independently-
   scrolling .results panel next to it. That's a "scroll twice" experience: scrolling the outer
   page to see the rest of the form also drags the card grid out of view, requiring a second,
   separate scroll inside .results to get back to it. Matching both columns' heights to the
   viewport means .content itself never needs to scroll -- each column scrolls on its own instead.
   Scoped to .two-col's direct child so unrelated standalone .form panels (Friends tab, login,
   etc.) aren't affected. Reset to no cap on mobile (see the max-width: 760px query below), same as
   .results there -- a single stacked column scrolls with the page instead. */
.two-col > .form {
  max-height: calc(100vh - 140px);
  overflow-y: auto;
}

/* Makes the "each column scrolls on its own, .content never does" goal above actually hold, instead
   of very nearly holding. `100vh - 140px` is a guess at the app chrome around .content, and it's 9px
   short: the real total is .topbar (63) + .app-footer (46) + .content's own 20px padding top and
   bottom = 149. So both columns were laid out 9px taller than the space they had, .content's
   overflow:auto kicked in over those 9px, and a second, nearly-unscrollable scrollbar appeared right
   next to the column's own -- the "multiple scrollbars" symptom.
   .content is a stretched flex item of #app-shell, so its height is definite and a percentage height
   on the panel resolves against it exactly, with no constant to keep in sync with the chrome. That
   makes .two-col's existing `height: 100%` resolve too, and the columns cap to their real available
   height. Scoped with :has() to panels whose .two-col is a direct child (Build/Edit, Card Ideas) --
   the .two-col inside My Collection's detail view and the Draft view sit further down and keep the
   viewport-relative cap above, which is fine there because those panels scroll as a whole anyway. */
.content > .tab-panel.active:has(> .two-col) { height: 100%; }

/* One row, sized to the grid's own height rather than to its tallest item's content. Without this
   the row still grows past .two-col to fit .results' 300px min-height (min beats max in CSS), which
   puts the outer scrollbar right back on short windows -- and because `max-height: 100%` below
   resolves against the grid AREA, both columns would follow the row past the bottom of the screen. */
.content > .tab-panel.active:has(> .two-col) > .two-col { grid-template-rows: minmax(0, 1fr); }

.content > .tab-panel.active:has(> .two-col) > .two-col > .form,
.content > .tab-panel.active:has(> .two-col) > .two-col > .results {
  max-height: 100%;
}

/* .results' 300px floor exists to stop an empty/short result panel collapsing to nothing next to a
   full-height form; stretching it to the row does that job properly instead, and does it at every
   window height. The floor is what's left of the old fixed sizing -- keeping it here would only
   re-introduce the overflow it caused above, so it goes with the same rule that replaces it.
   .two-col's `align-items: start` still governs .form, which is meant to shrink-wrap its fields. */
.content > .tab-panel.active:has(> .two-col) > .two-col > .results {
  align-self: stretch;
  min-height: 0;
}

/* Reserves the scrollbar's width whether or not it's currently showing. Both columns' content grows
   and shrinks as a deck is built (and the Build form's fields change wholesale with the "How do you
   want to start?" select -- Analyze is short enough to need no scrollbar at all), so without this,
   each appearance/disappearance changes the column's content width by the scrollbar's ~17px and
   every label, help text and card row inside it re-wraps. Text visibly reflowing as a side effect of
   picking from a dropdown is what this prevents. */
.results,
.two-col > .form {
  scrollbar-gutter: stable;
}

.results-empty {
  color: var(--text-dim);
  font-size: 0.9286rem;
  padding: 20px 0;
  text-align: center;
}

.results-empty.results-error {
  color: var(--error);
}

.results-loading {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 40px 0;
  color: var(--text-dim);
  font-size: 0.9286rem;
}

.spinner {
  width: 28px;
  height: 28px;
  border: 3px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

/* Loading progress bar (see loadingUI.js) -- indeterminate (sliding highlight) by default since
   most callers have no real progress signal; .determinate + an explicit width (set via JS when
   the desktop app's real deck:progress events arrive) switches it to a plain fill transition. */
.loading-progress-bar {
  width: 100%;
  max-width: 320px;
  height: 4px;
  background: var(--border);
  border-radius: 2px;
  overflow: hidden;
  position: relative;
}

.loading-progress-fill {
  position: absolute;
  top: 0;
  left: -40%;
  height: 100%;
  width: 40%;
  background: var(--accent);
  border-radius: 2px;
  animation: loading-indeterminate 1.4s ease-in-out infinite;
}

.loading-progress-fill.determinate {
  animation: none;
  left: 0;
  width: 0;
  transition: width 0.4s ease;
  box-shadow:
    0 0 8px color-mix(in srgb, var(--accent) 60%, transparent),
    0 0 16px color-mix(in srgb, var(--accent) 35%, transparent);
}

@keyframes loading-indeterminate {
  0% { left: -40%; }
  100% { left: 100%; }
}

.loading-detail {
  font-size: 0.8571rem;
  color: var(--text-dim);
  text-align: center;
  max-width: 340px;
  /* text-align alone only centers the text *inside* this box -- without this, the box itself
     (a plain block element, max-width narrower than its parent) sits flush against the parent's
     left edge by default, same underlying issue as the SVG sign box below being centered on the
     automaton/desk area instead of the full scene. */
  margin: 0 auto;
  line-height: 1.4;
}

/* Real-updates line + glowing progress bar, anchored at the bottom of the mascot frame (see
   loadingUI.js's showRich()) -- .mascot-status-text reuses .loading-detail's own type/centering
   and just adds the wrapper's own margin instead, since it's now positioned by its parent's gap
   rather than its own auto margins. Deliberately separate from the SVG sign box above (still pure
   flavor quips) -- this is the one place a genuine reported build stage shows up. */
.mascot-status-wrap {
  width: 100%;
  max-width: 340px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.mascot-status-text { margin: 0; }

.mascot-progress-track {
  position: relative;
  height: 6px;
  border-radius: 3px;
  background: var(--border);
  overflow: hidden;
}

/* The "glowing loading bar" itself -- a slowly-scrolling rainbow gradient (not a flat fill) with a
   soft multi-layer glow, same "something magical, not just a plain bar" intent as the scroll's own
   rainbow fill above. Width is set directly by loadingUI.js's setProgress/reveal(). */
.mascot-progress-fill-bar {
  height: 100%;
  width: 0%;
  border-radius: 3px;
  background: linear-gradient(90deg, #ff3b3b, #ff9c3b, #ffe93b, #4dff7a, #3bc9ff, #7a5bff, #ff3bcf);
  background-size: 300% 100%;
  animation: mascot-progress-glow-shift 3s linear infinite;
  transition: width 0.5s ease;
  box-shadow:
    0 0 8px rgba(255, 255, 255, 0.3),
    0 0 16px color-mix(in srgb, var(--accent-hover) 55%, transparent),
    0 0 28px color-mix(in srgb, var(--accent-hover) 30%, transparent);
}
@keyframes mascot-progress-glow-shift {
  0% { background-position: 0% 50%; }
  100% { background-position: 300% 50%; }
}

/* Wraps loadingUI's spinner/bar/detail inside a My Decks tile while a fresh per-deck analysis
   runs -- see decks.js's toggleAnalysis(). */
.deck-analysis-loading { padding: 8px 0; }

/* ============================================================================================
   Loading mascot scene (see loadingUI.js's showRich()) -- the illustrated automaton used for the
   two main Build/Edit and Card Ideas results panels. Every selector here is class-based, never
   id-based: loadingUI.js can have more than one instance live at once (e.g. Build and Ideas both
   loading at the same time, on different tabs), and duplicate ids across two simultaneously-
   rendered scenes would break the CSS/attribute-selector targeting below. data-pos/data-motion/
   data-facing live on .mascot-svg itself (the <svg> root of one instance), scoped by descendant
   selectors from there, so two instances never cross-talk.
   ============================================================================================ */

.mascot-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-md);
  padding: var(--space-lg) 0;
}

.mascot-stage { width: 100%; max-width: 760px; }
.mascot-svg { width: 100%; height: auto; overflow: visible; display: block; }

/* Old-looking serif italic -- the sign reads Urza's quips as invoked lore, not a UI label, so it
   gets its own font rather than inheriting the app's chosen body font (var(--font-family)). System
   stack only (no webfont fetch), same convention as the rest of this app. */
.mascot-sign-text {
  transition: opacity 200ms ease;
  font-family: Cambria, Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", serif;
  font-style: italic;
  letter-spacing: 0.015em;
}

/* ---------- automaton positioning + facing ----------
   Structural note: SVG elements can carry a `transform` *attribute* (e.g. transform="translate(20,0)")
   AND be targeted by a CSS `transform` rule -- but the CSS value completely replaces the
   attribute rather than composing with it. Every element below that needs a static positioning
   offset AND a CSS-animated transform is therefore split into two nested <g>s: an outer one
   carrying the plain attribute (left alone, never touched by CSS), and an inner one CSS is free
   to animate. This applies equally to the speech bubble, which separately carries its own
   show/hide transform -- see .mascot-speech-bubble below. */
.automaton-pos {
  transform: translateX(0px);
  transition: transform 2.3s cubic-bezier(.4,0,.2,1);
}

.automaton-facing {
  transform-origin: 219px 180px;
  transition: transform 2.3s cubic-bezier(.4,0,.2,1);
}
.mascot-svg[data-facing="left"] .automaton-facing { transform: scaleX(-1); }

.automaton-body { transform-origin: center bottom; }

.mascot-svg[data-motion="sitting"] .automaton-body,
.mascot-svg[data-motion="cabinet"] .automaton-body,
.mascot-svg[data-motion="shelf"] .automaton-body {
  animation: mascot-bob 3.2s ease-in-out infinite;
}
@keyframes mascot-bob {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  50% { transform: translateY(-3px) rotate(0deg); }
}

.automaton-eye { animation: mascot-blink 4.5s ease-in-out infinite; transform-origin: center; }
@keyframes mascot-blink {
  0%, 88%, 100% { transform: scaleY(1); }
  92% { transform: scaleY(0.15); }
}
.automaton-eye-glow { animation: mascot-glow-pulse 2.6s ease-in-out infinite; }
@keyframes mascot-glow-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }

/* Mouse-follow: JS sets --eye-dx/--eye-dy on .eye-pupil while the pointer moves over the stage. */
.eye-pupil {
  transform: translate(var(--eye-dx, 0px), var(--eye-dy, 0px));
  transition: transform 140ms ease-out;
}

.automaton-quill { transform-origin: 250px 191px; }
.mascot-svg[data-motion="sitting"] .automaton-quill { animation: mascot-quill-write 2.4s ease-in-out infinite; }
@keyframes mascot-quill-write { 0%, 100% { transform: rotate(-4deg); } 50% { transform: rotate(6deg); } }

/* Floating, not walking -- no legs, so he glides sideways (via .automaton-pos's own translateX
   transition) with just a bob/tilt to read as drifting rather than sliding like a cutout. */
.mascot-svg[data-motion="walking"] .automaton-body { animation: mascot-shuffle-bob 0.9s ease-in-out infinite; }
@keyframes mascot-shuffle-bob {
  0%, 100% { transform: translateY(0) rotate(-2deg); }
  50% { transform: translateY(-5px) rotate(2deg); }
}
.mascot-float-shadow { animation: mascot-float-shadow-pulse 3.2s ease-in-out infinite; }
@keyframes mascot-float-shadow-pulse {
  0%, 100% { opacity: 0.25; transform: scaleX(1); }
  50% { opacity: 0.15; transform: scaleX(0.85); }
}
.mascot-svg[data-motion="walking"] .automaton-arm-l { animation: mascot-swing-l 0.46s ease-in-out infinite; transform-origin: 206px 189px; }
.mascot-svg[data-motion="walking"] .automaton-arm-r { animation: mascot-swing-r 0.46s ease-in-out infinite; transform-origin: 242px 189px; }
@keyframes mascot-swing-l { 0%, 100% { transform: rotate(-10deg); } 50% { transform: rotate(14deg); } }
@keyframes mascot-swing-r { 0%, 100% { transform: rotate(14deg); } 50% { transform: rotate(-10deg); } }

/* ---------- gesture: randomized arm/wand flourishes layered on top of the idle sitting pose ----------
   Both Lebard (home.js, always data-motion="sitting") and the loading scene's automaton (while
   seated at the desk between shuffles) share this same markup, so a `data-gesture` attribute on the
   shared .mascot-svg root is all either caller needs to set -- see home.js's applyGesture and
   loadingUI.js's own applyGesture, called on each fresh arrival at "sitting". Scoped under
   [data-motion="sitting"] specifically so a stale attribute left over from a previous gesture can
   never fight the walking/cabinet/shelf arm rules above/below once he's moved on. `null`/absent is
   a valid pick too (see the GESTURES lists in both callers) -- picking "no gesture" some of the time
   keeps him from looking like he's constantly performing. */
.mascot-svg[data-motion="sitting"][data-gesture="conjure"] .automaton-arm-l { animation: lebard-gesture-conjure-l 2.8s ease-in-out infinite; transform-origin: 206px 189px; }
.mascot-svg[data-motion="sitting"][data-gesture="conjure"] .automaton-arm-r { animation: lebard-gesture-conjure-r 2.8s ease-in-out infinite; transform-origin: 242px 189px; }
@keyframes lebard-gesture-conjure-l { 0%, 100% { transform: rotate(0deg) translateY(0); } 50% { transform: rotate(-36deg) translateY(-6px); } }
@keyframes lebard-gesture-conjure-r { 0%, 100% { transform: rotate(0deg) translateY(0); } 50% { transform: rotate(36deg) translateY(-6px); } }

.mascot-svg[data-motion="sitting"][data-gesture="stir"] .automaton-arm-r { animation: lebard-gesture-stir-r 1.9s linear infinite; transform-origin: 242px 189px; }
@keyframes lebard-gesture-stir-r {
  0% { transform: rotate(6deg) translate(0, 0); }
  25% { transform: rotate(22deg) translate(2px, -2px); }
  50% { transform: rotate(6deg) translate(3px, 2px); }
  75% { transform: rotate(-8deg) translate(-1px, 1px); }
  100% { transform: rotate(6deg) translate(0, 0); }
}

/* The one gesture that also reaches the wand itself -- his quill (always in-hand, see
   .automaton-quill) and the separate .lebard-prop-wand (only present while home.js's "wand" scene
   is showing) share this same keyframe at the same duration so hand and wand tip read as one sweep
   rather than two independently-timed motions. */
.mascot-svg[data-motion="sitting"][data-gesture="flourish"] .automaton-arm-r { animation: lebard-gesture-flourish-r 1.7s ease-in-out infinite; transform-origin: 242px 189px; }
.mascot-svg[data-motion="sitting"][data-gesture="flourish"] .automaton-arm-l { animation: lebard-gesture-flourish-l 1.7s ease-in-out infinite; transform-origin: 206px 189px; }
.mascot-svg[data-motion="sitting"][data-gesture="flourish"] .automaton-quill,
.mascot-svg[data-motion="sitting"][data-gesture="flourish"] .lebard-prop-wand { animation: lebard-gesture-flourish-prop 1.7s ease-in-out infinite; }
@keyframes lebard-gesture-flourish-r { 0%, 12% { transform: rotate(0deg); } 38% { transform: rotate(-44deg); } 60% { transform: rotate(48deg); } 82%, 100% { transform: rotate(0deg); } }
@keyframes lebard-gesture-flourish-l { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-14deg); } }
@keyframes lebard-gesture-flourish-prop { 0%, 12% { transform: rotate(-4deg); } 38% { transform: rotate(-48deg); } 60% { transform: rotate(44deg); } 82%, 100% { transform: rotate(-4deg); } }

.mascot-svg[data-motion="sitting"][data-gesture="point"] .automaton-arm-r { animation: lebard-gesture-point-r 2.6s ease-in-out infinite; transform-origin: 242px 189px; }
@keyframes lebard-gesture-point-r { 0%, 100% { transform: rotate(0deg); } 30% { transform: rotate(-52deg); } 70% { transform: rotate(-52deg); } }

.mascot-svg[data-motion="cabinet"] .automaton-body,
.mascot-svg[data-motion="shelf"] .automaton-body {
  animation: mascot-browse-tilt 1.8s ease-in-out infinite;
}
@keyframes mascot-browse-tilt { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-3deg); } }

/* ---------- scroll: unfurls downward from its rod while "reading" ---------- */
/* Real progress bar (see loadingUI.js's setProgress/reveal), not a decorative loop -- scaleY is
   driven by JS setting an inline `transform` directly on this group (see that function's own
   comment for why that safely overrides the CSS default below without the attribute-vs-CSS-
   transform collision documented elsewhere in this file: there's no competing CSS `transform`
   *rule* here, just this one static default, and inline style always wins over a stylesheet rule
   regardless of specificity). `transform-box: fill-box` makes `transform-origin: top` resolve
   against this group's own bounding box (the parchment rect, y=158) rather than the whole 780x320
   scene's top edge -- without it the parchment would grow from a point far above the rod instead
   of its own top. Everything that should visibly "unfurl" together -- the parchment, its rainbow
   fill, the faint rule lines -- lives inside this one group so a single scaleY grows all of them
   in lockstep. */
.mascot-scroll-fill {
  transform: scaleY(0);
  transform-origin: top;
  transform-box: fill-box;
  transition: transform 0.6s ease;
}
/* `fill` is the per-instance rainbow gradient defined in loadingUI.js's buildMascotMarkup, not a
   flat color -- reads as something magical washing over the parchment as it unfurls. */
.page-fill { opacity: 0.6; }

/* ---------- wax seal: cracks once as the scene mounts, then stays gone ----------
   Deliberately a plain (non-`infinite`) animation with `animation-fill-mode: forwards` -- it just
   plays through once as soon as this element exists (loadingUI.js rebuilds the whole scene's
   markup fresh on every show(), so a new instance always gets a fresh crack) and then holds its
   end state, rather than looping alongside the ambient stuff below. */
.mascot-seal-solid, .mascot-seal-half {
  transform-box: fill-box;
  transform-origin: center;
}
.mascot-seal-solid { animation: mascot-seal-hold 1.1s ease forwards; }
@keyframes mascot-seal-hold {
  0%, 85% { opacity: 1; }
  100% { opacity: 0; }
}
.mascot-seal-half {
  animation-duration: 1.1s;
  animation-timing-function: cubic-bezier(.5, 0, .75, 1);
  animation-fill-mode: forwards;
}
.mascot-seal-half--l { animation-name: mascot-seal-crack-l; }
.mascot-seal-half--r { animation-name: mascot-seal-crack-r; }
@keyframes mascot-seal-crack-l {
  0%, 78% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
  100% { transform: translate(-16px, 22px) rotate(-55deg); opacity: 0; }
}
@keyframes mascot-seal-crack-r {
  0%, 78% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
  100% { transform: translate(16px, 24px) rotate(55deg); opacity: 0; }
}

/* ambient magic near the book while reading */
.mascot-spark { opacity: 0; }
.mascot-svg[data-motion="sitting"] .mascot-spark { animation: mascot-spark-rise 2.6s ease-out infinite; }
.mascot-spark--1 { animation-delay: 0s; } .mascot-spark--2 { animation-delay: 0.6s; } .mascot-spark--3 { animation-delay: 1.2s; }
.mascot-spark--4 { animation-delay: 1.8s; } .mascot-spark--5 { animation-delay: 0.3s; }
@keyframes mascot-spark-rise {
  0% { opacity: 0; transform: translateY(0) scale(0.6); }
  15% { opacity: 1; } 80% { opacity: 0.4; }
  100% { opacity: 0; transform: translateY(-34px) scale(1); }
}
.book-glow { animation: mascot-aura-pulse 3.4s ease-in-out infinite; }
@keyframes mascot-aura-pulse { 0%, 100% { opacity: 0.12; r: 46; } 50% { opacity: 0.28; r: 56; } }

/* Shelf books are draggable (see loadingUI.js's makeShelfBooksInteractive) -- touch-action: none
   stops a touchscreen drag from also scrolling the page while a book is being picked up. */
.mascot-shelf-book { cursor: grab; touch-action: none; }
.mascot-shelf-book.is-dragging { cursor: grabbing; }

/* ---------- desk dressing: candle, reading lamp, alchemy props ---------- */
/* transform-box: fill-box makes transform-origin resolve against this <path>'s own geometry (its
   flame shape spans x=6..14, so "bottom center" lands exactly on the candlestick's cx=10) --
   without it, some browsers anchor an SVG element's transform-origin to the whole <svg> viewport
   instead of the element's own bounding box, which is what was actually causing the flame to swing
   off-center: the rotate/scale below was pivoting around a point nowhere near the candle. Also
   toned down the rotate/scale amplitude itself, which read as too jittery even once correctly
   anchored. */
.candle-flame {
  transform-box: fill-box;
  transform-origin: bottom center;
  animation: mascot-flicker 1.8s ease-in-out infinite;
}
@keyframes mascot-flicker {
  0%, 100% { transform: scale(1, 1) rotate(0deg); }
  25% { transform: scale(0.97, 1.03) rotate(-0.75deg); }
  50% { transform: scale(1.02, 0.98) rotate(0.5deg); }
  75% { transform: scale(0.98, 1.02) rotate(0.75deg); }
}
.candle-glow, .lamp-glow, .vial-glow { animation: mascot-aura-pulse 2.6s ease-in-out infinite; }
.lamp-glow { animation-duration: 3.1s; }
.vial-glow { animation-duration: 2.2s; animation-delay: 0.4s; }

/* ---------- ambient mystical dust, drifting the whole scene, always on ---------- */
.ambient-mote { opacity: 0; animation: mascot-ambient-drift 8s ease-in-out infinite; }
@keyframes mascot-ambient-drift {
  0% { opacity: 0; transform: translate(0, 0); }
  10% { opacity: 0.5; }
  50% { opacity: 0.7; transform: translate(var(--mx, 10px), calc(var(--my, -20px) * 0.5)); }
  90% { opacity: 0.3; }
  100% { opacity: 0; transform: translate(var(--mx, 10px), var(--my, -20px)); }
}

/* ---------- essence wisps: motes whisked from the shelf/cabinets toward the book ----------
   Positions (cx/cy) and per-wisp --dx/--dy are set in JS by loadingUI.js's populateEssenceWisps --
   the keyframes below just animate a translate() offset + fade so each wisp reads as drawn out of
   its source and converging on the book while it's "building". */
.mascot-essence-wisp {
  opacity: 0;
  animation: mascot-essence-flow 2.6s ease-in infinite;
}
@keyframes mascot-essence-flow {
  0% { opacity: 0; transform: translate(0, 0) scale(0.7); }
  12% { opacity: 0.9; }
  75% { opacity: 0.5; }
  100% { opacity: 0; transform: translate(var(--dx, 0px), var(--dy, 0px)) scale(0.4); }
}

/* ---------- reveal: the loading scene's completion moment (see loadingUI.js's showRich's own
   reveal(), called via LoadingUI.playReveal() right before the real result replaces this scene) --
   everything still moving slows way down while the scroll finishes unfurling and grows slightly,
   so "it's done" reads as a deliberate beat instead of the scene just vanishing mid-motion. Every
   duration in this whole section was deliberately lengthened together (durations up from an
   original ~0.55-1.7s to ~1.3-4.4s here, plus loadingUI.js's own EXPLOSION_DELAY_MS/
   RESOLVE_AFTER_EXPLOSION_MS/EXPLOSION_SLOWMO/animateSectionsIn stagger) -- this is a one-time
   payoff moment, not something anyone sits through repeatedly the way the ambient wandering/quips
   are, so there's no cost to lingering on it, and the original faster pace made the reveal burst's
   own arrival at the book hard to actually follow (see mascot-reveal-converge below). ---------- */
.mascot-scroll-group {
  transition: transform 4.4s cubic-bezier(.22, .9, .3, 1);
  transform-box: fill-box;
  transform-origin: center;
}
.mascot-svg.revealing .mascot-scroll-group { transform: scale(1.16); }
.mascot-svg.revealing .mascot-scroll-fill { transition: transform 4.4s cubic-bezier(.22, .9, .3, 1); }
.mascot-svg.revealing .book-glow {
  animation: none;
  opacity: 0.5;
  r: 66;
  transition: opacity 4.4s ease, r 4.4s ease;
}
.mascot-svg.revealing .mascot-spark { animation-duration: 7.5s; }
.mascot-svg.revealing .ambient-mote { animation-duration: 23s; }
.mascot-svg.revealing .mascot-essence-wisp { animation-duration: 7.5s; }
.mascot-svg.revealing .candle-flame,
.mascot-svg.revealing .candle-glow,
.mascot-svg.revealing .lamp-glow,
.mascot-svg.revealing .vial-glow { animation-duration: 7.5s; }

/* Celebration: jumps in place while both arms raise and wave, wherever the automaton currently is
   (desk, cabinet, or shelf; see loadingUI.js's reveal() for why he isn't sent home first). The
   jump overrides -- doesn't combine with -- the ambient mascot-bob/mascot-browse-tilt rule above
   (same .automaton-body element, tied specificity via the [data-motion="..."] attribute selector,
   so this wins purely on appearing later in the stylesheet); same transform-origin values on the
   arms as the existing walking swing further up. Jump and wave share one duration so the peaks of
   each land together and read as one cohesive celebration gesture rather than two independently-
   timed motions drifting in and out of sync. */
.mascot-svg.revealing .automaton-body { animation: mascot-celebrate-jump 1.3s ease-in-out infinite; }
@keyframes mascot-celebrate-jump {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  45% { transform: translateY(-16px) rotate(0deg); }
  55% { transform: translateY(-16px) rotate(0deg); }
}
.mascot-svg.revealing .automaton-arm-l { animation: mascot-wave-l 1.3s ease-in-out infinite; transform-origin: 206px 189px; }
.mascot-svg.revealing .automaton-arm-r { animation: mascot-wave-r 1.3s ease-in-out infinite; transform-origin: 242px 189px; }
@keyframes mascot-wave-l { 0%, 100% { transform: rotate(-48deg); } 50% { transform: rotate(-74deg); } }
@keyframes mascot-wave-r { 0%, 100% { transform: rotate(48deg); } 50% { transform: rotate(74deg); } }

/* ---------- reveal burst: rainbow motes rushing in from all four edges of the frame toward the
   book's exact center the instant the deck is ready (see loadingUI.js's populateRevealBurst) -- a
   continuous ~5s stream of individually-spawned, one-shot particles (not an always-on loop like the
   ambient essence wisps above), so it reads as a distinct celebratory rush rather than continuous
   ambient flow.
   Went through several revisions before landing here, all chasing the same problem from different
   angles: a version that faded a particle out well before it actually reached the book never read
   as "landing" anywhere; a version that held particles fully opaque and motionless at the target
   for a big chunk of a shared, long animation piled dozens of them into one static, book-shaped
   clump; even randomizing each particle's OWN duration/delay within that same "create them all up
   front" approach still left a large fraction of the field parked near the target at any given
   instant, since so much of any one particle's long lifetime was necessarily spent already arrived.
   The actual fix wasn't in this keyframe at all -- it was spawning particles one at a time, spread
   over the whole ~5s window (see populateRevealBurst), so only a small, constantly-changing handful
   are ever in flight together. That means each individual particle's OWN trip (below) can -- and
   needs to -- be short and almost entirely in motion: translation runs across nearly this whole
   duration, with only a brief flash right at arrival before fading, rather than a long dwell. ------- */
.mascot-reveal-particle {
  opacity: 0;
  animation: mascot-reveal-converge 1.1s cubic-bezier(.3, .05, .35, 1) forwards;
}
@keyframes mascot-reveal-converge {
  0% { opacity: 0; transform: translate(0, 0) scale(0.6); }
  10% { opacity: 1; }
  86% { opacity: 1; transform: translate(calc(var(--dx, 0px) * 0.96), calc(var(--dy, 0px) * 0.96)) scale(1.05); }
  100% { opacity: 0; transform: translate(var(--dx, 0px), var(--dy, 0px)) scale(1.3); }
}

/* ---------- speech bubble (child of .automaton-pos, tracks his position) ----------
   .mascot-speech-bubble itself carries the show/hide transform below -- its static position
   lives on a separate outer wrapper in the markup, not on this element, for the same
   attribute-vs-CSS-transform reason documented above .automaton-pos. */
.mascot-speech-bubble { opacity: 0; transform: translateY(6px) scale(0.85); transform-origin: bottom left; transition: opacity 220ms ease, transform 220ms ease; }
.mascot-speech-bubble.show { opacity: 1; transform: translateY(0) scale(1); }

@media (prefers-reduced-motion: reduce) {
  .automaton-pos, .automaton-facing, .automaton-body, .automaton-eye, .automaton-eye-glow,
  .eye-pupil, .automaton-quill, .mascot-float-shadow, .automaton-arm-l,
  .automaton-arm-r, .lebard-prop-wand, .page-fill, .book-glow, .candle-flame, .candle-glow, .lamp-glow, .vial-glow,
  .ambient-mote, .mascot-spark, .mascot-speech-bubble, .mascot-essence-wisp, .mascot-scroll-group,
  .mascot-scroll-fill, .mascot-seal-solid, .mascot-seal-half, .mascot-progress-fill-bar,
  .mascot-reveal-particle, .reveal-section,
  .companion-svg, .companion-svg * {
    animation: none !important;
    transition: none !important;
  }
  /* No crack animation to reveal it, so skip straight to "already open": hide the seal outright
     rather than leaving an intact wax seal sitting there forever. */
  .mascot-seal-solid, .mascot-seal-half { opacity: 0 !important; }
}

/* Staggered top-to-bottom fade-in for a freshly-rendered result's own top-level sections (see
   loadingUI.js's animateSectionsIn, called right after renderBuildResult/renderAnalyzeResult/the
   Card Ideas gallery render, while playReveal()'s page-wide explosion is still travelling across
   the screen) -- transition-delay is set per-element in JS, this just defines the two end states. */
.reveal-section {
  opacity: 0;
  transform: translateY(-14px);
  transition: opacity 0.5s ease, transform 0.5s var(--ease);
}
.reveal-section--in { opacity: 1; transform: translateY(0); }

.result-block { display: flex; flex-direction: column; gap: 10px; }

.result-heading {
  font-size: 0.8929rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim);
}

/* Collapsible section headers (see collapsible.js) -- every result-heading in the build/analyze
   output, the mana curve/color/Card View blocks, and My Decks' analysis view is
   one of these now. .result-heading's font styling above still applies; this gives the <button>
   its own bordered, tappable row instead of sitting flush with the page background as plain text
   -- a whole result panel is usually a dozen-plus of these stacked in a row (see collapsible.js's
   own comment on why everything starts collapsed), so without a visible edge each one reads as
   just another line of text rather than a distinct, clickable control. */
button.collapsible-header {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 9px 12px;
  margin: 0;
  cursor: pointer;
  text-align: left;
  font-family: inherit;
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}

button.collapsible-header:hover {
  color: var(--text);
  border-color: var(--accent-dim);
  background: color-mix(in srgb, var(--bg-panel) 80%, var(--accent) 20%);
}

/* An open section stays visually distinct from its still-collapsed siblings even after the mouse
   moves away -- an accent-tinted edge reads as "this one's expanded" at a glance down a long list
   of headers, the same job the chevron's rotation does up close. */
.collapsible-section.is-open > button.collapsible-header {
  color: var(--text);
  border-color: var(--accent-dim);
}

.collapsible-chevron {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  color: var(--accent-hover);
  transition: transform 0.15s ease, background-color 0.15s ease;
  font-size: 0.7143rem;
}

.collapsible-section.is-open .collapsible-chevron {
  transform: rotate(90deg);
  background: color-mix(in srgb, var(--accent) 30%, transparent);
}

.collapsible-body { margin-top: 8px; }

/* "The Cards" is the one section heading in the app with a neon glow + sparkle treatment (see
   chart.js) -- deliberately not applied to every header, so it stays a specific, findable
   landmark instead of visual noise repeated everywhere. --neon is themed per [data-theme] above,
   chosen as roughly the opposite hue of that theme's --accent so the glow always reads as a
   distinct, contrasting color rather than just a brighter version of the accent already used
   everywhere else. The sparkle is a single ::after with several small radial-gradient dots whose
   opacity is keyframed on staggered offsets, rather than separate DOM elements per dot, since
   nothing here needs individual control over each sparkle. */
.the-cards-section > button.collapsible-header {
  position: relative;
  color: var(--neon);
  border-color: color-mix(in srgb, var(--neon) 45%, var(--border));
  text-shadow: 0 0 4px var(--neon), 0 0 10px var(--neon), 0 0 20px color-mix(in srgb, var(--neon) 65%, transparent);
  animation: the-cards-glow-pulse 2.6s ease-in-out infinite;
}

.the-cards-section > button.collapsible-header:hover,
.the-cards-section.is-open > button.collapsible-header {
  color: var(--neon);
  border-color: var(--neon);
}

.the-cards-section .collapsible-chevron {
  background: color-mix(in srgb, var(--neon) 25%, transparent);
  color: var(--neon);
}

.the-cards-section > button.collapsible-header::after {
  content: '';
  position: absolute;
  inset: -8px -12px;
  pointer-events: none;
  background-image:
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%);
  background-size: 4px 4px, 3px 3px, 5px 5px, 3px 3px, 4px 4px;
  background-repeat: no-repeat;
  background-position: 10% 15%, 30% 80%, 55% 10%, 78% 70%, 92% 30%;
  opacity: 0;
  animation: the-cards-sparkle 3.4s ease-in-out infinite;
}

@keyframes the-cards-glow-pulse {
  0%, 100% {
    text-shadow: 0 0 4px var(--neon), 0 0 10px var(--neon), 0 0 20px color-mix(in srgb, var(--neon) 65%, transparent);
  }
  50% {
    text-shadow: 0 0 6px var(--neon), 0 0 16px var(--neon), 0 0 30px color-mix(in srgb, var(--neon) 80%, transparent);
  }
}

@keyframes the-cards-sparkle {
  0%, 100% { opacity: 0; }
  8%, 22% { opacity: 1; }
  15% { opacity: 0.15; }
  38%, 52% { opacity: 1; }
  45% { opacity: 0.15; }
  68%, 82% { opacity: 1; }
  75% { opacity: 0.15; }
}

/* Same shape as the-cards-glow-pulse above, but with --neon-orange baked into the steps instead
   of --neon -- see .synergy-matrix-heading for why this can't just reuse the-cards-glow-pulse. */
@keyframes synergy-matrix-glow-pulse {
  0%, 100% {
    text-shadow: 0 0 4px var(--neon-orange), 0 0 10px var(--neon-orange), 0 0 20px color-mix(in srgb, var(--neon-orange) 65%, transparent);
  }
  50% {
    text-shadow: 0 0 6px var(--neon-orange), 0 0 16px var(--neon-orange), 0 0 30px color-mix(in srgb, var(--neon-orange) 80%, transparent);
  }
}

/* Play nav tab's own pulse -- same shape again, --neon-blue baked into the steps (a keyframe can't
   parameterize the color it animates, hence one per landmark; see .synergy-matrix-heading). */
@keyframes play-glow-pulse {
  0%, 100% {
    text-shadow: 0 0 4px var(--neon-blue), 0 0 10px var(--neon-blue), 0 0 20px color-mix(in srgb, var(--neon-blue) 65%, transparent);
  }
  50% {
    text-shadow: 0 0 6px var(--neon-blue), 0 0 16px var(--neon-blue), 0 0 30px color-mix(in srgb, var(--neon-blue) 80%, transparent);
  }
}

.collapsible-body.hidden { display: none; }

/* My Decks' per-deck analysis (see decks.js's renderAnalysisBlock) can run to several nested
   sections of prose once expanded, and it lives inside a list that may already have several other
   deck tiles above and below it -- without a bound of its own it just keeps growing the tile
   (and the whole decks list) taller instead of giving the person something they can scroll inside
   right where they're already looking, same reasoning as .results' own max-height above. */
.deck-analysis-block > .collapsible-body {
  max-height: 60vh;
  overflow-y: auto;
}

/* Dims the mana curve/color/Card View block in place while refreshAnalysis() (app.js) re-fetches
   after picking a printing or adding a card -- keeping the real content on screen instead of
   swapping it for a one-line loading placeholder avoids both the page's scroll position clamping
   back up when the tall Card View grid briefly vanished, and an already-open Card View flashing
   shut and back open. */
.is-refreshing {
  opacity: 0.5;
  pointer-events: none;
  transition: opacity 0.15s ease;
}

/* Wraps the nested per-section collapsibles built by decks.js's renderAnalysisSections -- same
   gap treatment as .results above, so consecutive section headers don't run together with no
   visual separation the way plain stacked block children would. */
.deck-analysis-sections {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.writeup-text {
  white-space: pre-wrap;
  line-height: 1.55;
  font-size: 0.9643rem;
}

/* Commander showcase inside "The Game Plan" (see app.js's buildCommanderShowcaseImage) -- floated
   right so the section's prose wraps around it, magazine-article style. .writeup-with-commander
   gets its own rule (a clearfix) so the section's box actually contains the float's height instead
   of collapsing around it when the prose is shorter than the image. */
.writeup-with-commander { overflow: auto; }

/* Stained-glass drop cap on the opening letter of "The Game Plan" -- there's no font-src
   allowed under this app's CSP (default-src 'self', no external font loading), so this still
   leans on a system serif pushed heavy + italic rather than a real display face. The glyph is
   built from several stacked background layers, all clipped to the letter shape via
   background-clip: text: three small "glint" highlights (screen-blended so they read as light
   catching cut facets) over a triangulated three-direction hatch (the "leading" seams, multiply-
   blended so they look cut into the glass rather than painted on top) over a hard-edged six-wedge
   conic gradient (the individual jewel-toned panes -- hard color stops instead of smooth ones so
   adjacent panes read as separate pieces of glass, not a blend). -webkit-text-stroke traces the
   letterform itself in the same dark leading color so its outline stays crisp against the glow.
   A layered text-shadow supplies the neon-tube glow (white-hot core fading through the app's
   accent color into fixed neon hues), and the box-shadow adds a second, wider colored bloom plus
   an inner rim light. Applies whenever the letter is drawn, regardless of whether the commander
   showcase image (see .writeup-with-commander above) also rendered -- the two floats coexist fine
   since ::first-letter still targets the first glyph of the actual text run. */
.writeup-game-plan::first-letter {
  float: left;
  font-family: Cambria, Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", serif;
  font-weight: 700;
  font-style: italic;
  font-size: 3.6em;
  line-height: 0.8;
  margin: 4px 10px 0 0;
  padding: 2px 7px;
  border: 3px solid #05060c;
  border-radius: 4px;
  outline: 1px solid rgba(255, 255, 255, 0.08);
  outline-offset: 2px;
  background:
    radial-gradient(circle at 24% 18%, rgba(255, 255, 255, 0.95), transparent 9%),
    radial-gradient(circle at 74% 62%, rgba(255, 255, 255, 0.55), transparent 7%),
    radial-gradient(circle at 55% 84%, rgba(255, 255, 255, 0.35), transparent 6%),
    repeating-linear-gradient(6deg, transparent 0 10px, rgba(4, 6, 14, 0.35) 10px 12px),
    repeating-linear-gradient(48deg, transparent 0 7px, rgba(4, 6, 14, 0.65) 7px 9px),
    repeating-linear-gradient(-52deg, transparent 0 9px, rgba(4, 6, 14, 0.55) 9px 11px),
    conic-gradient(
      from 210deg at 45% 35%,
      #ff2ec4 0deg 51deg,
      #7d5bff 51deg 111deg,
      #2ec6ff 111deg 171deg,
      #29ffb0 171deg 231deg,
      #f6ff3d 231deg 291deg,
      #ff8a2e 291deg 360deg
    );
  background-blend-mode: screen, screen, screen, multiply, multiply, multiply, normal;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  -webkit-text-stroke: 1px #05060c;
  color: transparent;
  filter: saturate(1.2) contrast(1.08);
  text-shadow:
    0 0 3px #fff,
    0 0 9px var(--accent),
    0 0 20px var(--accent),
    0 0 34px color-mix(in srgb, var(--accent) 65%, #2ec6ff),
    0 0 54px color-mix(in srgb, var(--accent) 45%, #ff2ec4);
  box-shadow:
    0 0 14px color-mix(in srgb, var(--accent) 55%, transparent),
    0 0 26px color-mix(in srgb, var(--accent) 30%, #ff2ec4),
    inset 0 0 0 1px rgba(255, 255, 255, 0.15),
    inset 0 0 8px rgba(255, 255, 255, 0.2);
}

/* Commander showcase in "The Game Plan"/Pilot description -- the highlighted commander card gets
   a themed accent glow plus a small particle "drip" that falls and fades below it. The drip zone
   is reserved as padding-bottom on this element itself (not margin) specifically so overflow:
   hidden on this same element clips it -- containment doesn't depend on how the clearfix parent's
   height/overflow happens to interact with a float's margin box, which is exactly the kind of
   thing that varies enough across browsers to risk the drip spilling into the next section below. */
.game-plan-commander {
  float: right;
  width: 200px;
  max-width: 42%;
  margin: 0 0 12px 16px;
  position: relative;
  padding-bottom: 30px;
  overflow: hidden;
}

.game-plan-commander img {
  width: 100%;
  display: block;
  position: relative;
  z-index: 1;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  box-shadow:
    0 6px 18px rgba(0, 0, 0, 0.35),
    0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
    0 0 16px color-mix(in srgb, var(--accent) 60%, transparent),
    0 0 32px color-mix(in srgb, var(--accent) 32%, transparent);
  animation: commander-glow-pulse 3s ease-in-out infinite;
}

.game-plan-commander::after {
  content: '';
  position: absolute;
  left: 6%;
  right: 6%;
  bottom: 0;
  height: 30px;
  pointer-events: none;
  background-image:
    radial-gradient(circle, var(--accent) 0%, transparent 70%),
    radial-gradient(circle, var(--accent) 0%, transparent 70%),
    radial-gradient(circle, var(--accent) 0%, transparent 70%),
    radial-gradient(circle, var(--accent) 0%, transparent 70%);
  background-repeat: no-repeat;
  background-size: 5px 8px, 4px 7px, 5px 9px, 4px 6px;
  background-position: 12% -10%, 40% -30%, 68% -15%, 88% -40%;
  animation: commander-drip 2.6s linear infinite;
}

/* Fallback for .game-plan-commander when the name doesn't resolve on Scryfall (see app.js's/
   renderer.js's buildCommanderShowcaseImage) -- not floated, since there's no image left for prose
   to wrap around; renders as its own small badge-style line above the writeup instead. */
.game-plan-commander-fallback {
  display: inline-flex;
  align-items: center;
  padding: 4px 10px;
  margin: 0 0 10px;
  border-radius: var(--radius-pill);
  font-size: 0.8571rem;
  font-weight: 600;
  background: rgba(var(--accent-rgb), 0.18);
  color: var(--accent);
}

@keyframes commander-glow-pulse {
  0%, 100% {
    box-shadow:
      0 6px 18px rgba(0, 0, 0, 0.35),
      0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
      0 0 16px color-mix(in srgb, var(--accent) 60%, transparent),
      0 0 32px color-mix(in srgb, var(--accent) 32%, transparent);
  }
  50% {
    box-shadow:
      0 6px 18px rgba(0, 0, 0, 0.35),
      0 0 0 1px color-mix(in srgb, var(--accent) 75%, transparent),
      0 0 22px color-mix(in srgb, var(--accent) 80%, transparent),
      0 0 42px color-mix(in srgb, var(--accent) 48%, transparent);
  }
}

@keyframes commander-drip {
  0% { background-position: 12% -10%, 40% -30%, 68% -15%, 88% -40%; opacity: 0.9; }
  70% { opacity: 0.45; }
  100% { background-position: 12% 100%, 40% 100%, 68% 100%, 88% 100%; opacity: 0; }
}

/* Card Ideas gallery (see app.js's renderIdeasResult) -- one tile per suggested card, each with a
   themed glow + particle drip on its art, same technique as the Commander showcase above
   (padding-bottom reserves a self-contained drip zone, clipped by this element's own
   overflow:hidden rather than depending on a parent's clearfix behavior). Uses --neon rather than
   --accent -- deliberately a different, contrasting color from the Commander showcase's glow, so
   "a card already in your deck" and "a new card being suggested" read as two distinct treatments
   at a glance instead of the same effect recycled everywhere. */
.card-ideas-gallery {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 20px;
}

.card-ideas-tile {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.card-ideas-tile-art {
  position: relative;
  padding-bottom: 26px;
  overflow: hidden;
}

.card-ideas-tile-art img {
  width: 100%;
  display: block;
  position: relative;
  z-index: 1;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  box-shadow:
    0 6px 18px rgba(0, 0, 0, 0.35),
    0 0 0 1px color-mix(in srgb, var(--neon) 55%, transparent),
    0 0 16px color-mix(in srgb, var(--neon) 55%, transparent),
    0 0 30px color-mix(in srgb, var(--neon) 28%, transparent);
  animation: card-ideas-glow-pulse 3s ease-in-out infinite;
}

.card-ideas-tile-art.missing-img {
  aspect-ratio: 63 / 88;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-dim);
  font-size: var(--font-xs);
}

.card-ideas-tile-art::after {
  content: '';
  position: absolute;
  left: 6%;
  right: 6%;
  bottom: 0;
  height: 26px;
  pointer-events: none;
  background-image:
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%);
  background-repeat: no-repeat;
  background-size: 5px 8px, 4px 7px, 5px 9px, 4px 6px;
  background-position: 12% -10%, 40% -30%, 68% -15%, 88% -40%;
  animation: card-ideas-drip 2.8s linear infinite;
}

@keyframes card-ideas-glow-pulse {
  0%, 100% {
    box-shadow:
      0 6px 18px rgba(0, 0, 0, 0.35),
      0 0 0 1px color-mix(in srgb, var(--neon) 55%, transparent),
      0 0 16px color-mix(in srgb, var(--neon) 55%, transparent),
      0 0 30px color-mix(in srgb, var(--neon) 28%, transparent);
  }
  50% {
    box-shadow:
      0 6px 18px rgba(0, 0, 0, 0.35),
      0 0 0 1px color-mix(in srgb, var(--neon) 75%, transparent),
      0 0 22px color-mix(in srgb, var(--neon) 75%, transparent),
      0 0 42px color-mix(in srgb, var(--neon) 42%, transparent);
  }
}

@keyframes card-ideas-drip {
  0% { background-position: 12% -10%, 40% -30%, 68% -15%, 88% -40%; opacity: 0.9; }
  70% { opacity: 0.45; }
  100% { background-position: 12% 100%, 40% 100%, 68% 100%, 88% 100%; opacity: 0; }
}

.card-ideas-tile-name { font-weight: 600; font-size: 0.9286rem; color: var(--text); }
.card-ideas-tile-reason { font-size: 0.8571rem; color: var(--text-dim); }

/* Strengths + Weaknesses side by side (see collapsible.js's makeStrengthsWeaknessesBlock) -- two
   tinted columns rather than the plain stacked collapsible sections every other writeup section
   gets. --ok/--error are already themed per [data-theme] above, so the tint always reads as a
   shade of the active theme's own palette instead of a flat red/green painted over it. */
.strengths-weaknesses-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}

.sw-column {
  border-radius: var(--radius);
  border: 1px solid;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.sw-strengths {
  background: color-mix(in srgb, var(--ok) 12%, var(--bg-panel));
  border-color: color-mix(in srgb, var(--ok) 45%, var(--border));
}

.sw-weaknesses {
  background: color-mix(in srgb, var(--error) 12%, var(--bg-panel));
  border-color: color-mix(in srgb, var(--error) 45%, var(--border));
}

.sw-heading { text-transform: uppercase; }
.sw-strengths .sw-heading { color: var(--ok); }
.sw-weaknesses .sw-heading { color: var(--error); }

.sw-list {
  margin: 0;
  padding-left: 22px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: 0.9286rem;
  line-height: 1.5;
  color: var(--text);
}

.sw-list li::marker { font-weight: 700; }
.sw-strengths .sw-list li::marker { color: var(--ok); }
.sw-weaknesses .sw-list li::marker { color: var(--error); }

/* "Your build inputs" recap at the bottom of a build/edit result -- a plain label/value list
   rather than prose, so it reads as reference data (what you asked for) distinct from the
   writeup above it (what Claude came back with). */
.build-inputs-list { display: flex; flex-direction: column; gap: 4px; }
.build-inputs-row { display: flex; gap: 8px; font-size: 0.9286rem; line-height: 1.5; }
.build-inputs-label { color: var(--text-dim); min-width: 140px; flex-shrink: 0; }
.build-inputs-value { color: var(--text); white-space: pre-wrap; }

.badge-row { display: flex; gap: 8px; flex-wrap: wrap; }

/* "Read summary aloud" (see voice.js's buildButton) -- always present in the DOM once a result
   renders, but only shown once the Settings -> Appearance "Voice summary" toggle turns this
   attribute on (see voice.js's applyEnabled) -- same data-attribute-on-<html> pattern
   appearance.js uses for theme/font, so flipping the setting takes effect instantly on whatever
   result is already on screen, not just the next one rendered. */
.read-aloud-btn { display: none; margin: 4px 0; }
html[data-voice-enabled="1"] .read-aloud-btn { display: inline-flex; align-items: center; gap: 6px; }

.badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px;
  border-radius: var(--radius-pill);
  font-size: 0.8571rem;
  font-weight: 600;
  background: var(--border);
  color: var(--text-dim);
}

.badge.ok { background: rgba(var(--ok-rgb), 0.18); color: var(--ok); }
.badge.warn { background: rgba(var(--warn-rgb), 0.18); color: var(--warn); }
.badge.error { background: rgba(var(--error-rgb), 0.18); color: var(--error); }
.badge.new { background: rgba(var(--accent-rgb), 0.18); color: var(--accent); }

/* A card fillDecklistToHundred (anthropicClient.js) added on its own to fill out a decklist that
   came in short of 100 -- see chart.js's buildCardPriceRow/Tile. Border + badge together rather
   than either alone, since a color/border-only cue isn't reliable for everyone (color vision, a
   small/dim monitor, etc.) -- the "New" text carries the meaning on its own if the border doesn't
   read clearly. Applies to both the List view's .collection-row (already has its own 1px border,
   so this just recolors/thickens it) and the Grid/Card views' .card-image-tile (has no border of
   its own normally, so this adds one outright) -- the "Card View" section can be in any of the
   three view modes. */
.collection-row.new-card {
  border: 2px solid var(--accent);
}

.card-image-tile.new-card {
  border: 2px solid var(--accent);
  border-radius: var(--radius-sm);
}

/* Highlights the commander's own row/tile in the List/Grid/Card views (see chart.js's
   buildCommanderCrown/isCommander) -- echoes the same accent glow the commander already gets as a
   floated showcase image in "The Game Plan" (see .game-plan-commander/@keyframes
   commander-glow-pulse above), toned down here to suit a small grid tile rather than reusing that
   rule's full-size box-shadow values verbatim. Placed after .new-card above so a card that's
   somehow both (an edit that swaps in a new commander) reads as "commander" first. */
.collection-row.commander-card,
.card-image-tile.commander-card {
  border: 2px solid var(--accent);
  animation: commander-card-glow-pulse 3s ease-in-out infinite;
}

.commander-crown {
  margin-right: 4px;
  font-size: 0.85em;
}

@keyframes commander-card-glow-pulse {
  0%, 100% {
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent), 0 0 8px color-mix(in srgb, var(--accent) 45%, transparent);
  }
  50% {
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 14px color-mix(in srgb, var(--accent) 65%, transparent);
  }
}

.new-card-badge {
  display: inline-block;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--accent);
  background: rgba(var(--accent-rgb), 0.18);
  border-radius: var(--radius-xs);
  padding: 1px 6px;
  margin-left: 6px;
  vertical-align: middle;
}

/* Generic tiered-color rating badge (see chart.js's buildRatingBadge) -- shared by the per-card
   synergy % badge and the Combo Breaker tab's combo rating badge (comboBreaker.js). Tiered
   coloring, not one flat color like .new-card-badge above, since a bare percentage is much slower
   to scan across a whole grid/table than a color already doing that work at a glance. --high
   reuses --ok (this is doing something specific/notable), --low reuses --text-dim (a fine
   include/combo, but not the headline) rather than --error -- a low rating isn't a problem to
   flag, just a neutral fact. */
.rating-badge {
  display: inline-block;
  font-size: 0.75rem;
  font-weight: 600;
  border-radius: var(--radius-xs);
  padding: 1px 6px;
  margin-left: 6px;
  vertical-align: middle;
}
.rating-badge--high { color: var(--ok); background: color-mix(in srgb, var(--ok) 18%, transparent); }
.rating-badge--mid { color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent); }
.rating-badge--low { color: var(--text-dim); background: color-mix(in srgb, var(--text-dim) 16%, transparent); }

/* ---------- Game Changers (see gameChangers.js + chart.js) ----------
   Cards on the official Commander Game Changers list get the same border+glow treatment as
   .commander-card (see commander-card-glow-pulse above), just in --neon-green instead of --accent,
   plus a small top-left badge carrying the Lebard-face icon so the cue isn't color-only. */
.collection-row.game-changer-card,
.card-image-tile.game-changer-card {
  position: relative;
  border: 2px solid var(--neon-green);
  animation: game-changer-glow-pulse 2.2s ease-in-out infinite;
}

@keyframes game-changer-glow-pulse {
  0%, 100% {
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--neon-green) 50%, transparent), 0 0 8px color-mix(in srgb, var(--neon-green) 55%, transparent);
  }
  50% {
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--neon-green) 70%, transparent), 0 0 16px color-mix(in srgb, var(--neon-green) 85%, transparent);
  }
}

.game-changer-badge {
  position: absolute;
  top: -6px;
  left: -6px;
  z-index: 1;
  display: block;
  line-height: 0;
  filter: drop-shadow(0 0 3px var(--neon-green)) drop-shadow(0 0 6px var(--neon-green));
}

.lebard-face-icon {
  width: 22px;
  height: 20px;
  display: block;
}

/* The section-level "View Game Changers" trigger (see chart.js's buildGameChangersTrigger) --
   sits inside .game-changers-wrap in "The Cards" controls row, which handles the centering (see
   that class below), so this doesn't need its own positioning margin. */
.game-changers-trigger {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border-radius: 50%;
  border: 2px solid var(--neon-green);
  background: var(--bg-input);
  cursor: pointer;
  animation: game-changer-glow-pulse 2.2s ease-in-out infinite;
}
.game-changers-trigger:hover,
.game-changers-trigger:focus-visible {
  border-color: var(--neon-green);
  outline: none;
}
.game-changers-trigger .lebard-face-icon {
  width: 26px;
  height: 24px;
  filter: none;
}

/* ---------- Spicy/Fluffy score (see spicyScore.js + chart.js's buildSpicyScoreWidget) ---------- */
.spicy-score-widget {
  display: block;
  width: 100%;
  max-width: 360px;
  text-align: left;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 8px 12px;
  margin-bottom: 12px;
  cursor: pointer;
}
.spicy-score-widget:hover,
.spicy-score-widget:focus-visible {
  border-color: var(--text-dim);
  outline: none;
}
.spicy-score-heading {
  font-size: var(--font-xs);
  color: var(--text-dim);
  margin-bottom: 4px;
}
.spicy-score-bar {
  display: flex;
  width: 100%;
  height: 10px;
  border-radius: 5px;
  overflow: hidden;
  background: var(--bg-input);
}
.spicy-score-bar-spicy {
  background: var(--neon-red);
  box-shadow: 0 0 6px var(--neon-red);
}
.spicy-score-bar-fluffy {
  background: var(--neon-green);
  box-shadow: 0 0 6px var(--neon-green);
}
.spicy-score-labels {
  display: flex;
  justify-content: space-between;
  margin-top: 4px;
  font-size: var(--font-xs);
  font-weight: 600;
}
.spicy-score-label-spicy { color: var(--neon-red); }
.spicy-score-label-fluffy { color: var(--neon-green); }

/* The Spicy/Fluffy modal's two-column breakdown (see chart.js's openSpicyScoreModal) -- separated
   by two adjacent vertical lines rather than one shared border, each glowing its own side's color,
   so the boundary itself carries the Spicy-vs-Fluffy meaning instead of being a neutral divider. */
.spicy-score-modal-columns {
  display: flex;
  align-items: stretch;
  gap: 0;
  margin-top: 12px;
  max-height: 50vh;
}
.spicy-score-modal-column {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  padding: 0 14px;
}
.spicy-score-modal-divider {
  flex: 0 0 auto;
  width: 3px;
  border-radius: 2px;
  margin: 0 4px;
}
.spicy-score-modal-divider--spicy {
  background: var(--neon-red);
  box-shadow: 0 0 8px var(--neon-red);
}
.spicy-score-modal-divider--fluffy {
  background: var(--neon-green);
  box-shadow: 0 0 8px var(--neon-green);
}
.spicy-score-modal-column-heading {
  font-weight: 700;
  font-size: 1.05rem;
  margin-bottom: 8px;
}
.spicy-score-modal-column--spicy .spicy-score-modal-column-heading { color: var(--neon-red); }
.spicy-score-modal-column--fluffy .spicy-score-modal-column-heading { color: var(--neon-green); }
/* padding-right is NOT decoration: .spicy-score-card-row below is space-between, so the "+N" points
   sit exactly on this scroll box's content edge -- i.e. directly under an overlay scrollbar (the
   Windows 11 / Electron default in some builds), which paints over the content instead of taking
   layout width and swallows the score digits. Reserving the gutter here keeps the numbers readable
   with either scrollbar style. */
.spicy-score-card-list {
  overflow-y: auto;
  padding-right: 14px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.spicy-score-card-row {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  font-size: var(--font-sm);
  padding: 3px 0;
  border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.spicy-score-card-points {
  color: var(--neon-red);
  font-weight: 600;
  flex-shrink: 0;
}

.decklist-textarea { min-height: 220px; }

/* Combo Breaker nav tab -- same neon glow "special landmark" treatment as The Cards section (see
   .the-cards-section above), applied straight to the nav tab button since this tab has no
   collapsible section of its own to attach it to. Reuses the same the-cards-glow-pulse/
   the-cards-sparkle keyframes rather than near-duplicate ones. Uses ::before for the sparkle
   (not ::after, like .the-cards-section does) specifically to avoid clobbering .tab.active::after,
   which already owns that pseudo-element for the active-tab underline indicator. */
.combo-breaker-heading {
  color: var(--neon);
  text-shadow: 0 0 4px var(--neon), 0 0 10px var(--neon), 0 0 20px color-mix(in srgb, var(--neon) 65%, transparent);
  animation: the-cards-glow-pulse 2.6s ease-in-out infinite;
}
.combo-breaker-heading:hover,
.combo-breaker-heading.active {
  color: var(--neon);
}
.combo-breaker-heading::before {
  content: '';
  position: absolute;
  inset: -8px -12px;
  pointer-events: none;
  background-image:
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%),
    radial-gradient(circle, var(--neon) 0%, transparent 70%);
  background-size: 4px 4px, 3px 3px, 5px 5px, 3px 3px, 4px 4px;
  background-repeat: no-repeat;
  background-position: 10% 15%, 30% 80%, 55% 10%, 78% 70%, 92% 30%;
  opacity: 0;
  animation: the-cards-sparkle 3.4s ease-in-out infinite;
}

/* Synergy Matrix nav tab -- same neon glow "special landmark" treatment as Combo Breaker above,
   but fixed neon orange (--neon-orange) instead of the themed --neon, so this tab always reads
   the same regardless of theme. Reuses the-cards-sparkle for the ::before (color-agnostic, just
   animates opacity), but needs its own synergy-matrix-glow-pulse keyframe below rather than
   the-cards-glow-pulse, since that keyframe's steps hardcode var(--neon) and would clobber this
   element's orange text-shadow mid-pulse. */
.synergy-matrix-heading {
  color: var(--neon-orange);
  text-shadow: 0 0 4px var(--neon-orange), 0 0 10px var(--neon-orange), 0 0 20px color-mix(in srgb, var(--neon-orange) 65%, transparent);
  animation: synergy-matrix-glow-pulse 2.6s ease-in-out infinite;
}
.synergy-matrix-heading:hover,
.synergy-matrix-heading.active {
  color: var(--neon-orange);
}
.synergy-matrix-heading::before {
  content: '';
  position: absolute;
  inset: -8px -12px;
  pointer-events: none;
  background-image:
    radial-gradient(circle, var(--neon-orange) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-orange) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-orange) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-orange) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-orange) 0%, transparent 70%);
  background-size: 4px 4px, 3px 3px, 5px 5px, 3px 3px, 4px 4px;
  background-repeat: no-repeat;
  background-position: 10% 15%, 30% 80%, 55% 10%, 78% 70%, 92% 30%;
  opacity: 0;
  animation: the-cards-sparkle 3.4s ease-in-out infinite;
}

/* Play nav tab -- the third "special landmark" tab, same neon glow treatment as Combo Breaker and
   Synergy Matrix above, in fixed neon light blue (--neon-blue). Reuses the-cards-sparkle for the
   ::before (color-agnostic, animates opacity only) with its own play-glow-pulse keyframe. */
.play-heading {
  color: var(--neon-blue);
  text-shadow: 0 0 4px var(--neon-blue), 0 0 10px var(--neon-blue), 0 0 20px color-mix(in srgb, var(--neon-blue) 65%, transparent);
  animation: play-glow-pulse 2.6s ease-in-out infinite;
}
.play-heading:hover,
.play-heading.active {
  color: var(--neon-blue);
}
.play-heading::before {
  content: '';
  position: absolute;
  inset: -8px -12px;
  pointer-events: none;
  background-image:
    radial-gradient(circle, var(--neon-blue) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-blue) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-blue) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-blue) 0%, transparent 70%),
    radial-gradient(circle, var(--neon-blue) 0%, transparent 70%);
  background-size: 4px 4px, 3px 3px, 5px 5px, 3px 3px, 4px 4px;
  background-repeat: no-repeat;
  background-position: 10% 15%, 30% 80%, 55% 10%, 78% 70%, 92% 30%;
  opacity: 0;
  animation: the-cards-sparkle 3.4s ease-in-out infinite;
}

/* ---------- Synergy Matrix (see shared/public/synergyMatrix.js) ----------
   The trigger button under the mana curve/pie chart (chart.js's buildAnalysisBlock) and the
   Synergy Matrix tab's own deck picker both open the same grid+line-graph rendering. */

/* Same recipe as .home-nav-btn (styles.css further down), just sized for an inline button rather
   than a home-screen tile -- reuses home-btn-glow-pulse (parameterized by --btn-glow, so pointing
   it at --neon-orange here is safe, unlike the-cards-glow-pulse's hardcoded --neon -- see
   .synergy-matrix-heading's comment above for that distinction). */
.synergy-matrix-btn {
  --btn-glow: var(--neon-orange);
  margin-top: 10px;
  padding: 10px 18px;
  font-family: inherit;
  font-size: 0.9286rem;
  font-weight: 700;
  color: var(--text);
  background: color-mix(in srgb, var(--btn-glow) 12%, var(--bg-panel));
  border: 2px solid color-mix(in srgb, var(--btn-glow) 55%, var(--border));
  border-radius: var(--radius-md);
  cursor: pointer;
  text-shadow: 0 0 6px color-mix(in srgb, var(--btn-glow) 70%, transparent);
  transition: transform var(--transition-fast), border-color var(--transition-fast);
  animation: home-btn-glow-pulse 3s ease-in-out infinite;
}
.synergy-matrix-btn:hover, .synergy-matrix-btn:focus-visible {
  transform: translateY(-2px);
  border-color: var(--btn-glow);
}
.synergy-matrix-btn:focus-visible { outline: 2px solid var(--btn-glow); outline-offset: 3px; }

/* Short "what draws a line" legend (see synergyMatrix.js's buildLegend) -- sits directly under
   the Sort/Show filter dropdowns, still inside the Synergy Matrix's own block. */
.synergy-matrix-legend {
  margin-top: 10px;
  padding: 10px 12px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.synergy-matrix-legend-title {
  font-weight: 600;
  color: var(--text);
  margin-bottom: 4px;
}
.synergy-matrix-legend ul {
  margin: 0;
  padding-left: 18px;
}
.synergy-matrix-legend li {
  margin: 2px 0;
}

.synergy-matrix-scroll {
  max-height: 65vh;
  overflow-y: auto;
  margin-top: 12px;
}

/* The positioning context both the line overlay and the card grid share -- its size is driven
   entirely by .synergy-grid (its only in-flow child), so the absolutely-positioned SVG (which
   doesn't contribute to that size) always stretches to match it exactly, scrolling together with
   the grid inside .synergy-matrix-scroll above rather than staying pinned to the viewport. */
.synergy-matrix-stage-inner {
  position: relative;
}

.synergy-lines-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

.synergy-line {
  stroke: var(--neon-green);
  stroke-linecap: round;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.synergy-line.is-active {
  opacity: var(--glow-opacity, 0.5);
  stroke-width: var(--glow-width, 2);
  filter: drop-shadow(0 0 var(--glow-blur, 4px) var(--neon-green));
  animation: synergy-line-pulse 1.8s ease-in-out infinite;
}

/* Reads the same per-line --glow-opacity/--glow-blur custom properties set above (never a
   hardcoded color/intensity), so every line's pulse scales to its own synergy % independently. */
@keyframes synergy-line-pulse {
  0%, 100% {
    opacity: calc(var(--glow-opacity, 0.5) * 0.75);
    filter: drop-shadow(0 0 calc(var(--glow-blur, 4px) * 0.7) var(--neon-green));
  }
  50% {
    opacity: var(--glow-opacity, 0.5);
    filter: drop-shadow(0 0 var(--glow-blur, 4px) var(--neon-green));
  }
}

.synergy-grid {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
  gap: 22px 16px;
  align-items: start;
}

.synergy-card-tile {
  position: relative;
  cursor: pointer;
  border-radius: var(--radius-sm);
  transition: opacity 0.2s ease, filter 0.2s ease;
}

/* Every card NOT connected to the pinned selection (and not the pinned card itself) once
   something is pinned -- see synergyMatrix.js's showConnections, which only ever applies this
   when `pinned` is true, never during a plain hover preview. Dimmed rather than hidden so the
   grid's overall shape/positions (and the leylines' own endpoints) don't jump around. */
.synergy-card-tile.is-faded {
  opacity: 0.22;
  filter: grayscale(70%);
}

/* Same border+glow treatment as .game-changer-card (reuses its game-changer-glow-pulse keyframe,
   parameterized by --neon-green -- safe to share, same convention as the-cards-glow-pulse being
   shared between The Cards section and the Combo Breaker tab). Marks whichever card is currently
   active -- either a live hover preview, or a click-pinned selection (see .is-pinned below for
   the extra cue distinguishing the two). */
.synergy-card-tile.is-hovered {
  border: 2px solid var(--neon-green);
  animation: game-changer-glow-pulse 2.2s ease-in-out infinite;
}

/* Static outer ring on top of .is-hovered's own border+glow -- the one visual difference between
   "currently hovering this card" (disappears the moment the mouse leaves) and "this card is
   pinned" (stays lit until clicked again or a different card is pinned instead), so a person can
   tell which mode they're in without having to test it by moving the mouse. */
.synergy-card-tile.is-pinned {
  outline: 2px solid var(--neon-green);
  outline-offset: 3px;
}

/* Every card connected to the active/pinned one via a currently-visible leyline -- var(--neon-
   orange), the same hue already used for the Synergy Matrix's own nav-tab/trigger glow (see
   --neon-orange's own definition), so "what this card connects to" reads as a distinct visual
   role from "the selected card itself" (green, .is-hovered above) at a glance. */
.synergy-card-tile.is-connected {
  border: 2px solid var(--neon-orange);
  animation: synergy-connected-glow-pulse 2.2s ease-in-out infinite;
}

@keyframes synergy-connected-glow-pulse {
  0%, 100% {
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--neon-orange) 50%, transparent), 0 0 8px color-mix(in srgb, var(--neon-orange) 55%, transparent);
  }
  50% {
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--neon-orange) 70%, transparent), 0 0 16px color-mix(in srgb, var(--neon-orange) 85%, transparent);
  }
}

.combo-search-panel { margin-bottom: 16px; }

.combo-list { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }

.combo-card {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px;
}

.combo-card-names { font-weight: 600; margin-bottom: 4px; }

.combo-card-description {
  white-space: pre-line;
  font-size: 0.8571rem;
  color: var(--text-dim);
  margin-top: 6px;
}

/* Full-width divider row in the Combo Breaker browse table when sorted by Type/section (see
   comboBreaker.js's buildSectionHeaderRow) -- reads as a category label, not just another row. */
.combo-section-row td {
  background: var(--bg-panel);
  color: var(--text-dim);
  font-weight: 600;
  text-transform: uppercase;
  font-size: 0.7857rem;
  letter-spacing: 0.03em;
  padding: 8px 12px;
}

.result-actions { display: flex; gap: 8px; }

.price-summary {
  white-space: pre-wrap;
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  font-size: 0.8571rem;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
  color: var(--text-dim);
}

.sync-token-box {
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  font-size: 0.9286rem;
  word-break: break-all;
  background: var(--bg-input);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
  color: var(--text);
  user-select: all;
}

/* --- Commander field live autocomplete (see commanderAutocomplete.js) ---------------------- */

.autocomplete-wrap { position: relative; }

.autocomplete-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 20;
  margin-top: 4px;
  max-height: 280px;
  overflow-y: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.autocomplete-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  cursor: pointer;
  border-bottom: 1px solid var(--border);
}

.autocomplete-item:last-child { border-bottom: none; }

.autocomplete-item:hover,
.autocomplete-item.active {
  background: var(--accent-dim);
}

.autocomplete-item-thumb {
  width: 24px;
  height: 24px;
  border-radius: var(--radius-xs);
  object-fit: cover;
  flex-shrink: 0;
  background: var(--bg-input);
}

.autocomplete-item-text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.autocomplete-item-name {
  font-size: 0.9286rem;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.autocomplete-item-type {
  font-size: 0.7857rem;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.autocomplete-empty {
  padding: 8px 10px;
  font-size: 0.8571rem;
  color: var(--text-dim);
}

/* Selected-card artwork in the "+ Add a card" modal -- see addCard.js's showCardPreview. */
.add-card-preview {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px;
  margin: -4px 0 8px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

.add-card-preview img {
  width: 48px;
  border-radius: var(--radius-xs);
  display: block;
  flex-shrink: 0;
}

.add-card-preview-label {
  font-size: 0.9286rem;
  color: var(--text);
}

/* Printing picker (see printingPicker.js) -- a grid of tappable printing thumbnails, same visual
   density as the Card View's own .card-image-grid/.card-image-tile. */
.printing-picker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 10px;
  max-height: 60vh;
  overflow-y: auto;
  margin: 12px 0;
}

.printing-picker-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  text-align: center;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 8px;
  cursor: pointer;
  font-family: inherit;
}

.printing-picker-tile:hover { border-color: var(--accent); }

.printing-picker-tile.selected {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

.printing-picker-tile img {
  width: 100%;
  border-radius: var(--radius-xs);
  display: block;
}

.printing-picker-tile-label {
  font-size: 0.8214rem;
  color: var(--text);
  line-height: 1.4;
}

.error-box {
  background: rgba(var(--error-rgb), 0.1);
  border: 1px solid rgba(var(--error-rgb), 0.4);
  color: var(--error);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
  font-size: 0.9286rem;
}

/* ---------- Settings modal ---------- */

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

.modal {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-modal);
  padding: 24px;
  width: 440px;
  max-width: 90vw;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* Entrance-only -- .modal-overlay goes display:none -> flex when its .hidden class is removed
   (see modalA11y.js), which is a fresh render each time, so this animation restarts on every
   open. No matching exit animation: closing still snaps instantly, which is the standard
   trade-off for display:none-based show/hide (animating the close would need swapping to an
   opacity/visibility-based hide, which reopens the focus-trap-vs-fading-content edge case
   modalA11y.js's trap()/release() were written to avoid). */
@keyframes modal-enter {
  from { opacity: 0; transform: translateY(8px) scale(0.97); }
  to { opacity: 1; transform: none; }
}

.modal-overlay:not(.hidden) .modal {
  animation: modal-enter var(--transition-base);
}

.modal h2 { margin: 0 0 4px 0; font-size: 1.1429rem; }

.help-text {
  font-size: 0.8571rem;
  color: var(--text-dim);
  line-height: 1.5;
}

.btn-inline-link {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: var(--accent-hover);
  cursor: pointer;
  text-decoration: underline;
}

.status-text.ok { color: var(--ok); }
.status-text.error { color: var(--error); }

.modal-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 6px;
}

.modal-wide { width: 560px; max-width: 92vw; }
.modal-xwide { width: 900px; max-width: 95vw; }

/* Card Group modal's card grid (see index.html's #card-group-modal, opened by chart.js's
   openCardGroupModal) -- unlike .modal-body above, this modal doesn't wrap its body in
   .modal-body, so without its own max-height/overflow the grid grew as tall as its card count
   demanded (a wedge with 60+ cards ran the whole modal off both ends of the viewport with no way
   to scroll to the rest). Scoped to the grid itself, not the whole .modal, so the title/count line
   and Close button stay pinned in view while only the cards scroll. */
#card-group-modal-grid {
  max-height: 60vh;
  overflow-y: auto;
  padding-right: 4px;
}

.modal-body {
  display: flex;
  flex-direction: column;
  gap: 20px;
  max-height: 65vh;
  overflow-y: auto;
  padding-right: 4px;
}

/* Wraps rather than squeezing: on a narrow window the row used to shrink each tab until labels
   broke mid-phrase ("API<br>Key") and the last one clipped off the edge. A second row is fine;
   an unreadable one isn't. */
.settings-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--space-sm);
}

.settings-tab {
  position: relative;
  flex: 0 0 auto;
  white-space: nowrap;
  background: transparent;
  border: none;
  color: var(--text-dim);
  padding: var(--space-xs) var(--space-md);
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  cursor: pointer;
  font-size: var(--font-sm);
  font-weight: 500;
  transition: color var(--transition-fast);
}

.settings-tab:hover { color: var(--text); }

.settings-tab.active { color: var(--text); }

.settings-tab.active::after {
  content: '';
  position: absolute;
  left: var(--space-xs);
  right: var(--space-xs);
  bottom: -1px;
  height: 2px;
  background: var(--accent);
  border-radius: 2px 2px 0 0;
}

.settings-panel { padding-top: var(--space-2xs); }

.settings-section {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--border);
}

.settings-section:last-child { border-bottom: none; padding-bottom: 0; }

.settings-section h3 {
  margin: 0;
  font-size: 0.9643rem;
  color: var(--text);
}

.theme-options {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.theme-option {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  width: 100%;
  text-align: left;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: var(--space-md);
  cursor: pointer;
  font-family: inherit;
  transition: border-color var(--transition-fast), background var(--transition-fast);
}

.theme-option:hover { border-color: var(--text-dim); }
.theme-option.active { border-color: var(--accent); background: rgba(var(--accent-rgb), 0.1); }

.theme-swatches { display: flex; gap: 4px; flex-shrink: 0; }

.theme-swatch {
  width: 16px;
  height: 16px;
  border-radius: var(--radius-xs);
  border: 1px solid rgba(255, 255, 255, 0.1);
}

.theme-option-name { display: block; font-size: var(--font-sm); font-weight: 600; color: var(--text); }
.theme-option-desc { display: block; font-size: var(--font-xs); color: var(--text-dim); margin-top: 2px; }

.font-preview-glyph {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-xs);
  font-size: var(--font-lg);
  color: var(--text);
}

/* Slider + typed number input for text size (see app.js's buildFontSizeOptions) -- replaces the
   old three-button small/medium/large picker with a continuous px value either control can drive. */
.font-size-controls {
  display: flex;
  align-items: center;
  gap: var(--space-md);
}

.font-size-slider {
  flex: 1;
  accent-color: var(--accent);
}

.font-size-number {
  width: 56px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: var(--space-xs) var(--space-sm);
  color: var(--text);
  font-family: inherit;
  text-align: right;
}

.font-size-number:focus-visible { outline: none; box-shadow: var(--focus-ring); border-color: var(--accent); }

.font-size-unit { color: var(--text-dim); font-size: var(--font-sm); }

.settings-subheading {
  margin: 4px 0 0;
  font-size: var(--font-sm);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--text-dim);
}

.checkbox-field {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.9286rem;
  color: var(--text-dim);
}

.checkbox-field input { width: auto; }

/* ---------- Topbar profile / logout ---------- */

.topbar-right { display: flex; align-items: center; gap: 10px; -webkit-app-region: no-drag; }

.profile-chip {
  display: inline-flex;
  align-items: center;
  font-size: 0.8929rem;
  color: var(--text-dim);
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  padding: 4px 12px;
}

.profile-chip-inner { display: flex; align-items: center; gap: 8px; }
.profile-chip-name { font-weight: 600; color: var(--text); }

/* ---------- Profile bling: avatar frame, border color/glow effects, tier badge (see
   public/bling.js) ---------- */

.avatar-frame {
  position: relative;
  flex-shrink: 0;
  border-radius: 50%;
}

.avatar-frame-inner {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  overflow: hidden;
  background: var(--bg-input);
  border: 2px solid var(--avatar-border-color, var(--mana-c));
  display: flex;
  align-items: center;
  justify-content: center;
}

.avatar-frame-img { width: 100%; height: 100%; object-fit: cover; display: block; }

.avatar-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--accent);
  color: var(--on-accent);
  font-weight: 700;
}

.avatar-prefab-icon {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgba(0, 0, 0, 0.72);
  text-shadow: 0 1px 2px rgba(255, 255, 255, 0.35);
}

/* "Soft Glow" (Playgroup+) -- a steady colored glow behind the border. */
.avatar-effect--glow .avatar-frame-inner {
  box-shadow:
    0 0 6px color-mix(in srgb, var(--avatar-border-color, var(--mana-c)) 70%, transparent),
    0 0 14px color-mix(in srgb, var(--avatar-border-color, var(--mana-c)) 40%, transparent);
}

/* "Pulsing Glow" (Commander+) -- the same glow, breathing between soft and bright. */
.avatar-effect--pulse .avatar-frame-inner {
  animation: avatar-pulse 2.4s ease-in-out infinite;
}
@keyframes avatar-pulse {
  0%, 100% {
    box-shadow:
      0 0 6px color-mix(in srgb, var(--avatar-border-color, var(--mana-c)) 55%, transparent),
      0 0 12px color-mix(in srgb, var(--avatar-border-color, var(--mana-c)) 25%, transparent);
  }
  50% {
    box-shadow:
      0 0 14px color-mix(in srgb, var(--avatar-border-color, var(--mana-c)) 90%, transparent),
      0 0 28px color-mix(in srgb, var(--avatar-border-color, var(--mana-c)) 55%, transparent);
  }
}

/* "Holo Foil" (Rules Committee) -- ignores the chosen color (see bling.js), always a spinning
   rainbow ring. Lives on a ::before pseudo-element rather than rotating .avatar-frame itself, so
   the avatar image/icon inside stays upright while only the ring spins. The mask cuts a thin band
   out of a solid circle so only the ring (not a full rainbow disc) is visible. */
.avatar-effect--holo .avatar-frame-inner { border-color: var(--bg-panel); }
.avatar-effect--holo::before {
  content: '';
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  z-index: 0;
  background: conic-gradient(from 0deg, #ff3b3b, #ff8c1a, #ffe066, #39ff6a, #2fb8ff, #b565ff, #ff3b3b);
  -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
  mask: radial-gradient(closest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
  animation: avatar-holo-spin 3s linear infinite;
}
@keyframes avatar-holo-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .avatar-effect--pulse .avatar-frame-inner, .avatar-effect--holo::before { animation: none; }
}

.tier-badge {
  position: absolute;
  right: -2px;
  bottom: -2px;
  z-index: 2;
  min-width: 14px;
  width: 42%;
  aspect-ratio: 1;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.7em;
  line-height: 1;
  border: 1px solid var(--bg-panel);
}
.tier-badge--playgroup { background: #b08d57; color: #2a1f0f; }
.tier-badge--commander { background: #c7cbd4; color: #1a1a1a; }
.tier-badge--rules_committee {
  background: linear-gradient(135deg, #ffd700, #fff2a8);
  color: #3a2c00;
  box-shadow: 0 0 6px rgba(255, 215, 0, 0.7);
}

.bling-preview { display: flex; justify-content: center; margin-bottom: var(--space-md); }

.bling-avatar-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
  gap: var(--space-sm);
  margin: var(--space-sm) 0 var(--space-lg);
}

.bling-avatar-option {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  border: 2px solid var(--border);
  padding: 0;
  overflow: hidden;
  cursor: pointer;
  background: none;
}
.bling-avatar-option:hover { border-color: var(--accent); }
.bling-avatar-option .avatar-prefab-icon { font-size: 1.2rem; }

.bling-color-row { display: flex; gap: var(--space-sm); flex-wrap: wrap; margin: var(--space-sm) 0; }

.bling-color-swatch {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 2px solid var(--border);
  cursor: pointer;
  padding: 0;
}
.bling-color-swatch.active { box-shadow: 0 0 0 2px var(--bg-panel), 0 0 0 4px var(--accent); }

.bling-effect-options { display: flex; flex-direction: column; gap: var(--space-sm); margin: var(--space-sm) 0; }
.bling-effect-option:disabled { opacity: 0.5; cursor: not-allowed; }

.friend-name-row { display: flex; align-items: center; gap: var(--space-sm); margin-right: auto; }

/* ---------- Presence (see presence.js) ---------- */

.friend-avatar-wrap { position: relative; display: inline-flex; flex-shrink: 0; }

/* Initial-letter avatar, used when there's no bling avatar to draw (see gameLobby.js's
   seatAvatarNode). Mostly a desktop path -- bling.js is web-only -- but the markup comes from a
   SHARED file, so the class has to be styled on both platforms or one of them renders an unstyled
   blob. Mirrors desktop-app/renderer/styles.css's copy. */
.friend-avatar-fallback {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  color: #fff;
  font-weight: 700;
  flex-shrink: 0;
}

/* ---------- Commander on a lobby seat (see gameLobby.js's commanderNode) ----------
   Which commander a seat brought, with its colour identity as pips right next to the name -- both
   from a SHARED file, so this block is mirrored in desktop-app/renderer/styles.css. */
.lobby-commander {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2xs);
  min-width: 0;
}
.lobby-commander-name {
  color: var(--text-dim);
  font-size: 0.8571rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.lobby-commander-colors { display: inline-flex; gap: 2px; flex-shrink: 0; }

/* Mana pips. Colours match the deck charts' own colour-identity palette (see chart.js's
   PIE_COLORS) so one commander reads as the same colours everywhere in the app. The letter stays
   in the pip rather than relying on colour alone -- W and C are near-indistinguishable at 14px for
   anyone who doesn't already know the convention, and colour-blind readers get nothing from the
   fill at all. */
.mana-pip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  font-size: 9px;
  font-weight: 700;
  line-height: 1;
  border: 1px solid rgba(0, 0, 0, 0.35);
}
.mana-pip--w { background: #f8f6d8; color: #2b2a22; }
.mana-pip--u { background: #4f8fd8; color: #06182b; }
.mana-pip--b { background: #3a3a3f; color: #d9d9de; }
.mana-pip--r { background: #d8503a; color: #2b0b06; }
.mana-pip--g { background: #4f9a5a; color: #07200d; }
.mana-pip--c { background: #9aa1ae; color: #1b1f26; }

.status-dot {
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  border: 2px solid var(--bg-input); /* "cutout" ring against whatever it's sitting on */
  box-sizing: border-box;
}
/* Own status toggle/picker rows aren't sitting on an avatar -- no cutout ring needed there, just
   a plain dot inline with the label text (see .presence-own-toggle/.presence-option below). */
.presence-own-toggle .status-dot,
.presence-option .status-dot {
  position: static;
  border: none;
  width: 9px;
  height: 9px;
}

.status-dot--online { background: var(--status-online); }
.status-dot--away { background: var(--status-away); }
.status-dot--dnd { background: var(--status-dnd); }
.status-dot--offline { background: var(--status-offline); }

.presence-own-wrap { position: relative; }

.presence-own-toggle { display: inline-flex; align-items: center; gap: 6px; }

.presence-picker {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  width: 200px;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-modal);
  z-index: 150;
}

.presence-option {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  background: none;
  border: none;
  border-radius: var(--radius-sm);
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.presence-option:hover { background: var(--bg-input); }
.presence-option.active { background: color-mix(in srgb, var(--accent) 18%, transparent); font-weight: 600; }

/* ---------- Companion mascot (see companion.js) -- a small animated cat/dog/owl that paces in
   its own strip of space in the topbar, between the title and the profile chip. Same class-based
   convention as the loading mascot scene above (only one companion instance ever exists, but
   consistency matters more than the saved characters). ---------- */

.companion-slot { width: 130px; height: 32px; overflow: visible; position: relative; flex-shrink: 0; }

.companion-svg {
  width: 34px; height: 34px; overflow: visible; opacity: 0;
  position: absolute; left: 0; top: 50%; margin-top: -17px; /* vertically centered in the slot --
    absolutely-positioned children ignore .companion-slot's own layout, so this can't rely on
    flex/grid alignment there */
  transform: translateX(0) scale(0.4) translateY(6px);
}
.companion-svg.enter { animation: companion-enter 420ms cubic-bezier(.3,1.6,.4,1) forwards; }
.companion-svg.exit { animation: companion-exit 260ms ease-in forwards; }
@keyframes companion-enter { to { opacity: 1; transform: translateX(0) scale(1) translateY(0); } }
@keyframes companion-exit { to { opacity: 0; transform: translateX(0) scale(0.5) translateY(6px); } }

/* Pacing: ping-pongs across the slot's width once settled in. Runs on the outer <svg> (position)
   while the species-specific idle animations below run independently on inner elements (tail,
   ears, etc.), so they layer without fighting over `transform`.

   opacity:1 is declared here explicitly, not just left to companion-enter's forwards fill --
   .enter is removed once .pacing takes over, and since this keyframe only touches `transform`,
   omitting it here would mean the element falls back to its base (opacity:0) declaration the
   instant pacing starts (see the identical note on .mascot-speech-bubble's sibling bug class
   above -- same root cause: animation-fill-mode: forwards only holds what its own keyframes
   touch). */
.companion-svg.pacing {
  opacity: 1;
  animation: companion-pace 5.2s ease-in-out infinite;
}
@keyframes companion-pace {
  0%, 100% { transform: translateX(0) scaleX(1); }
  48% { transform: translateX(96px) scaleX(1); }
  50% { transform: translateX(96px) scaleX(-1); }
  98% { transform: translateX(0) scaleX(-1); }
}
.companion-svg .companion-foot-l, .companion-svg .companion-foot-r { transform-origin: top center; }
.companion-svg.pacing .companion-foot-l { animation: companion-pace-step-l 0.32s ease-in-out infinite; }
.companion-svg.pacing .companion-foot-r { animation: companion-pace-step-r 0.32s ease-in-out infinite; }
@keyframes companion-pace-step-l { 0%, 100% { transform: rotate(22deg); } 50% { transform: rotate(-18deg); } }
@keyframes companion-pace-step-r { 0%, 100% { transform: rotate(-18deg); } 50% { transform: rotate(22deg); } }

.companion-svg .companion-idle-blink { animation: mascot-blink 3.2s ease-in-out infinite; transform-origin: center; }

.companion-svg .companion-idle-cat-tail { animation: companion-cat-tail 1.6s ease-in-out infinite; transform-origin: 20px 26px; }
@keyframes companion-cat-tail { 0%, 100% { transform: rotate(-8deg); } 50% { transform: rotate(10deg); } }

.companion-svg .companion-idle-dog-tail { animation: companion-dog-tail 0.35s ease-in-out infinite; transform-origin: 26px 22px; }
@keyframes companion-dog-tail { 0%, 100% { transform: rotate(-18deg); } 50% { transform: rotate(18deg); } }
.companion-svg .companion-idle-dog-bob { animation: companion-dog-bob 1.1s ease-in-out infinite; }
@keyframes companion-dog-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }

.companion-svg .companion-idle-owl-tilt { animation: companion-owl-tilt 2.4s ease-in-out infinite; transform-origin: 17px 20px; }
@keyframes companion-owl-tilt { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }

/* Essence-active: toggled via window.Companion.setEssenceActive() (see companion.js) while a
   rich automaton loading scene is running somewhere in the app -- ties the topbar companion into
   the same "drawing magical essence" idea as the shelf/cabinet wisps in loadingUI.js, without any
   cross-DOM positioning between the two (see loadingUI.js's richInstanceCount comment). A soft
   pulsing glow behind the critter, not a new shape, so it reads at 34x34 without redesigning the
   sprite itself. */
.companion-slot.essence-active .companion-svg.pacing {
  filter: drop-shadow(0 0 4px var(--accent-hover)) drop-shadow(0 0 8px var(--ok));
  animation-name: companion-pace, companion-essence-glow;
  animation-duration: 5.2s, 1.6s;
  animation-timing-function: ease-in-out, ease-in-out;
  animation-iteration-count: infinite, infinite;
}
@keyframes companion-essence-glow {
  0%, 100% { filter: drop-shadow(0 0 3px var(--accent-hover)) drop-shadow(0 0 6px var(--ok)); }
  50% { filter: drop-shadow(0 0 6px var(--accent-hover)) drop-shadow(0 0 12px var(--ok)); }
}

/* ---------- Auth screen ---------- */

.auth-screen {
  position: fixed;
  inset: 0;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 200;
}

.auth-card {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 28px;
  width: 400px;
  max-width: 92vw;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.auth-title {
  font-weight: 700;
  font-size: 1.2143rem;
  display: flex;
  align-items: center;
  gap: 8px;
  justify-content: center;
  margin-bottom: 4px;
}

.auth-view { display: flex; flex-direction: column; gap: 14px; }

.auth-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }

.profile-list { display: flex; flex-direction: column; gap: 8px; }

.profile-btn {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  color: var(--text);
  border-radius: var(--radius-sm);
  padding: 10px 14px;
  cursor: pointer;
  font-size: 0.9643rem;
  text-align: left;
}

.profile-btn:hover { border-color: var(--accent); }

.profile-btn .twofa-tag {
  font-size: 0.7857rem;
  color: var(--text-dim);
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  padding: 2px 8px;
}

/* ---------- My Decks tab ---------- */

.decks-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 14px;
}

/* Groups multiple action buttons inside a .decks-toolbar into one tight cluster (small gap, wraps
   on narrow screens) instead of letting the toolbar's own space-between spread each button apart
   individually -- used wherever a toolbar has a heading plus more than one button (e.g. the Cube
   detail view's Start Draft/Rename/Delete row). */
.toolbar-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

/* --- List/Grid/Card view toggle (viewToggle.js) --------------------------------------------- */

.view-toggle {
  display: flex;
  gap: 4px;
  margin-bottom: 10px;
}

/* My Collection, opened-collection view (index.html's #collection-detail): stretch the card list
   all the way to the bottom of the window instead of capping it at a fixed height and leaving the
   rest of the viewport as dead space below it. Each level below #tab-collection has to opt into
   flex (rather than percentage heights) because .content is the only ancestor with a height that's
   actually definite -- flex:1 propagates that down without every intermediate div needing its own
   explicit height. .two-col's own align-items: start (needed elsewhere so its two columns don't
   stretch to match each other) is overridden back to stretch here so .panel.results actually fills
   the row instead of shrinking to its content height. */
#tab-collection.active {
  display: flex;
  flex-direction: column;
  height: 100%;
}
#collection-detail:not(.hidden) {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
#collection-detail .two-col {
  flex: 1;
  min-height: 0;
  align-items: stretch;
}
#collection-detail .panel.results {
  display: flex;
  flex-direction: column;
  min-height: 0;
}
#collection-detail .collection-list {
  flex: 1;
  min-height: 0;
  max-height: none;
}

/* My Collection's per-collection toolbar (index.html's #collection-detail) -- sits the List/Grid/
   Card view toggle and the color-identity filter (reuses .mana-toggle-row, same WUBRG(+C) pips as
   the Build tab's own color filter) on one tight row, color toggles pushed to the right via
   space-between. Both children keep their own margin-bottom: 0 here (each carries one on its own
   elsewhere -- .view-toggle above, .mana-toggle-row nowhere but inherits field spacing) so the row
   itself owns the single margin below it instead of doubling up unevenly between two children of
   different heights (36px round pips vs. the shorter text pills). align-items: center keeps both
   rows' buttons vertically centered against each other despite that height difference. */
.collection-view-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 10px;
}
.collection-view-toolbar .view-toggle,
.collection-view-toolbar .mana-toggle-row {
  margin-bottom: 0;
}

.view-toggle-btn {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  color: var(--text-dim);
  border-radius: var(--radius-sm);
  padding: 5px 12px;
  font-size: 0.8571rem;
  cursor: pointer;
}

.view-toggle-btn.active {
  background: var(--accent);
  color: var(--bg);
  border-color: var(--accent);
}

/* --- My Decks tab: three view modes ---------------------------------------------------------- */

.decks-grid.view-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.decks-grid.view-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 14px;
}

.decks-grid.view-card {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 14px;
}

.deck-card,
.deck-tile {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.deck-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 8px 12px;
}

.deck-row .deck-name { flex: 0 0 auto; min-width: 140px; }
.deck-row .deck-meta { flex: 1; }
.deck-row .deck-actions { margin-top: 0; flex: 0 0 auto; }

.deck-card .deck-name,
.deck-tile .deck-name,
.deck-row .deck-name { font-weight: 600; font-size: 1rem; }

.deck-card .deck-meta,
.deck-tile .deck-meta,
.deck-row .deck-meta { font-size: 0.8571rem; color: var(--text-dim); line-height: 1.5; }

.deck-card .deck-actions,
.deck-tile .deck-actions,
.deck-row .deck-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }

.deck-card .deck-actions .btn,
.deck-tile .deck-actions .btn,
.deck-row .deck-actions .btn { padding: 6px 10px; font-size: 0.8571rem; }

.deck-tile-art {
  width: 100%;
  aspect-ratio: 63 / 88;
  overflow: hidden;
  border-radius: var(--radius-sm);
}

.deck-tile-art img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-sm);
}

.deck-tile-art .missing-img { width: 100%; height: 100%; }

/* Read-only deck detail ("View") -- see decks.js's renderDeckDetail. Shown in place of the deck
   list within the same My Decks tab, never inside the Build/Edit tab (that's still "Edit This
   Deck"'s job). */
.deck-detail-back { margin-bottom: var(--space-md); }

.deck-detail-header {
  display: flex;
  gap: var(--space-md);
  margin-bottom: var(--space-md);
}

.deck-detail-art {
  width: 140px;
  flex-shrink: 0;
  aspect-ratio: 63 / 88;
  overflow: hidden;
  border-radius: var(--radius-sm);
}
.deck-detail-art img { width: 100%; height: 100%; object-fit: cover; border-radius: var(--radius-sm); }

.deck-detail-info { display: flex; flex-direction: column; gap: 8px; min-width: 0; }

.deck-detail-name {
  font-size: 1.2857rem;
  font-weight: 700;
  cursor: pointer;
}
.deck-detail-name:hover { text-decoration: underline dotted; }

/* Edit This Deck / View Analysis / Add a card / Delete -- previously plain full-size .btn
   elements with no gap rule at all (relying on whatever incidental inline spacing the browser
   happened to give adjacent buttons), which read as cramped next to each other. Sized down a
   step from the default .btn (not all the way to the deck-list rows' compact size above -- these
   are the primary actions for whichever single deck is open, so they stay a bit more prominent
   than a list row's inline buttons) and given real breathing room between them. */
#deck-detail .deck-actions {
  margin-bottom: var(--space-lg);
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

#deck-detail .deck-actions .btn {
  padding: 7px 14px;
  font-size: 0.8929rem;
}
#deck-detail .deck-analysis-sections { margin-bottom: var(--space-lg); }

.badge-warn-text { color: var(--error) !important; }

/* --- My Collection tab ---------------------------------------------------------------------- */

/* Detail view's own toolbar (see index.html) -- wraps to two lines on a narrow viewport instead of
   .decks-toolbar's plain single-row space-between, since the stats bar below can now carry more
   than one line of its own (title + a row of stat pills). */
.collection-detail-toolbar {
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 12px;
}

.collection-detail-actions {
  display: flex;
  gap: 8px;
  flex-shrink: 0;
}

.collection-stats-bar {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 0;
}

/* One labeled pill per stat (see collection.js's buildStatPill) -- same label+value shape as
   chart.js's .market-total-badge ("The Cards" section's own per-source price totals), reused here
   so a collection's headline numbers read as part of the same visual system. */
.collection-stats-pills {
  display: flex;
  gap: var(--space-sm);
  flex-wrap: wrap;
}

.collection-stat-pill {
  display: flex;
  align-items: baseline;
  gap: 6px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 5px 10px;
  font-size: var(--font-xs);
}

.collection-stat-pill-label { color: var(--text-dim); }
.collection-stat-pill-value { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }

/* Total Value gets its own neon-green glowing border rather than blending in with the other
   pills -- it's the number someone opening their collection cares about most. Reuses
   game-changer-glow-pulse (see the Game Changers section above) rather than a third near-
   identical keyframe block, and --neon-green rather than the themed --neon for the same reason
   Game Changers/the Spicy-Fluffy score do: a fixed, unmistakably "money" green regardless of theme. */
.collection-stat-pill--value {
  border: 2px solid var(--neon-green);
  animation: game-changer-glow-pulse 2.2s ease-in-out infinite;
}
.collection-stat-pill--value .collection-stat-pill-value {
  color: var(--neon-green);
  text-shadow: 0 0 6px color-mix(in srgb, var(--neon-green) 70%, transparent);
}

.collection-stat-pill--warn { border-color: var(--warn); }
.collection-stat-pill--warn .collection-stat-pill-value { color: var(--warn); }

.collection-list {
  max-height: 520px;
  overflow-y: auto;
}

.collection-list.view-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.collection-list.view-grid,
.card-price-list.view-grid,
.card-role-group.view-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 8px;
}

.collection-list.view-card,
.card-price-list.view-card,
.card-role-group.view-card {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 10px;
}

.collection-row {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 10px 12px;
}

.collection-row-name {
  flex: 1;
  /* Flex items default to `min-width: auto`, which for `white-space: nowrap` text resolves to the
     *full* unwrapped text width, not 0 -- without this override, a long card name can't actually
     shrink below its own full width to make room for the price/qty/Remove columns that come after
     it, which forces the row to either overflow or crowd/hide those later columns depending on the
     browser. This is what actually lets `flex: 1` + the ellipsis truncation below do their job. */
  min-width: 0;
  font-size: 0.9286rem;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.collection-row-price {
  font-size: 0.8571rem;
  color: var(--text-dim);
  white-space: nowrap;
}

.collection-row-qty {
  /* `flex: 0 0 60px` (not just `width: 60px`) so this can never get stretched wide by leftover
     flex space -- belt-and-suspenders alongside collection-row-name's min-width fix above, so the
     qty box stays a fixed 60px regardless of how the row's other columns end up sized. */
  flex: 0 0 60px;
  width: 60px;
  text-align: center;
}

.collection-row .btn { padding: 5px 10px; font-size: 0.8571rem; }

/* --- Deck card-prices list (chart.js) -------------------------------------------------------- */

.card-price-list.view-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 520px;
  overflow-y: auto;
}

/* View / Group By dropdowns (see chart.js's buildFilterDropdown/buildGroupToggle) -- independent
   axes, so both stay visible and usable together. */
.card-view-controls {
  display: flex;
  align-items: flex-start;
  gap: 20px;
  flex-wrap: wrap;
}

/* Pushes the Sort dropdown (and the Price Tier/Treatment dropdowns after it, if present) to the
   right side of .card-view-controls, separating "how these cards are sorted/priced" from the
   View/Group By dropdowns on the left rather than having every control run together in one row. */
.price-sort-wrap {
  margin-left: auto;
}

/* --- Filter dropdowns shared by View/Group By/Sort (chart.js) and Price Tier/Treatment
   (priceFilters.js) -- a header label above a native <select>, so every filter in "The Cards"
   controls row reads as its own clearly-named dropdown instead of an unlabeled row of buttons. */
.filter-dropdown-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.filter-dropdown-label {
  font-size: 0.7857rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim);
}

.filter-dropdown {
  width: auto;
  min-width: 140px;
  padding: 5px 10px;
  font-size: 0.8571rem;
  cursor: pointer;
}

.filter-dropdown:disabled {
  cursor: not-allowed;
}

/* --- Price Tier / Treatment dropdowns (priceFilters.js) ---------------------------------------
   Their own bordered/padded wrapper (rather than sitting bare in .card-view-controls the way the
   View/Group By/Sort dropdowns do) so the pair reads as one distinct "these two controls belong
   together" unit -- they jointly control how the whole decklist gets repriced, not just how it's
   displayed. */
.price-filter-bar {
  display: flex;
  align-items: flex-start;
  gap: 22px;
  flex-wrap: wrap;
  padding: 10px 16px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.price-filter-bar--busy {
  opacity: 0.6;
}

/* "By role" grouping (chart.js's renderGrouped) -- the outer list gets its own single scroll
   region (unlike the flat view-list above, each per-role .card-role-group below is NOT
   independently scrollable -- see its own rule) so a long grouped decklist doesn't grow the whole
   result forever, without nesting a scrollbar inside a scrollbar per role. */
.card-price-list.card-price-list--grouped {
  max-height: 640px;
  overflow-y: auto;
}

.card-role-heading {
  font-size: 0.8571rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--text-dim);
  margin: 14px 0 6px;
}

.card-role-heading:first-child { margin-top: 0; }

/* Grouped rendering (renderCardGroups in cube.js/draft.js/chart.js) appends each heading and its
   .card-role-group wrapper as a direct child of the card list -- fine for the flex-column
   .view-list layout, but in .view-grid/.view-card the list itself is a CSS grid, so without this
   the heading and group divs get placed as individual grid cells among the card tiles instead of
   each starting its own full-width row. Spanning every column forces the intended "heading, then
   that group's own tile grid, then the next heading" stacked layout. */
.card-price-list.view-grid > .card-role-heading,
.card-price-list.view-card > .card-role-heading,
.collection-list.view-grid > .card-role-heading,
.collection-list.view-card > .card-role-heading,
.card-price-list.view-grid > .card-role-group,
.card-price-list.view-card > .card-role-group,
.collection-list.view-grid > .card-role-group,
.collection-list.view-card > .card-role-group {
  grid-column: 1 / -1;
}

.card-role-group.view-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 4px;
}

/* ---------- Mana curve / color chart ---------- */

.chart-block {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* Bars + color-identity pie chart side by side (see chart.js's buildManaCurve/buildManaCurvePie)
   -- flex-wrap so the pie drops to its own line under the bars on a narrow sidebar instead of
   squeezing both into an unreadable sliver. */
.mana-curve-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-lg);
}

.mana-curve-bars {
  display: flex;
  align-items: flex-end;
  gap: 6px;
  height: 110px;
  padding: 4px 0;
  flex: 1;
  min-width: 180px;
}

/* To the right of the "7+" bar, vertically centered against the bars above it. Fixed size (not
   flex: 1) so .mana-curve-bars above always claims the rest of the row's width evenly regardless
   of sidebar width. */
.mana-curve-pie-wrap {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  flex-shrink: 0;
  opacity: 0;
  transform: scale(0.85);
  transition: opacity 0.4s ease, transform 0.4s var(--ease);
}
.mana-curve-pie-wrap--in { opacity: 1; transform: scale(1); }

/* Ring and pie (see chart.js's buildManaCurvePie) are stacked here via position: relative/absolute
   rather than nested -- the ring's own continuous rotate animation below would otherwise carry the
   pie's wedges around with it, which breaks the click handler's angle math (it assumes the pie's
   own unrotated bounding box). 96px so the 84px ring is centered with a 6px gap all round for the
   84px pie in front of it. */
.mana-curve-pie-stack {
  position: relative;
  width: 96px;
  height: 96px;
  flex-shrink: 0;
}

/* Proportional color "border" -- a smoothly blended conic-gradient of just the WUBRG colors this
   deck actually runs (never Colorless -- see buildColorRingGradient), continuously rotating with a
   breathing neon glow color-matched to that same blend (--pie-glow-color, set inline per-deck by
   chart.js). Sits behind the pie (lower in DOM + no z-index needed since the pie is positioned
   after it and both are absolute) so only the outer ring shows past the pie's own edge. */
.mana-curve-pie-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  box-shadow: 0 0 0 1px var(--border);
  animation: mana-curve-ring-spin 12s linear infinite, mana-curve-ring-pulse 2.6s ease-in-out infinite;
}
.mana-curve-pie-ring--empty {
  background: var(--border);
  animation: none;
}
@keyframes mana-curve-ring-spin {
  to { transform: rotate(360deg); }
}
@keyframes mana-curve-ring-pulse {
  0%, 100% {
    box-shadow:
      0 0 0 1px var(--border),
      0 0 6px 1px var(--pie-glow-color, var(--neon)),
      0 0 14px 2px color-mix(in srgb, var(--pie-glow-color, var(--neon)) 55%, transparent);
  }
  50% {
    box-shadow:
      0 0 0 1px var(--border),
      0 0 14px 3px var(--pie-glow-color, var(--neon)),
      0 0 30px 6px color-mix(in srgb, var(--pie-glow-color, var(--neon)) 70%, transparent);
  }
}

.mana-curve-pie {
  position: absolute;
  top: 6px;
  left: 6px;
  width: 84px;
  height: 84px;
  border-radius: 50%;
  box-shadow: 0 0 0 1px var(--border);
  /* Neon pulse on the wedges themselves -- saturation/brightness breathing plus a soft white
     bloom, independent of the ring's own color-matched glow above, so the pie reads as "alive"
     even for a mono-colored deck where the ring's blend is just a single flat color. */
  animation: mana-curve-pie-neon-pulse 2.6s ease-in-out infinite;
  transition: transform 0.2s var(--ease);
}
.mana-curve-pie--clickable { cursor: pointer; }
.mana-curve-pie--clickable:hover,
.mana-curve-pie--clickable:focus-visible {
  transform: scale(1.06);
}
@keyframes mana-curve-pie-neon-pulse {
  0%, 100% { filter: saturate(1.1) brightness(1) drop-shadow(0 0 3px rgba(255, 255, 255, 0.12)); }
  50% { filter: saturate(1.5) brightness(1.12) drop-shadow(0 0 9px rgba(255, 255, 255, 0.3)); }
}

.mana-curve-pie-legend {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 0.75rem;
  color: var(--text-dim);
  white-space: nowrap;
}

/* A real <button> (see chart.js) -- reset to look like the plain row it replaced, then a hover/
   focus treatment so it reads as clickable (this is the keyboard-accessible route to the same
   "see these cards" grid the pie wedges open on click). */
.mana-curve-pie-legend-row {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  background: none;
  border: none;
  margin: -2px -4px;
  padding: 2px 4px;
  border-radius: 4px;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color 0.15s ease, transform 0.15s ease;
}
.mana-curve-pie-legend-row:hover,
.mana-curve-pie-legend-row:focus-visible {
  background: color-mix(in srgb, var(--accent) 14%, transparent);
  transform: translateX(2px);
}
.mana-curve-pie-legend-row:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.mana-curve-pie-legend-row:disabled {
  cursor: default;
}
.mana-curve-pie-legend-row:disabled:hover {
  background: none;
  transform: none;
}

.mana-curve-pie-swatch {
  width: 9px;
  height: 9px;
  border-radius: 2px;
  flex-shrink: 0;
}

/* A real <button>, not a div (see chart.js's buildManaCurve) -- reset to keep the exact same
   layout it had as a plain column, then click opens a grid of that CMC's own cards. */
.mana-curve-col {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  height: 100%;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.mana-curve-col:disabled {
  cursor: default;
}
.mana-curve-col:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 3px;
}

.mana-curve-bar {
  width: 100%;
  background: var(--accent);
  border-radius: 3px 3px 0 0;
  min-height: 2px;
  /* Grow-in from the bottom on mount (see chart.js's buildManaCurve) -- height is already set to
     its final pixel value inline, so this only animates the scale, not a height/layout change. */
  transform: scaleY(0);
  transform-origin: bottom;
  transition: transform 0.5s var(--ease), filter 0.15s ease, box-shadow 0.15s ease;
}
.mana-curve-col:not(:disabled):hover .mana-curve-bar,
.mana-curve-col:focus-visible .mana-curve-bar {
  filter: brightness(1.3);
  box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 70%, transparent);
}

.mana-curve-bar--in { transform: scaleY(1); }

.mana-curve-count { font-size: 0.75rem; color: var(--text-dim); }
.mana-curve-label { font-size: 0.75rem; color: var(--text-dim); }

.swatch.W { background: #f8f6d8; }
.swatch.U { background: #4f8fd8; }
.swatch.B { background: #3a3a3f; }
.swatch.R { background: #d8503a; }
.swatch.G { background: #4f9a5a; }
.swatch.C { background: #9aa1ae; }

/* Game Changers trigger's slot in "The Cards" controls row (see chart.js's buildCardPricesSection)
   -- margin: 0 auto shares the row's own free space equally on both sides, landing it roughly
   centered between the grouping toggles and the price-sort button rather than against either.
   Used to hold a compact color-identity bar here (replaced by the mana-curve pie chart -- see
   .mana-curve-pie-wrap) before the Game Changers trigger moved into this same spot. */
.game-changers-wrap {
  margin: 0 auto;
  align-self: center;
}

/* ---------- Card image grid ---------- */

.card-image-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
  gap: 8px;
  /* Grid items stretch to their row's tallest tile by default -- a card with a wrapped name or an
     extra badge (New/synergy/combo) made every shorter neighbor in that row pad out with blank
     space at the bottom instead of sitting flush above the `gap`, so rows read as unevenly spaced
     even though the gap itself never changed. align-items: start keeps each tile exactly as tall
     as its own content. */
  align-items: start;
}

.card-image-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  text-align: center;
}

/* Holds the name/price stack in a Card View list row so it can take the flex-grow that used to
   live directly on .collection-row-name -- see chart.js's buildCardPriceRow, which now wraps that
   pair together so a remove button can sit alongside them as its own flex sibling. `gap` gives the
   name and price line some breathing room instead of sitting flush against each other. */
.card-price-row-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }

/* Deck-wide "overall price from each site" totals (see chart.js's buildMarketTotalsRow) -- plain
   informational badges, not links (see that function's own comment on why a whole-deck link isn't
   offered the way a per-card one is). */
.market-totals-row {
  display: flex;
  gap: var(--space-sm);
  flex-wrap: wrap;
  margin: 4px 0 8px;
}

.market-total-badge {
  display: flex;
  align-items: baseline;
  gap: 6px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 5px 10px;
  font-size: var(--font-xs);
}

.market-total-label { color: var(--text-dim); }
.market-total-value { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }

/* Per-card "view this printing on X" links (see chart.js's buildMarketLinksRow) -- small pill
   links, only rendered for whichever sources actually have a URL for that exact printing. */
/* Per-card "view this printing on X" buttons, right above the qty stepper (see chart.js's
   buildMarketLinksRow) -- always exactly three fixed-size .market-link buttons, one bold letter
   each (T/C/M -- see the button's own title/aria-label for the full site name), never fewer, so
   every card's row lands at the same width/x-position as every other card's regardless of which
   sites actually stock that particular printing (a source with no link for this card still gets
   its button, just dimmed -- see .market-link-unavailable below). Fixed pixel size rather than
   flex:1 text -- a full site name overflowed and overlapped badly at grid/card-mode tile widths;
   a single letter in its own small round button never has that problem at any width. */
.market-links-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

.market-link {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--font-2xs);
  font-weight: 700;
  color: var(--accent);
  text-decoration: none;
  border: 1px solid var(--accent-dim);
  border-radius: 50%;
  background: var(--bg-input);
  transition: background-color var(--transition-fast), color var(--transition-fast);
}

.market-link:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); }

/* This card's printing isn't stocked at that source (see attachMarketPrices) -- the button still
   renders (see buildMarketLinksRow's own comment on why) so the row's width/spacing never shifts,
   just dimmed and inert rather than styled like a live link. */
.market-link-unavailable {
  color: var(--text-dim);
  border-color: var(--border);
  opacity: 0.5;
  cursor: default;
  pointer-events: none;
}
.market-link-unavailable:hover { background: var(--bg-input); }

/* Card View rows/tiles double as the "pick a specific printing" trigger (see chart.js's
   attachPrintingPickerClick), same treatment reused by My Decks (which renders through chart.js
   too) and My Collection's own row/tile markup (see collection.js's attachPrintingClick) --
   cursor + a faint hover tint is the only visual cue (no visible button), so a subtle affordance
   matters here to signal the whole card area is clickable. */
.card-printing-clickable { cursor: pointer; border-radius: var(--radius-sm); transition: background-color var(--transition-fast); }
.card-price-row-info.card-printing-clickable:hover,
.collection-row-name.card-printing-clickable:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.card-image-tile.card-printing-clickable:hover { outline: 1px solid var(--accent-dim); outline-offset: 2px; }

/* +/- quantity stepper -- see chart.js's buildQtyStepper. Replaces the old single ✕ "remove"
   button; '-' at qty 1 is still a full removal (see chart.js's handleBump), just reachable via
   the same control instead of a dedicated button. */
.card-qty-stepper {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  flex-shrink: 0;
}

/* Grid/Card tiles: last child in the tile's own flex column (see .card-image-tile), which is what
   actually puts this at the bottom-center of the card -- no absolute-overlay positioning needed. */
.card-qty-stepper-tile { margin-top: 2px; }

.card-qty-btn {
  padding: 2px 8px !important;
  font-size: 0.9286rem !important;
  line-height: 1;
  color: var(--text-dim);
  min-width: 26px;
}

.card-qty-btn:hover { color: var(--text); }
/* Only the '-' button, and only once it's one click away from actually removing the card (qty 1)
   -- see chart.js toggling this class in buildQtyStepper/updateCardQtyDisplay. Declared after the
   plain :hover above so it wins on specificity tie. */
.card-qty-btn--danger:hover { color: var(--danger); }

.card-qty-value {
  min-width: 1.4em;
  text-align: center;
  font-size: 0.8571rem;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/* Small fixed-position toast offering to undo a just-removed card -- see chart.js's
   showUndoToast/dismissUndoToast. */
.undo-toast {
  position: fixed;
  left: 50%;
  bottom: 24px;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 10px 14px;
  box-shadow: var(--shadow-modal);
  z-index: 500;
  animation: undo-toast-in var(--transition-base);
}

@keyframes undo-toast-in {
  from { opacity: 0; transform: translateX(-50%) translateY(8px); }
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
}

.undo-toast-message { font-size: 0.9286rem; color: var(--text); }
.undo-toast-btn { flex-shrink: 0; }

@media (prefers-reduced-motion: reduce) {
  .undo-toast { animation: none; }
}

/* Left in a removed card's exact grid/list slot until a replacement is picked or the section
   next fully re-renders -- see chart.js's buildReplacementPlaceholder(). */
.replacement-placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  text-align: center;
  border-style: dashed;
}

.replacement-list { display: flex; flex-direction: column; gap: 8px; max-height: 50vh; overflow-y: auto; }

.replacement-row {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 8px 10px;
}

.replacement-row > div:first-child { flex: 1; min-width: 0; }

.card-image-tile img {
  width: 100%;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
}

.card-image-tile .qty-badge {
  font-size: 0.7857rem;
  color: var(--text-dim);
}

/* Reserves 2 lines' worth of height for the name (see chart.js's buildCardPriceTile) even when it
   only wraps to 1 -- full card names are never abbreviated (a short name next to a long one that
   wraps otherwise leaves the price/market-links/stepper below it sitting a line higher than its
   row neighbors', which is what actually read as "uneven" card spacing, not the grid gap itself). */
.card-name-line {
  min-height: 3em;
}

.card-image-tile .missing-img {
  width: 100%;
  aspect-ratio: 63 / 88;
  background: var(--bg-input);
  border: 1px dashed var(--border);
  border-radius: var(--radius-sm);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.7143rem;
  color: var(--text-dim);
  padding: 4px;
}

/* ---------- Hover-to-enlarge card preview (cardPreview.js) ---------- */

/* A "hoverable card" cursor on anything cardPreview.js is attached to -- buttons keep their own
   pointer cursor (they're their own, separately clickable thing, not "the card"), so this is
   explicitly scoped off of them rather than just relying on cursor inheritance. */
.card-hover-target:not(button):not(.btn) { cursor: zoom-in; }

/* A card name linkified inside AI-written prose (see cardPreview.js's linkifyText) -- a dotted
   underline rather than a solid one deliberately reads as "hover for more," not a normal
   navigation link (clicking it does nothing; the card list/decklist elsewhere is still the real
   place to act on a card). */
.card-name-link {
  text-decoration: underline dotted;
  text-underline-offset: 2px;
  text-decoration-color: var(--text-dim);
}

.card-name-link:hover { text-decoration-color: var(--accent); }

.card-hover-preview {
  position: fixed;
  z-index: 500;
  pointer-events: none; /* never steal the hover/click that's still under the cursor */
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 8px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  max-width: 300px;
}

.card-hover-preview.hidden { display: none; }

.card-hover-preview img {
  display: block;
  width: 100%;
  border-radius: var(--radius-sm);
}

.card-hover-preview-label {
  margin-top: 6px;
  font-size: 0.8571rem;
  font-weight: 600;
  color: var(--text);
  text-align: center;
}

/* ---------- Small icon/utility buttons: precise, consistent hit areas ---------- */
/* A pass to make the small buttons that sit alongside or on top of card rows/tiles (the qty
   stepper, view toggle, collapsible chevron is its own thing above) easier to hit precisely and
   to tell, at a glance, that the cursor is actually over them -- rather than each one having
   whatever hit area its own padding happened to produce. */

.card-qty-btn {
  min-height: 26px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* ---------- Card scan modal ---------- */

.scan-video-wrap {
  position: relative;
  background: #000;
  border-radius: var(--radius-sm);
  overflow: hidden;
  min-height: 260px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.scan-video-wrap video {
  width: 100%;
  max-height: 340px;
  display: block;
}

/* Live-scan alignment guide (see scan.js) -- a card-shaped (Magic cards are ~63:88 width:height)
   neon outline centered over the feed to line the physical card up against, in the same
   --neon-green glow language as Game Changers/commander borders elsewhere in the app (see
   .game-changer-card above) rather than a one-off color. This is a fixed target zone, not a
   real edge-tracked outline -- there's no client-side computer vision here, just scan.js's own
   frame-stability check deciding when to auto-capture. `--active` (toggled by scan.js while a
   capture is actually in flight) swaps in a brighter, faster pulse so the glow itself communicates
   "reading now" vs. "waiting for you to hold steady."
   Sized by WIDTH (+ aspect-ratio deriving height) and centered via flexbox on the wrap, not by a
   percentage `height`/`top` on the guide itself -- .scan-video-wrap's height comes from its video
   child's rendered size (no explicit CSS height), and percentage height/top on an absolutely
   positioned element resolves against an ancestor's *explicit* height, so it would silently
   collapse to nothing here. `inset: 0` needs no such resolution (it just anchors all four edges to
   the already-laid-out parent box), which is what makes this wrapper reliable. */
.scan-guide-wrap {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 18px;
  box-sizing: border-box;
  pointer-events: none;
}

.scan-guide {
  width: min(56%, 100%);
  max-height: 100%;
  aspect-ratio: 63 / 88;
  border-radius: 16px;
  border: 3px solid var(--neon-green);
  animation: scan-guide-pulse 2.2s ease-in-out infinite;
}

.scan-guide--active {
  animation: scan-guide-pulse 0.7s ease-in-out infinite;
}

@keyframes scan-guide-pulse {
  0%, 100% {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--neon-green) 50%, transparent),
      0 0 10px color-mix(in srgb, var(--neon-green) 55%, transparent),
      inset 0 0 10px color-mix(in srgb, var(--neon-green) 25%, transparent);
  }
  50% {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--neon-green) 80%, transparent),
      0 0 22px color-mix(in srgb, var(--neon-green) 90%, transparent),
      inset 0 0 18px color-mix(in srgb, var(--neon-green) 45%, transparent);
  }
}

.scan-live-hint {
  text-align: center;
  min-height: 1.2em;
  margin-top: -4px;
  color: var(--neon-green);
}

.scan-result {
  display: flex;
  gap: 14px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 12px;
  align-items: flex-start;
}

.scan-result img {
  width: 100px;
  border-radius: var(--radius-sm);
  flex-shrink: 0;
}

.scan-result-img-pickable {
  cursor: pointer;
  outline-offset: 2px;
  transition: box-shadow 0.15s ease;
}

.scan-result-img-pickable:hover,
.scan-result-img-pickable:focus-visible {
  box-shadow: 0 0 0 2px var(--accent);
}

.scan-result-info { display: flex; flex-direction: column; gap: 6px; flex: 1; }

.scan-result-name { font-weight: 700; font-size: 1rem; }

.scan-result-meta { font-size: 0.8571rem; color: var(--text-dim); line-height: 1.5; }

/* A caveat on a scan result the person should read before adding the card -- currently only the
   "the artwork didn't confirm this name" case (see scan.js's renderMatch). Deliberately not styled
   like the dimmed meta line above it: the whole point is that it must not be skimmed past. */
.scan-result-note {
  font-size: 0.8571rem;
  line-height: 1.5;
  color: var(--warn);
  border-left: 2px solid currentColor;
  padding-left: 8px;
}

/* ---------- Admin tab (see public/admin.js) ---------- */

.admin-section-toolbar { margin-top: 24px; }

.admin-overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 12px;
  margin-bottom: 8px;
}

.admin-stat-tile {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.admin-stat-value { font-size: 1.5rem; font-weight: 700; }

.admin-stat-label {
  font-size: 0.8571rem;
  color: var(--text-dim);
}

.admin-table-wrap {
  overflow-x: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.admin-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.8929rem;
  white-space: nowrap;
}

.admin-table th,
.admin-table td {
  text-align: left;
  padding: 10px 12px;
  border-bottom: 1px solid var(--border);
}

.admin-table th {
  color: var(--text-dim);
  font-weight: 600;
  text-transform: uppercase;
  font-size: 0.7857rem;
  letter-spacing: 0.03em;
}

.admin-table tbody tr:last-child td { border-bottom: none; }

.admin-row-actions { display: flex; gap: 6px; }

/* ============================================================================================
   Home tab -- the landing screen shown after login (see home.js). A hero frame with Lebard the
   Librarian automaton (same character/markup as loadingUI.js's loading-scene mascot, just
   untethered from the desk and left to drift) wandering the background, and a grid of large
   neon nav buttons in front of him routing straight to each of the app's other tabs.
   ============================================================================================ */

.home-hero {
  position: relative;
  min-height: 480px;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--bg-panel);
  padding: var(--space-2xl);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2xl);
}

/* Purely decorative (aria-hidden -- see index.html) and z-index 0 with pointer-events: none, so
   Lebard drifting through this layer never intercepts a click meant for the nav grid sitting
   above him at z-index 1. */
.home-stage {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
}

.home-hero-text, .home-nav-grid {
  position: relative;
  z-index: 1;
}

.home-hero-text { text-align: center; max-width: 560px; }
.home-title { margin: 0 0 6px 0; font-size: 1.7rem; }
.home-subtitle { margin: 0; color: var(--text-dim); }

.home-nav-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: var(--space-lg);
  width: 100%;
  max-width: 920px;
}

/* Each variant just repoints --btn-glow at one of this theme's own already-contrast-checked
   accent colors (see the [data-theme] blocks up top) -- that's what keeps five differently-
   colored buttons reading as "part of this theme" instead of an arbitrary neon rainbow bolted on
   top of it. Reuses the same color-mix glow recipe as .the-cards-section/.combo-breaker-heading
   above, just parameterized through --btn-glow instead of a hardcoded var(--neon), since this
   needs a different color per button rather than one shared landmark color. */
.home-nav-btn {
  --btn-glow: var(--accent);
  position: relative;
  padding: 22px 18px;
  font-size: 1.0714rem;
  font-weight: 700;
  font-family: inherit;
  color: var(--text);
  background: color-mix(in srgb, var(--btn-glow) 12%, var(--bg-panel));
  border: 2px solid color-mix(in srgb, var(--btn-glow) 55%, var(--border));
  border-radius: var(--radius-lg);
  cursor: pointer;
  text-shadow: 0 0 6px color-mix(in srgb, var(--btn-glow) 70%, transparent);
  transition: transform var(--transition-fast), border-color var(--transition-fast);
  animation: home-btn-glow-pulse 3s ease-in-out infinite;
}

.home-nav-btn:hover, .home-nav-btn:focus-visible {
  transform: translateY(-3px);
  border-color: var(--btn-glow);
}

.home-nav-btn:focus-visible { outline: 2px solid var(--btn-glow); outline-offset: 3px; }

.home-nav-btn--1 { --btn-glow: var(--accent); }
.home-nav-btn--2 { --btn-glow: var(--ok); }
.home-nav-btn--3 { --btn-glow: var(--warn); }
.home-nav-btn--4 { --btn-glow: var(--neon); }
.home-nav-btn--5 { --btn-glow: var(--error); }
.home-nav-btn--6 { --btn-glow: var(--neon-orange); }
.home-nav-btn--7 { --btn-glow: var(--neon-green); }
/* Play's tile deliberately uses the same fixed --neon-blue as its nav tab (see .play-heading)
   rather than a theme accent, so the Play landmark reads identically in both places. */
.home-nav-btn--play { --btn-glow: var(--neon-blue); }

@keyframes home-btn-glow-pulse {
  0%, 100% {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--btn-glow) 45%, transparent),
      0 0 16px color-mix(in srgb, var(--btn-glow) 35%, transparent);
  }
  50% {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--btn-glow) 68%, transparent),
      0 0 26px color-mix(in srgb, var(--btn-glow) 55%, transparent);
  }
}

/* ---------- Lebard: positioning wrapper ----------
   Two nested elements, same attribute-vs-CSS-transform split reasoning as .automaton-pos/
   .automaton-facing above -- the outer .home-stage-figure is repositioned by home.js setting
   plain left/top (never a transform), so the inner .lebard-bob is free to run its own idle
   translateY keyframe without the two fighting over one `transform` property. */
.home-stage-figure {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 130px;
  transition: left 6.5s cubic-bezier(.4, 0, .2, 1), top 6.5s cubic-bezier(.4, 0, .2, 1);
}

.lebard-bob { animation: lebard-bob 4.2s ease-in-out infinite; }
@keyframes lebard-bob {
  0%, 100% { transform: translateY(0) rotate(-1deg); }
  50% { transform: translateY(-10px) rotate(1deg); }
}

.lebard-svg {
  width: 100%;
  height: auto;
  overflow: visible;
  display: block;
  opacity: 0.92;
  filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.35));
}

/* ---------- Lebard: the 10 mystical/alchemical props (see home.js's SCENES) ----------
   home.js swaps the single .lebard-prop-slot's contents between these every several seconds,
   picking a different one at random each time -- kept as a handful of shared, generic keyframes
   (glow pulse, rise-and-fade, rotate, twinkle) rather than one bespoke animation per prop, the
   same "reuse, don't multiply keyframes" approach as the rest of this mascot section. */
.lebard-prop { animation: lebard-prop-fade-in 0.6s ease; }
@keyframes lebard-prop-fade-in {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

.lebard-glow { animation: mascot-aura-pulse 2.8s ease-in-out infinite; }
.lebard-glow--2 { animation-delay: 0.4s; }
.lebard-glow--3 { animation-delay: 0.8s; }

.lebard-bubble { animation: mascot-spark-rise 2.2s ease-out infinite; }
.lebard-bubble--2 { animation-delay: 0.5s; }
.lebard-bubble--3 { animation-delay: 1s; }
.lebard-bubble--4 { animation-delay: 1.5s; }
.lebard-bubble--5 { animation-delay: 0.25s; }

.lebard-twinkle { animation: lebard-twinkle 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
.lebard-twinkle--2 { animation-delay: 0.5s; }
.lebard-twinkle--3 { animation-delay: 1s; }
@keyframes lebard-twinkle {
  0%, 100% { opacity: 0.35; transform: scale(0.7) rotate(0deg); }
  50% { opacity: 1; transform: scale(1.15) rotate(25deg); }
}

.lebard-sand { animation: lebard-sand-fall 2s ease-in-out infinite; transform-box: fill-box; transform-origin: top; }
@keyframes lebard-sand-fall {
  0% { transform: scaleY(1) translateY(0); opacity: 0.85; }
  90% { transform: scaleY(0.25) translateY(7px); opacity: 0.6; }
  100% { transform: scaleY(1) translateY(0); opacity: 0.85; }
}

.lebard-rune-ring, .lebard-crystal-swirl, .lebard-circle-a {
  transform-box: fill-box;
  transform-origin: center;
  animation: lebard-rotate 9s linear infinite;
}
.lebard-crystal-swirl { animation-duration: 5s; }
.lebard-circle-a { animation-duration: 6s; }
.lebard-circle-b {
  transform-box: fill-box;
  transform-origin: center;
  animation: lebard-rotate-reverse 4s linear infinite;
}
@keyframes lebard-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes lebard-rotate-reverse { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }

.lebard-wand-trail { animation: lebard-trail-flow 2.4s ease-in-out infinite; }
@keyframes lebard-trail-flow {
  0%, 100% { stroke-dashoffset: 0; opacity: 0.5; }
  50% { stroke-dashoffset: 8; opacity: 0.85; }
}

/* Baseline idle sway for the wand itself, always on whenever the wand scene is showing (separate
   from the gesture system above) so it never just sits dead still between the bigger "flourish"
   gesture's own sweeps -- see styles.css's [data-gesture="flourish"] .lebard-prop-wand rule, which
   overrides this via the extra attribute selector's higher specificity whenever that gesture is
   active. Pivots on the hand-held end (line x1="278" y1="188" above), not the tip, so the whole
   prop reads as wobbling in his grip. */
.lebard-prop-wand { animation: lebard-wand-sway 3.2s ease-in-out infinite; transform-origin: 278px 188px; }
@keyframes lebard-wand-sway {
  0%, 100% { transform: rotate(-4deg); }
  50% { transform: rotate(4deg); }
}

@media (max-width: 760px) {
  .home-hero { min-height: 380px; padding: var(--space-lg); }
  .home-title { font-size: 1.35rem; }
  .home-stage-figure { width: 96px; }
}

/* ================================================================================================
   Collapsible nav drawer (see navDrawer.js)
   ================================================================================================
   On a narrow window navDrawer.js physically moves .tabs and .topbar-right into #nav-drawer and
   puts `nav-collapsed` on <body>; widening moves them back and drops the class. Everything below
   keys off that class rather than a @media query of its own -- the JS matchMedia is then the single
   place the breakpoint is written down, so the styling and the DOM move can never disagree about
   which one is in effect. */

/* Wide screens: no hamburger, and #nav-drawer is an empty div sitting in the topbar. */
.nav-drawer-toggle { display: none; }
.nav-drawer { display: none; }

/* Text labels that only earn their space once a control is a full-width row in the drawer -- in
   the topbar the icon alone is the affordance (and there's no room for more). */
.topbar-btn-label { display: none; }

body.nav-collapsed .topbar {
  /* Positioning context for the drawer, so it can hang off the topbar's bottom edge without JS
     having to measure the header's height. */
  position: relative;
  gap: 8px;
}

/* Long title + fixed-width companion + toggle can exceed a 320px-wide phone -- let the title be
   the thing that gives, since it's the one part that's still legible truncated. */
body.nav-collapsed .topbar-title { min-width: 0; }
body.nav-collapsed .topbar-title span:last-child {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

body.nav-collapsed .nav-drawer-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  margin-left: auto;
  width: 42px;
  height: 42px;
  padding: 0;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text);
  cursor: pointer;
  transition: border-color var(--transition-fast), color var(--transition-fast);
}

body.nav-collapsed .nav-drawer-toggle:hover,
body.nav-collapsed .nav-drawer-toggle.is-open { border-color: var(--accent); color: var(--accent); }

.nav-drawer-bars {
  position: relative;
  display: block;
  width: 20px;
  height: 14px;
}

.nav-drawer-bars > span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform var(--transition-base), opacity var(--transition-fast);
}

.nav-drawer-bars > span:nth-child(1) { top: 0; }
.nav-drawer-bars > span:nth-child(2) { top: 6px; }
.nav-drawer-bars > span:nth-child(3) { top: 12px; }

/* Open: the outer bars slide onto the middle one and cross, the middle one fades out. */
.nav-drawer-toggle.is-open .nav-drawer-bars > span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-drawer-toggle.is-open .nav-drawer-bars > span:nth-child(2) { opacity: 0; }
.nav-drawer-toggle.is-open .nav-drawer-bars > span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }

body.nav-collapsed .nav-drawer {
  display: block;
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  /* Over normal page content, under .modal-overlay's 100 and .game-board-overlay's 500 -- a menu
     shouldn't be able to cover a dialog or the live game board. */
  z-index: 90;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
  box-shadow: var(--shadow-modal);
  /* The drawer lives inside .topbar, which is the desktop app's window-drag region -- .topbar's
     own rule only exempts `button`, which would leave the notification rows (divs) as dead
     drag-handles instead of clickable rows. */
  -webkit-app-region: no-drag;
  /* Closed. `visibility` is what actually takes the collapsed menu out of the tab order and the
     accessibility tree, and it's transitioned with a delay equal to the slide so it only flips to
     `hidden` once the panel has finished sliding away (0s and no delay on the way open). */
  max-height: 0;
  overflow: hidden;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-6px);
  transition: max-height var(--transition-base),
              opacity var(--transition-fast),
              transform var(--transition-base),
              visibility 0s linear 300ms;
}

body.nav-collapsed .nav-drawer.is-open {
  /* An explicit max-height (rather than `none`) is what makes the open/close animatable at all;
     the cap doubles as "never taller than the screen", with the overflow scrolling the rest. */
  max-height: min(76dvh, 620px);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  opacity: 1;
  visibility: visible;
  transform: none;
  transition: max-height var(--transition-base),
              opacity var(--transition-fast),
              transform var(--transition-base),
              visibility 0s;
}

@media (prefers-reduced-motion: reduce) {
  /* The global reduce rule near the top of this file collapses transition-*duration* only, which
     would leave the closing drawer's visibility delay intact -- and a 300ms invisible-but-present
     panel is exactly the kind of thing that eats the next tap. */
  body.nav-collapsed .nav-drawer { transition-delay: 0s; }
}

/* Both moved elements were horizontal rows; in the drawer the narrow axis is the other one, so
   they become a single stacked list of full-width rows. */
body.nav-collapsed .nav-drawer .tabs,
body.nav-collapsed .nav-drawer .topbar-right {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 2px;
  padding: 8px;
  background: transparent;
  border-bottom: none;
  overflow: visible;
}

/* Separator between the two halves of the menu -- only when the nav half is actually present. */
body.nav-collapsed .nav-drawer:not(.nav-drawer--no-tabs) .topbar-right {
  border-top: 1px solid var(--border);
}

/* The tabs' normal parent (#app-shell) is `hidden` until a profile signs in, and moving them out
   from under it would otherwise put the whole nav on the login screen. */
body.nav-collapsed .nav-drawer--no-tabs .tabs { display: none; }

body.nav-collapsed .nav-drawer .tab,
body.nav-collapsed .nav-drawer .topbar-right > .btn,
body.nav-collapsed .nav-drawer .presence-own-toggle,
body.nav-collapsed .nav-drawer .notif-bell {
  width: 100%;
  justify-content: flex-start;
  text-align: left;
  white-space: normal;
  padding: 12px 14px;
  border-radius: var(--radius-sm);
}

body.nav-collapsed .nav-drawer .presence-own-wrap,
body.nav-collapsed .nav-drawer .notif-bell-wrap { width: 100%; }

body.nav-collapsed .nav-drawer .profile-chip { justify-content: center; }

body.nav-collapsed .nav-drawer .topbar-btn-label {
  display: inline;
  /* Keeps the click target reporting the button itself as event.target -- notifications.js's
     close-on-outside-click compares `e.target !== bellBtn`, so a child element swallowing the tap
     would close the dropdown the same tap just opened. */
  pointer-events: none;
}

body.nav-collapsed .nav-drawer .notif-bell { gap: 10px; display: flex; align-items: center; }
body.nav-collapsed .nav-drawer .notif-bell-badge { top: 8px; right: auto; left: 26px; }

/* The active-tab marker is a bottom edge on a horizontal row; on a vertical list it reads as a
   left edge instead. */
body.nav-collapsed .nav-drawer .tab.active { background: var(--bg-input); }
body.nav-collapsed .nav-drawer .tab.active::after {
  left: 0;
  right: auto;
  top: 6px;
  bottom: 6px;
  width: 2px;
  height: auto;
  border-radius: 0 2px 2px 0;
}

/* Both of these normally float over the page anchored to their topbar button. Inside a scrolling
   drawer a floating panel would just be clipped by the drawer's own overflow, so they become
   in-flow blocks that push the rest of the menu down instead. */
body.nav-collapsed .nav-drawer .presence-picker,
body.nav-collapsed .nav-drawer .notif-dropdown {
  position: static;
  width: 100%;
  max-width: none;
  max-height: none;
  margin-top: 4px;
  box-shadow: none;
}

/* ---------- Mobile ---------- */
/* The desktop app's layout assumes a wide window; this is the one addition needed to make the
   same markup/CSS work on a phone screen -- everything above is otherwise shared as-is. */

@media (max-width: 760px) {
  /* `100vh` on the body (see the shared rule above) is sized to the viewport with the mobile
     browser's address bar hidden -- with the bar visible (the common case), the actual visible
     area is shorter than 100vh, so body's fixed-height + overflow:hidden shell ends up taller
     than the screen with no way to reach the bottom. `100dvh` ("dynamic viewport height") tracks
     the real visible area instead; every current mobile browser supports it, and this simply
     overrides the 100vh above in ones that do, cascade order handles browsers that don't. */
  body { height: 100dvh; }

  .strengths-weaknesses-grid { grid-template-columns: 1fr; }

  /* iOS Safari doesn't give an `overflow: auto` region inertial/touch-drag scrolling by default
     when it's nested inside an `overflow: hidden` ancestor (the body above) -- without this, a
     finger-drag inside .content silently does nothing instead of scrolling. This is the actual
     "no scrolling on mobile" bug: the layout was already correct, touch scrolling just wasn't
     enabled on the region that needed it. */
  .content, .modal-body, .collection-list, .card-price-list.view-list, .autocomplete-dropdown {
    -webkit-overflow-scrolling: touch;
  }

  .content { padding: 12px; }

  .two-col {
    display: flex;
    flex-direction: column;
    height: auto;
  }

  /* Undoes the desktop height chain (see .content > .tab-panel.active:has(> .two-col) above) for the
     same reason the two caps below are reset: stacked, the columns are meant to be their natural
     height and scroll with .content as one page, not be pinned to one screenful. Those rules are
     more specific than the plain .results/.two-col > .form resets below, so the parts of the chain
     that aren't already inert here (.two-col is display: flex on mobile, which ignores
     grid-template-rows) have to be undone at matching specificity rather than by those. */
  .content > .tab-panel.active:has(> .two-col) { height: auto; }

  .content > .tab-panel.active:has(> .two-col) > .two-col > .form,
  .content > .tab-panel.active:has(> .two-col) > .two-col > .results {
    max-height: none;
  }

  .content > .tab-panel.active:has(> .two-col) > .two-col > .results {
    align-self: auto;
    min-height: 200px;
  }

  .results {
    max-height: none;
    min-height: 200px;
  }

  .two-col > .form {
    max-height: none;
  }

  .field-row { grid-template-columns: 1fr; }

  .tabs {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    padding: 8px 10px 0 10px;
  }

  .tab { white-space: nowrap; padding: 8px 12px; }

  .topbar { padding: 10px 12px; }
  .topbar-title span:last-child { font-size: 0.9286rem; }

  .modal-wide, .modal-xwide, .auth-card { width: 100%; max-width: 100vw; }

  .modal { padding: 18px; border-radius: 0; }

  .spicy-score-modal-columns { flex-direction: column; max-height: none; }
  .spicy-score-modal-divider { width: 100%; height: 3px; margin: 8px 0; }

  .decks-grid.view-grid, .decks-grid.view-card { grid-template-columns: 1fr; }

  .file-row { flex-wrap: wrap; }

  /* Inputs/buttons a little taller for touch. */
  input[type="text"], input[type="password"], input[type="number"], select, textarea, .btn {
    font-size: 16px; /* also prevents iOS Safari's auto-zoom-on-focus below 16px */
  }

  /* A float narrow enough to still read as "an image with text wrapping around it" needs real
     width to work with -- on a phone-width column it would squeeze the Game Plan prose down to a
     couple of words per line instead. Stack the commander above the text instead, centered. */
  .game-plan-commander {
    float: none;
    width: 55%;
    max-width: 220px;
    margin: 0 auto 12px;
  }

  /* Hover has no real equivalent on a touch screen -- most mobile browsers won't fire the
     mouseenter/mousemove events cardPreview.js listens for at all, but on the rare one that
     synthesizes something close to it on tap, a large floating image stuck open over the content
     with no obvious way to dismiss it is worse than just not showing one. */
  .card-hover-preview { display: none !important; }
}

/* ================================================================================================
   Friends / Chat / Inbox / Notifications (see friends.js, inbox.js, notifications.js, ws.js)
   ================================================================================================ */

.friend-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 8px;
}

.friend-row {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 12px;
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.friend-name { font-weight: 600; }

.friend-row-actions { display: flex; gap: 6px; flex-wrap: wrap; }

/* ---------- Social dock (Friends + Chat, see friends.js) ----------
   Fixed to the bottom-right of the viewport, outside #app-shell -- persists across every tab
   instead of being buried behind a page switch, same reasoning a chat app docks its contact
   list/conversations rather than making them a page you navigate to and away from. */

.social-dock {
  position: fixed;
  right: 16px;
  bottom: 0;
  z-index: 250; /* above normal content, below .modal-overlay's 100... */
  display: flex;
  align-items: flex-end;
  gap: 10px;
  max-width: calc(100vw - 24px);
}

.chat-windows {
  display: flex;
  align-items: flex-end;
  gap: 10px;
  overflow-x: auto;
}

.chat-window {
  width: 300px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-bottom: none;
  border-radius: var(--radius) var(--radius) 0 0;
  box-shadow: var(--shadow-modal);
}

.chat-window-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 10px;
  border-bottom: 1px solid var(--border);
}

.chat-window-close {
  background: none;
  border: none;
  color: var(--text-dim);
  font-size: 1.2857rem;
  line-height: 1;
  cursor: pointer;
  padding: 0 4px;
}
.chat-window-close:hover { color: var(--text); }

.friends-dock { position: relative; }

.friends-dock-toggle { border-radius: var(--radius) var(--radius) 0 0; }

.friends-dock-panel {
  position: absolute;
  bottom: 100%;
  right: 0;
  width: 340px;
  max-width: calc(100vw - 24px);
  max-height: 70vh;
  overflow-y: auto;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius) var(--radius) 0 0;
  box-shadow: var(--shadow-modal);
}

.friends-dock-panel .panel { margin: 0; }

/* Card Rulings' live-search suggestions, opened UPWARD. The dock panel is itself a scroll box and
   the rulings field is its last child, so the dropdown's default downward direction drops the
   suggestions past the end of the panel's content, where they can only be reached by scrolling the
   whole panel further down. The panel is anchored to the bottom of the viewport, so there is always
   more room above this field than below it. */
.friends-dock-panel .autocomplete-dropdown {
  top: auto;
  bottom: 100%;
  margin-top: 0;
  margin-bottom: 4px;
}

.chat-messages {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 320px;
  overflow-y: auto;
  padding: 10px;
  background: var(--bg-input);
  margin: 0;
}

.chat-bubble {
  align-self: flex-start;
  max-width: 75%;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 8px 10px;
}

.chat-bubble--mine {
  align-self: flex-end;
  background: color-mix(in srgb, var(--accent) 22%, var(--bg-panel));
  border-color: var(--accent-dim);
}

.chat-bubble-text { white-space: pre-wrap; word-break: break-word; }

.chat-bubble-time { font-size: 0.7857rem; color: var(--text-dim); margin-top: 4px; }

.chat-form { display: flex; gap: 8px; }

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

/* ---------- Inbox ---------- */

.inbox-preview { margin: 6px 0; }

/* ---------- Notification bell (see notifications.js) ---------- */
/* Reuses --neon-orange -- same fixed, un-themed glow color as the Synergy Matrix tab/home button
   (see that section's own comment for why it's deliberately not themed per-palette). */

.notif-bell-wrap { position: relative; }

.notif-bell {
  position: relative;
  font-size: 1.1429rem;
  line-height: 1;
  padding: 8px 10px;
}

.notif-bell-badge {
  position: absolute;
  top: 2px;
  right: 2px;
  min-width: 16px;
  height: 16px;
  padding: 0 3px;
  border-radius: 999px;
  background: var(--danger);
  color: #fff;
  font-size: 0.6429rem;
  font-weight: 700;
  line-height: 16px;
  text-align: center;
}

.notif-bell.has-unread {
  color: var(--neon-orange);
  border-color: color-mix(in srgb, var(--neon-orange) 55%, var(--border));
  animation: notif-bell-glow-pulse 3s ease-in-out infinite;
}

@keyframes notif-bell-glow-pulse {
  0%, 100% {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--neon-orange) 40%, transparent),
      0 0 12px color-mix(in srgb, var(--neon-orange) 30%, transparent);
  }
  50% {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--neon-orange) 65%, transparent),
      0 0 22px color-mix(in srgb, var(--neon-orange) 55%, transparent);
  }
}

/* Brief stronger flash the moment a live push arrives (see notifications.js's flash()), layered on
   top of has-unread's slower persistent pulse -- settles back to the plain pulse a couple seconds
   later rather than looping indefinitely, so it reads as "something just happened" rather than a
   permanently more intense state. */
.notif-bell.just-arrived {
  animation: notif-bell-flash 0.6s ease-in-out 2;
}

@keyframes notif-bell-flash {
  0%, 100% {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--neon-orange) 65%, transparent),
      0 0 22px color-mix(in srgb, var(--neon-orange) 55%, transparent);
  }
  50% {
    box-shadow:
      0 0 0 2px var(--neon-orange),
      0 0 36px color-mix(in srgb, var(--neon-orange) 90%, transparent),
      0 0 60px color-mix(in srgb, var(--neon-orange) 60%, transparent);
    transform: scale(1.08);
  }
}

/* ---------- Inbox tab firefly (see notifications.js's spawnInboxFirefly) ---------- */
/* Each live notification/message spawns one of these inside the "Inbox" nav tab button
   (.tab[data-tab="inbox"], already position:relative). JS builds a one-off @keyframes per firefly
   that circles the button and fades in/out right on top of the "o" (.inbox-o span in index.html) --
   this rule just supplies the dot's look; the per-firefly <style> supplies left/top/opacity. */
.inbox-firefly {
  position: absolute;
  left: 0;
  top: 0;
  width: 5px;
  height: 5px;
  margin: 0;
  border-radius: 50%;
  background: var(--firefly-glow);
  box-shadow:
    0 0 4px 1px var(--firefly-glow),
    0 0 9px 2px color-mix(in srgb, var(--firefly-glow) 70%, transparent);
  pointer-events: none;
  transform: translate(-50%, -50%);
  animation-timing-function: ease-in-out;
  animation-fill-mode: forwards;
  z-index: 2;
}

.notif-dropdown {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  width: 340px;
  max-width: 90vw;
  max-height: 420px;
  overflow-y: auto;
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-modal);
  z-index: 150;
}

.notif-dropdown-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  border-bottom: 1px solid var(--border);
  font-weight: 600;
}

.notif-dropdown-list { display: flex; flex-direction: column; }

.notif-row {
  padding: 10px 12px;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
}

.notif-row:hover { background: var(--bg-input); }

.notif-row--unread { border-left: 3px solid var(--neon-orange); }

.notif-row-text { font-size: 0.9286rem; }

.notif-row-time { font-size: 0.7143rem; color: var(--text-dim); margin-top: 2px; }

.btn-small { padding: 4px 8px; font-size: 0.7857rem; }

@media (max-width: 760px) {
  /* A 300px chat window plus a 340px friends panel doesn't fit a phone-width viewport side by
     side -- shrink both to fill nearly the whole width instead, same "one thing at a time" trade-
     off the rest of the responsive layout already makes. */
  .social-dock { right: 8px; gap: 6px; }
  .chat-window { width: calc(100vw - 16px); }
  .friends-dock-panel { width: calc(100vw - 16px); }
}

/* ===================== Live game board (see gameSession.js) ===================== */
/* Fixed, full-viewport, above everything (including .topbar/.tabs) -- see #game-board-overlay's
   own comment in index.html for why this is a sibling of #app-shell rather than a tab panel. */
.game-board-overlay {
  position: fixed;
  inset: 0;
  z-index: 500;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  padding: var(--space-md);
  gap: var(--space-sm);
  overflow-y: auto;
}

.game-board-topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-sm);
  border-bottom: 1px solid var(--border);
  padding-bottom: var(--space-sm);
}

.game-board-turn-info { font-weight: 600; }
.game-board-topbar-actions { display: flex; gap: var(--space-xs); flex-wrap: wrap; }

/* Card size stepper (see gameSession.js's card-zoom section). Boxed as one unit so the two buttons
   and the percentage read as a single control rather than as two more topbar buttons. */
.game-card-zoom {
  display: flex;
  align-items: center;
  gap: var(--space-2xs);
  padding: 0 var(--space-2xs);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.game-card-zoom-value {
  font-size: 0.7857rem;
  color: var(--text-dim);
  min-width: 4ch; /* holds its width from 60% to 300% so the neighbouring buttons don't shuffle */
  text-align: center;
}

/* The 12 real turn steps (see gameSession.js's renderPhaseStrip) -- current one highlighted. */
.game-phase-strip { display: flex; gap: 2px; flex-wrap: wrap; }
.game-phase-pip {
  font-size: 0.6428rem;
  padding: 2px 6px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--text-dim);
}
.game-phase-pip--current {
  background: var(--accent);
  color: var(--on-accent);
  border-color: var(--accent);
  font-weight: 700;
}

/* Mana pool chips (see gameSession.js's renderManaPool) -- reuses the same fixed per-color
   --mana-* variables the Build tab's WUBRG filter toggles already use (deliberately untheme-able,
   a mana color is a game concept, not a UI accent). */
.game-mana-pool { display: flex; gap: var(--space-2xs); flex-wrap: wrap; }
.game-mana-chip {
  font-size: 0.7143rem;
  font-weight: 700;
  padding: 1px 6px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--bg-input);
}
.game-player-board--mine .game-mana-chip { cursor: pointer; }
.game-mana-chip--empty { opacity: 0.45; }
.game-mana-chip--w { color: var(--mana-w); }
.game-mana-chip--u { color: var(--mana-u); }
.game-mana-chip--b { color: var(--mana-b); }
.game-mana-chip--r { color: var(--mana-r); }
.game-mana-chip--g { color: var(--mana-g); }
.game-mana-chip--c { color: var(--mana-c); }

/* Pending "add one mana of any color" choice (see gameSession.js's tapForMana/renderManaPool): the
   viewer's own WUBRG chips become the picker and pulse for attention; colorless dims out, since an
   any-color source can't produce {C}. */
.game-mana-chip--choosable {
  cursor: pointer;
  border-color: currentColor;
  box-shadow: 0 0 0 1px currentColor, 0 0 10px color-mix(in srgb, currentColor 70%, transparent);
  animation: game-mana-await-pulse 1.1s ease-in-out infinite;
}
.game-mana-chip--dimmed { opacity: 0.3; }
@keyframes game-mana-await-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.14); }
}
/* The same five choices as buttons in the action bar, for anyone who'd rather click there. */
.game-mana-pick { font-weight: 700; background: var(--bg-input); border: 1px solid currentColor; }
.game-mana-pick--w { color: var(--mana-w); }
.game-mana-pick--u { color: var(--mana-u); }
.game-mana-pick--b { color: var(--mana-b); }
.game-mana-pick--r { color: var(--mana-r); }
.game-mana-pick--g { color: var(--mana-g); }

/* The stack (see gameSession.js's renderStackPanel) -- visible only while spells wait to resolve. */
.game-stack-panel {
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  background: var(--bg-panel);
  padding: var(--space-sm);
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}
.game-stack-list { display: flex; flex-direction: column; gap: 2px; }
/* Who the table is still waiting on (see gameSession.js's renderStackPanel). Set apart from the
   stack rows above it because it describes the whole window, not any one spell on it. */
.game-stack-waiting {
  margin-top: var(--space-2xs);
  padding-top: var(--space-2xs);
  border-top: 1px dashed var(--border);
}
.game-stack-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  padding: var(--space-2xs) var(--space-sm);
  background: var(--bg-input);
  border-radius: var(--radius);
}
.game-stack-row--top { border: 1px solid var(--accent-dim); font-weight: 600; }
.game-stack-row-owner { font-size: 0.7143rem; color: var(--text-dim); }
.game-stack-actions { display: flex; gap: var(--space-xs); flex-wrap: wrap; }
/* An ability on the stack, distinguished from a spell resolving (see renderStackPanel): "Grave
   Titan" alone can't tell you whether the creature or its trigger is about to resolve. */
.game-stack-row-name--ability { font-style: italic; }

/* The pending-choice panel (see gameSession.js's renderChoicePanel) -- the Oracle-text layer's
   questions: "you may", "choose one", "discard two cards", "which creature?".

   Positioned as a centered overlay rather than tucked into a sidebar on purpose: while a choice is
   outstanding the SERVER refuses every other action, so this is not one panel among several -- it is
   the only control that makes the game move again, and anything less prominent reads as a freeze. */
.game-choice-panel {
  /* Fixed, not absolute: the board overlay it lives in is a scrolling flex column, so an absolutely
     positioned child would scroll away from the question it is asking. z-index 515 puts it above the
     overlay (500) AND above the card context menu (510) -- while a choice is outstanding every other
     interaction is refused server-side, so nothing may paint over it. Same stacking-context trap the
     context menu documents below. */
  position: fixed;
  left: 50%;
  top: 18%;
  transform: translateX(-50%);
  z-index: 515;
  width: min(30rem, 92vw);
  max-height: 64vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  padding: var(--space-md);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  background: var(--bg-panel);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.game-choice-title { font-weight: 700; color: var(--accent); }
.game-choice-prompt { font-size: 0.9286rem; }
.game-choice-options {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  max-height: 32vh;
  overflow-y: auto;
}
/* A bare .btn carries no colours of its own -- without these an unpicked option renders with the
   browser's default light-grey button chrome, which is invisible-adjacent on a dark panel. Styled to
   match the app's inputs, so the list reads as a set of choices rather than a row of stray buttons. */
.game-choice-option {
  text-align: left;
  justify-content: flex-start;
  background: var(--bg-input);
  color: var(--text);
  border: 1px solid var(--border);
  font-weight: 400;
}
.game-choice-option:hover { border-color: var(--accent-dim); }
/* A picked option has to be obvious at a glance: these choices are irreversible once confirmed, so
   "did I select two or three?" must never be a question the player has to squint at. */
.game-choice-option--picked {
  border-color: var(--accent);
  background: var(--accent-dim);
  font-weight: 600;
}
.game-choice-actions { display: flex; gap: var(--space-xs); flex-wrap: wrap; align-items: center; }
.game-choice-number { width: 5rem; }


/* Combat / summoning-sickness / marked-damage indicators on card tiles (see fillCardTile). */
.game-card--attacking { outline: 2px solid var(--error); outline-offset: 1px; }
.game-card--blocking { outline: 2px solid var(--ok); outline-offset: 1px; }
.game-card--sick { filter: saturate(0.55); }
.game-card-damage-badge {
  position: absolute;
  top: 0.1em;
  right: 0.1em;
  background: var(--error);
  color: #fff;
  font-size: 0.85em;
  font-weight: 700;
  line-height: 1;
  padding: 0.1em 0.3em;
  border-radius: 3px;
}
.game-card-sick-badge,
.game-card-combat-badge {
  position: absolute;
  top: 0.1em;
  left: 0.1em;
  font-size: 0.9em;
  line-height: 1;
  text-shadow: 0 0 2px #000;
}
.game-card-combat-badge { top: auto; bottom: 0.1em; }

/* Live-session combat declarations (see gameSession.js's renderLiveCombatPanel). Only rendered during
   the declare-attackers/blockers steps of a live-camera game: the creatures are on a real table, so
   this is the shared record of who is attacking whom, declared by name rather than by clicking a card
   that doesn't exist on screen. */
.game-combat-panel {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  padding: var(--space-sm);
  margin-bottom: var(--space-sm);
  background: var(--bg-panel);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
}
.game-combat-row {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  flex-wrap: wrap;
}
.game-combat-line { flex: 1; min-width: 12ch; }
.game-combat-form {
  display: flex;
  gap: var(--space-xs);
  flex-wrap: wrap;
  align-items: center;
}
.game-combat-name { flex: 1; min-width: 14ch; }
.game-combat-target { min-width: 12ch; }
@media (max-width: 700px) {
  .game-combat-form { flex-direction: column; align-items: stretch; }
}

.game-board-players {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: var(--space-md);
  position: relative; /* the positioning context free-mode boards are placed against */
}
/* Free-arrange mode (see gameSession.js's enableFreeLayout): boards become absolutely positioned
   wherever they were dragged. They no longer contribute height, hence the min-height -- the board
   overlay itself scrolls if an arrangement runs past it. */
.game-board-players--free {
  display: block;
  min-height: 78vh;
}
.game-board-players--free .game-player-board {
  position: absolute;
  margin: 0;
}

/* ---------- Templated frame layouts (see gameSession.js's templated-frame-layouts section) --------
   Two ready-made shapes, both real grids rather than the computed pixel geometry free mode uses --
   which is the point of them: a grid re-divides itself when the window resizes, when a seat is
   defeated, or when the same table is opened on a different monitor. Saved coordinates can't.

   A fixed `height` (not min-height) is load-bearing for both: `1fr` tracks divide FREE SPACE, and a
   container that sizes to its content has none to divide, so every row would fall back to its
   content height and the boards would come out unequal -- exactly what these templates exist to
   avoid. The overlay scrolls if the result is taller than the window. */
.game-board-players--tpl-highlight,
.game-board-players--tpl-grid {
  display: grid;
  height: 78vh;
}
/* One seat blown up on the left; every other seat shares the narrow column at equal height.
   --game-tpl-rows is set by syncTemplateClasses to the number of those other seats -- inline, but
   as a custom PROPERTY rather than as grid-template-rows itself, so the phone breakpoint below can
   still override the real property (an inline grid-template-rows would outrank any rule). */
.game-board-players--tpl-highlight {
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  grid-template-rows: repeat(var(--game-tpl-rows, 1), minmax(0, 1fr));
}
.game-board-players--tpl-highlight .game-player-board--focus {
  grid-column: 1;
  grid-row: 1 / -1;
}
/* Equal sizing, two boards to a row -- 2x2 at a four-player table, and it keeps dividing evenly at
   any other seat count rather than breaking. */
.game-board-players--tpl-grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  grid-auto-rows: minmax(0, 1fr);
}
/* The board's own min-width/min-height and resize handle are what free mode is built around, and
   all three fight a template: a min-height taller than its track makes "equal" boards unequal, and
   dragging the corner handle would pin a size the grid is supposed to own. Each board scrolls
   inside its cell instead (it already has overflow: auto), and dragging a board by its HEADER is
   still the way out -- that switches to free mode and hands the sizing back. */
.game-board-players--tpl-highlight .game-player-board,
.game-board-players--tpl-grid .game-player-board {
  min-width: 0;
  min-height: 0;
  resize: none;
}

.game-player-board {
  --game-card-scale: 1; /* set live by gameSession.js's ResizeObserver as this board is resized */
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-panel);
  padding: var(--space-sm);
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  /* Native browser resize handle (bottom-right corner) instead of hand-built drag-resize code --
     see gameSession.js's playerBoardNodes/ResizeObserver comment for why the board container has
     to be a persistent DOM node across renders for this to actually stick. */
  resize: both;
  overflow: auto;
  min-width: 260px;
  min-height: 220px;
}
.game-player-board--mine { border-color: var(--accent-dim); }
.game-player-board--active { box-shadow: 0 0 0 2px var(--accent) inset; }

/* The header doubles as the board's drag handle (see gameSession.js's attachBoardDrag) -- the rest
   of the board stays free for card interaction, and the bottom-right corner belongs to the native
   resize handle. */
.game-player-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  cursor: grab;
  touch-action: none; /* let pointermove drag instead of the browser scrolling/panning the board */
}
.game-player-board--dragging { cursor: grabbing; opacity: 0.9; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); }
.game-player-board--dragging .game-player-header { cursor: grabbing; }
.game-player-name { font-weight: 700; }
/* Name plus (while one seat is highlighted) the button that moves the highlight to THIS seat --
   grouped so the header still reads as one name on the left and the life total on the right. */
.game-player-identity { display: flex; align-items: center; gap: var(--space-2xs); min-width: 0; }
.game-player-focus-btn { line-height: 1; }
/* The seat the Player highlighted template blew up -- the grid does the sizing (see
   .game-board-players--tpl-highlight); this is just so it's obvious which board that is. */
.game-player-board--focus { border-color: var(--accent); }
.game-player-life { display: flex; align-items: center; gap: var(--space-2xs); }
.game-life-value { font-weight: 700; font-size: 1.1428rem; min-width: 2ch; text-align: center; }

.game-zone-row { display: flex; flex-direction: column; gap: var(--space-2xs); }
.game-zone-row-inline { display: flex; gap: var(--space-sm); }
.game-zone-row-inline .game-zone-row { flex: 1; }
.game-zone-label { font-size: 0.7857rem; color: var(--text-dim); display: flex; align-items: center; gap: var(--space-2xs); flex-wrap: wrap; }

/* The size every card tile actually renders at: the board's own width-driven scale times the
   table-wide card zoom from the topbar (see gameSession.js's card-zoom section, which sets
   --game-card-zoom on #game-board-overlay). Declared on the elements that USE it rather than on an
   ancestor -- a custom property whose value is a calc() over other custom properties is resolved
   where it's declared, so putting it on the overlay would freeze in the overlay's own (absent)
   --game-card-scale and throw away every board's individual scale. */
.game-zone-cards,
.game-card {
  --game-card-size: calc(var(--game-card-scale, 1) * var(--game-card-zoom, 1));
}

.game-zone-cards {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs);
  min-height: calc(46px * var(--game-card-size, 1)); /* keeps an empty zone's drop target card-sized at any board scale */
}

.game-card {
  position: relative;
  /* Scales with --game-card-size (see its comment above) -- 44x61 is the natural size at size 1,
     Magic's ~63:88 card aspect ratio at a compact battlefield-tile size. */
  width: calc(44px * var(--game-card-size, 1));
  height: calc(61px * var(--game-card-size, 1));
  border-radius: 4px;
  overflow: hidden;
  background: var(--bg-input);
  border: 1px solid var(--border);
  flex-shrink: 0;
  transition: transform 0.15s ease;
  /* Every badge/label inside a tile sizes itself in `em` against this, so resizing a board frame
     scales the card's whole contents proportionally instead of leaving fixed-px badges swamping a
     shrunken card (or lost on an enlarged one). */
  font-size: calc(10px * var(--game-card-size, 1));
}
.game-card img { width: 100%; height: 100%; object-fit: cover; }

/* The face-down back every hidden card in a game session shows -- opponents' redacted hand entries
   (.game-card--back) and the library pile below. Drawn as an inline SVG rather than shipped as an
   image file: it needs no network round-trip (the desktop app's board has to render with no server
   reachable at all), and being vector it stays crisp at every --game-card-size, from a phone-sized
   board to a zoomed-in one. Deliberately not a scan of the real printed back -- that artwork is
   Wizards', so this is the same silhouette in the same brown, drawn from scratch. */
:root {
  --mtg-card-back: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 63 88' preserveAspectRatio='none'><defs><linearGradient id='b' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='%237a5330'/><stop offset='.5' stop-color='%234a2e16'/><stop offset='1' stop-color='%23241408'/></linearGradient><radialGradient id='o' cx='.5' cy='.4' r='.65'><stop offset='0' stop-color='%236b4622'/><stop offset='1' stop-color='%231a0f06'/></radialGradient></defs><rect width='63' height='88' rx='4' fill='%230b0806'/><rect x='2.5' y='2.5' width='58' height='83' rx='3' fill='url(%23b)'/><rect x='4.5' y='4.5' width='54' height='79' rx='2' fill='none' stroke='%23c9a267' stroke-width='.5' opacity='.45'/><ellipse cx='31.5' cy='44' rx='19' ry='29' fill='url(%23o)' stroke='%23c9a267' stroke-width='1.1'/><ellipse cx='31.5' cy='44' rx='16' ry='25.5' fill='none' stroke='%23e0c08a' stroke-width='.45' opacity='.4'/><path d='M31.5 26 C22.5 36 22.5 52 31.5 62 C40.5 52 40.5 36 31.5 26 Z' fill='%23e6c791' opacity='.5'/><path d='M31.5 30 C26 37.5 26 50.5 31.5 58 C37 50.5 37 37.5 31.5 30 Z' fill='%23241408' opacity='.55'/><circle cx='31.5' cy='44' r='3' fill='%23f7e4bb' opacity='.75'/></svg>");
}
.game-card--back { background: var(--mtg-card-back) center / 100% 100% no-repeat; }

/* The library: a real pile of face-down cards (see gameSession.js's libraryPile). The stack behind
   it is box-shadow silhouettes rather than extra elements -- offsets scale with the card so the
   pile keeps its proportions at any board size, and the deeper tiers simply layer more of them on,
   so a fresh 99-card deck visibly sits thicker than one that's been drawn down to a handful. */
.game-library-pile {
  position: relative;
  width: calc(44px * var(--game-card-size, 1));
  height: calc(61px * var(--game-card-size, 1));
  flex-shrink: 0;
  border-radius: 4px;
  background: var(--mtg-card-back) center / 100% 100% no-repeat;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: calc(10px * var(--game-card-size, 1));
  --game-library-edge: calc(2px * var(--game-card-size, 1));
}
.game-library-pile--depth-1 {
  box-shadow: var(--game-library-edge) var(--game-library-edge) 0 0 #100c0a;
}
.game-library-pile--depth-2 {
  box-shadow:
    var(--game-library-edge) var(--game-library-edge) 0 0 #100c0a,
    calc(var(--game-library-edge) * 2) calc(var(--game-library-edge) * 2) 0 0 #4a3018,
    calc(var(--game-library-edge) * 2.5) calc(var(--game-library-edge) * 2.5) 0 0 #100c0a;
}
.game-library-pile--depth-3 {
  box-shadow:
    var(--game-library-edge) var(--game-library-edge) 0 0 #100c0a,
    calc(var(--game-library-edge) * 2) calc(var(--game-library-edge) * 2) 0 0 #4a3018,
    calc(var(--game-library-edge) * 2.5) calc(var(--game-library-edge) * 2.5) 0 0 #100c0a,
    calc(var(--game-library-edge) * 3.5) calc(var(--game-library-edge) * 3.5) 0 0 #4a3018,
    calc(var(--game-library-edge) * 4) calc(var(--game-library-edge) * 4) 0 0 #100c0a;
}
/* Decked out (or not yet dealt): no pile to draw, just the empty space where one was. */
.game-library-pile--depth-0 {
  background: none;
  border: 1px dashed var(--border);
}
/* Sat low rather than centred so the back's own art still reads underneath it -- the pile should
   look like a deck first and a number second. */
.game-library-pile-count {
  position: absolute;
  bottom: 0.35em;
  left: 50%;
  transform: translateX(-50%);
  font-weight: 700;
  font-size: 1.1em;
  color: #f7e4bb;
  background: rgba(11, 8, 6, 0.82);
  border: 1px solid rgba(201, 162, 103, 0.5);
  border-radius: 999px;
  padding: 0.05em 0.45em;
  line-height: 1.35;
}
.game-library-pile--depth-0 .game-library-pile-count {
  color: var(--text-dim);
  background: none;
  border-color: transparent;
}
/* Tap rotation lives in a custom property, not a bare `transform`, because every animation below
   also drives `transform` and would otherwise snap a tapped card upright for its duration (most
   visibly: attackers get tapped when declared, so the attack lunge would un-rotate them). Each
   keyframe composes `rotate(var(--game-card-tap-rotate))` instead. */
.game-card { --game-card-tap-rotate: 0deg; }
.game-card--tapped { --game-card-tap-rotate: 90deg; transform: rotate(var(--game-card-tap-rotate)); }
.game-card--selected { outline: 2px solid var(--accent); outline-offset: 1px; }
.game-card--dragging { opacity: 0.4; }
.game-card--entering { animation: game-card-enter 0.25s ease; }
@keyframes game-card-enter {
  from { opacity: 0; transform: scale(0.5); }
  to { opacity: 1; transform: scale(1); }
}

/* ---- Card animations (see gameSession.js's animation section) --------------------------------
   Distances/offsets are expressed in em against the tile's own font-size, or as percentages of the
   tile, so every one of these scales with --game-card-scale along with the card itself rather than
   drifting out of proportion when a board frame is resized. */

/* Picked up: lifts toward the viewer (the selection outline itself is declared above). */
.game-card--picked-up {
  animation: game-card-pickup 0.18s ease forwards;
  z-index: 3;
}
@keyframes game-card-pickup {
  from { transform: translateY(0) rotate(var(--game-card-tap-rotate)) scale(1); }
  to { transform: translateY(-18%) rotate(var(--game-card-tap-rotate)) scale(1.12); }
}

/* Combat: attackers lunge toward the opponent, blockers shove back into them (the attack/block
   outlines themselves are declared in the combat-indicators block below). Infinite alternate so the
   state reads as ongoing for as long as the declaration stands. Translate is applied BEFORE the tap
   rotation in each keyframe so the motion stays screen-vertical even on a tapped (rotated)
   attacker, which is the normal case -- declaring an attacker taps it. */
.game-card--attacking { animation: game-card-lunge 0.9s ease-in-out infinite alternate; }
.game-card--blocking { animation: game-card-brace 0.9s ease-in-out infinite alternate; }
@keyframes game-card-lunge {
  from { transform: translateY(0) rotate(var(--game-card-tap-rotate)); }
  to { transform: translateY(-14%) rotate(var(--game-card-tap-rotate)); }
}
@keyframes game-card-brace {
  from { transform: translateY(0) rotate(var(--game-card-tap-rotate)); }
  to { transform: translateY(9%) rotate(var(--game-card-tap-rotate)); }
}

/* Took damage but survived: a red flash and a quick shake. */
.game-card--damaged { animation: game-card-damaged 0.45s ease; }
@keyframes game-card-damaged {
  0% { transform: translateX(0) rotate(var(--game-card-tap-rotate)); filter: none; }
  15% { transform: translateX(-9%) rotate(var(--game-card-tap-rotate)); filter: brightness(1.5) sepia(1) hue-rotate(-35deg) saturate(4); }
  35% { transform: translateX(8%) rotate(var(--game-card-tap-rotate)); }
  55% { transform: translateX(-6%) rotate(var(--game-card-tap-rotate)); filter: brightness(1.3) sepia(1) hue-rotate(-35deg) saturate(3); }
  75% { transform: translateX(4%) rotate(var(--game-card-tap-rotate)); }
  100% { transform: translateX(0) rotate(var(--game-card-tap-rotate)); filter: none; }
}

/* Leaving play. `--leaving` is applied to a CLONE of the tile left behind at the old position (the
   real node is already gone from the DOM by then), so these can run to completion without the
   layout fighting them. One per destination so the reason a card left is legible at a glance. */
.game-card--leaving {
  position: fixed;
  /* Above .game-board-overlay (z-index 500), NOT the 70 this used to be -- exactly the same trap
     .game-card-context-menu below already fell into and documents. The clone is appended to <body>
     (makeLeavingClone), making it a sibling of that full-screen, OPAQUE overlay in the root
     stacking context, so at 70 every death/exile/recall animation ran to completion painted
     underneath the board and was never actually seen. Matches .game-life-particles' 505, and stays
     under the context menu's 510 so an open menu still wins. */
  z-index: 505;
  pointer-events: none;
  margin: 0;
}
/* Creature death / destruction: crumples and drops away. The leaving clone carries the tap rotation
   as a starting offset (see makeLeavingClone), so these keyframes rotate relative to it. */
.game-card--died { animation: game-card-died 0.6s ease forwards; }
@keyframes game-card-died {
  0% { transform: rotate(var(--game-card-tap-rotate)) translateY(0) scale(1); opacity: 1; filter: none; }
  30% { transform: rotate(calc(var(--game-card-tap-rotate) - 8deg)) translateY(6%) scale(0.98); filter: brightness(1.4) sepia(1) hue-rotate(-35deg) saturate(4); }
  100% { transform: rotate(calc(var(--game-card-tap-rotate) - 22deg)) translateY(70%) scale(0.72); opacity: 0; filter: grayscale(1) brightness(0.5); }
}
/* Non-creature to the graveyard: slides down and fades, no death throes. */
.game-card--to-graveyard { animation: game-card-to-graveyard 0.5s ease forwards; }
@keyframes game-card-to-graveyard {
  from { transform: translateY(0) rotate(var(--game-card-tap-rotate)) scale(1); opacity: 1; }
  to { transform: translateY(55%) rotate(var(--game-card-tap-rotate)) scale(0.8); opacity: 0; filter: grayscale(0.8); }
}
/* Exiled: blows out to white and vanishes upward -- deliberately distinct from a graveyard trip,
   since exile is the one that doesn't come back. */
.game-card--exiled { animation: game-card-exiled 0.6s ease forwards; }
@keyframes game-card-exiled {
  0% { transform: translateY(0) rotate(var(--game-card-tap-rotate)) scale(1); opacity: 1; filter: none; }
  40% { transform: translateY(-12%) rotate(var(--game-card-tap-rotate)) scale(1.06); filter: brightness(3) saturate(0); }
  100% { transform: translateY(-45%) rotate(var(--game-card-tap-rotate)) scale(0.6); opacity: 0; filter: brightness(4) saturate(0); }
}
/* Back to hand or library: shrinks away toward its owner rather than dying. */
.game-card--recalled { animation: game-card-recalled 0.4s ease forwards; }
@keyframes game-card-recalled {
  from { transform: translateY(0) rotate(var(--game-card-tap-rotate)) scale(1); opacity: 1; }
  to { transform: translateY(30%) rotate(var(--game-card-tap-rotate)) scale(0.45); opacity: 0; }
}

.game-zone-cards--drag-over {
  outline: 2px dashed var(--accent);
  outline-offset: 2px;
  border-radius: 4px;
}

.game-card-context-menu {
  position: fixed;
  /* Above .game-board-overlay (z-index 500), NOT the 60 this used to be. The menu is appended to
     <body> rather than into the overlay (so it can never be clipped by a board frame's scroll box),
     which makes it a sibling of that full-screen, opaque overlay in the root stacking context -- at
     60 it was being painted underneath the board and was completely invisible. Right-click looked
     like it did nothing; it had been opening the menu behind the game the whole time. */
  z-index: 510;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-2xs);
  background: var(--bg-panel);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  padding: var(--space-sm);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
  max-width: 220px;
}
.game-card-context-menu .game-action-bar-name { margin: 0 0 var(--space-2xs); }
/* Sizes below are in `em` against .game-card's scaled font-size (see its comment) so they track the
   card's size as a board frame is resized. */
.game-card-noimg {
  font-size: 0.9em;
  padding: 0.2em;
  text-align: center;
  color: var(--text);
  word-break: break-word;
}
/* A face-down permanent (morph). Drawn as a card BACK rather than as the card underneath, for
   everyone including its controller -- that's what it looks like in paper, and showing the real art
   to its owner would make a face-down creature and a real one indistinguishable on their own board.
   The owner still gets the name on the tile's tooltip and the real card on hover preview. */
.game-card--face-down {
  background: repeating-linear-gradient(
    135deg,
    var(--surface-2, #2b2b33),
    var(--surface-2, #2b2b33) 4px,
    var(--surface, #23232a) 4px,
    var(--surface, #23232a) 8px
  );
  border-style: dashed;
}
.game-card-face-down {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  font-size: 0.75em;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-align: center;
  padding: 0.2em;
  color: var(--text-muted, var(--text));
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.game-card-counter-badge {
  position: absolute;
  bottom: 0.1em;
  right: 0.1em;
  background: var(--accent);
  color: var(--on-accent);
  font-size: 0.85em;
  font-weight: 700;
  line-height: 1;
  padding: 0.1em 0.3em;
  border-radius: 3px;
}
/* Current power/toughness, superimposed on the tile's bottom-right corner -- where the printed P/T
   box already sits on the art, which at a 44x61px tile is far too small to read (see
   gameSession.js's powerToughnessText for what "current" means, and why a live-camera session never
   renders one of these). The outline keeps the chip legible over dark card frames as well as light
   ones. */
.game-card-pt-badge {
  position: absolute;
  bottom: 0.1em;
  right: 0.1em;
  background: rgba(0, 0, 0, 0.78);
  color: #fff;
  font-size: 0.95em;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  padding: 0.1em 0.28em;
  border-radius: 3px;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.28);
}
/* Not the printed size any more -- counters are folded into the number itself, so the chip says
   which way it was bent rather than leaving the difference to be inferred from the counter badge. */
.game-card-pt-badge--buffed { background: rgba(26, 92, 48, 0.92); box-shadow: 0 0 0 1px var(--ok); }
.game-card-pt-badge--weakened { background: rgba(112, 30, 30, 0.92); box-shadow: 0 0 0 1px var(--error); }
/* Both badges want that same corner (see fillCardTile) -- P/T is the one that has to be where the
   printed box is, so the counter count stacks directly above it whenever they coexist.
   The offset has to clear the P/T chip's full height (0.95em font + 0.1em padding either side) plus
   its own 0.1em bottom inset -- but note this `em` resolves against the COUNTER badge's font-size
   (0.85em of the tile), not the tile's, so the number is bigger than the P/T chip's own em maths
   suggests: 1.62 x 0.85 = ~1.38 tile-em, comfortably past the ~1.28 the chip actually occupies.
   Both are proportions of the tile's font-size, so the clearance holds at every board scale. */
.game-card-counter-badge--above-pt { bottom: 1.62em; }

/* ---- Life-total reactions (see gameSession.js's playLifeAnimations) ---------------------------
   Green for a gain, red for a loss, on every seat's screen rather than only the one that changed.
   The number itself glows and swells; the particle burst is thrown from a separate fixed-position
   layer (below), because a player board is a scroll box and would clip anything leaving the
   number's own bounds. */
.game-life-value--gain { animation: game-life-gain 0.75s ease-out; }
.game-life-value--loss { animation: game-life-loss 0.75s ease-out; }
@keyframes game-life-gain {
  0% { transform: scale(1); text-shadow: none; }
  25% {
    transform: scale(1.3);
    color: #6ee7a0;
    text-shadow: 0 0 10px rgba(72, 219, 128, 0.95), 0 0 24px rgba(72, 219, 128, 0.55);
  }
  100% { transform: scale(1); text-shadow: none; }
}
@keyframes game-life-loss {
  0% { transform: scale(1); text-shadow: none; }
  15% { transform: scale(1.3) translateX(-6%); color: #ff8177; text-shadow: 0 0 10px rgba(226, 76, 62, 0.95), 0 0 24px rgba(226, 76, 62, 0.55); }
  45% { transform: scale(1.18) translateX(5%); color: #ff8177; text-shadow: 0 0 8px rgba(226, 76, 62, 0.8); }
  100% { transform: scale(1) translateX(0); text-shadow: none; }
}

/* The burst layer is a zero-size anchor point positioned at the life total's centre, with the
   particles absolutely placed around it. z-index has to clear .game-board-overlay's 500 for the
   same reason the context menu above does: this is appended to <body>, making it a sibling of that
   full-screen opaque overlay, so anything below 500 is painted underneath the board and never
   seen. Kept under the context menu's 510 so an open menu still wins. */
.game-life-particles {
  position: fixed;
  z-index: 505;
  width: 0;
  height: 0;
  pointer-events: none;
}
.game-life-particle {
  position: absolute;
  left: 0;
  top: 0;
  width: 5px;
  height: 5px;
  margin: -2.5px 0 0 -2.5px;
  border-radius: 50%;
  opacity: 0;
  /* Per-particle angle/distance/delay come from gameSession.js as custom properties, so one
     keyframe animation covers the whole burst instead of a dozen hand-written variants. */
  animation: game-life-particle-fly 0.6s ease-out var(--delay, 0ms) forwards;
}
.game-life-particles--gain .game-life-particle { background: #5fe08c; box-shadow: 0 0 6px rgba(72, 219, 128, 0.9); }
.game-life-particles--loss .game-life-particle { background: #f0655a; box-shadow: 0 0 6px rgba(226, 76, 62, 0.9); }
@keyframes game-life-particle-fly {
  0% { opacity: 1; transform: rotate(var(--angle, 0deg)) translateX(0) scale(1); }
  100% { opacity: 0; transform: rotate(var(--angle, 0deg)) translateX(var(--distance, 24px)) scale(0.35); }
}

.game-board-action-bar {
  position: sticky;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  flex-wrap: wrap;
  background: var(--bg-panel);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  padding: var(--space-sm);
}
.game-action-bar-name { font-weight: 600; margin-right: var(--space-sm); }

/* Bottom two-pane view: History | Rulings (see gameSession.js's rulings section). Side by side so a
   ruling can be read WITHOUT losing sight of the log that prompted the question -- tabs would have
   hidden one to show the other, which is the wrong trade when the argument is about what just
   happened. Stacks on a narrow screen, where side-by-side would leave two unreadable columns. */
.game-board-bottom {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  /* Resizable while still docked, not only once dragged loose (see gameSession.js's
     makeDetachableFrame and its resizableWhileDocked flag) -- "I want to see more of the log" is the
     common want, and needing to detach the strip first to get it would be a strange toll. Vertical
     only: docked, this spans the board's full width, and a horizontal drag would just leave a gap
     down the side. The explicit default height is what the resize handle overrides, and is also what
     lets the panes below flex to fill it -- an `auto` height would have nothing to divide up. */
  height: 200px;
  min-height: 120px;
  resize: vertical;
  overflow: auto;
  /* Load-bearing: this strip is a flex ITEM of .game-board-overlay's column, so by default it
     shrinks to fit whatever height is left over -- which pinned it at min-height and made both the
     declared height above and any resize the person performed do precisely nothing. `flex: none`
     makes its own height authoritative; the overlay already scrolls (overflow-y: auto), so a taller
     strip pushes the board into a scroll rather than being squashed back. */
  flex: none;
}
.game-board-bottom-panes {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-md);
  align-items: stretch; /* both panes take the frame's full height, so they scroll rather than clip */
  flex: 1;
  min-height: 0; /* without this a flex child refuses to shrink below its content, defeating the scroll boxes */
}
/* Title bar / drag handle, matching the drawers' own (see gameSession.js's makeDetachableFrame).
   Deliberately understated -- this strip is always on screen, so a full-weight heading here would
   compete with the boards for attention when all it is is a grab point. */
.game-board-bottom-title {
  margin: 0;
  font-size: 0.7857rem;
  font-weight: 600;
  color: var(--text-dim);
  cursor: grab;
  touch-action: none; /* let pointermove drag instead of the browser panning the overlay */
}
/* Detached into the same absolute coordinate space as the player boards, so it snaps against them
   (see snapPosition) and resizes by its own corner handle, exactly like a board. */
.game-board-bottom--free {
  position: absolute;
  inset: auto;
  width: min(720px, 92vw);
  background: var(--bg-panel);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  padding: var(--space-sm);
  resize: both;
  overflow: auto;
  min-width: 280px;
  min-height: 160px;
}
.game-board-bottom-pane {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  min-width: 0; /* lets a long ruling wrap instead of stretching the grid column */
  min-height: 0; /* same reason as the panes grid above -- lets the scroll box inside actually scroll */
}
.game-rulings-input { width: 100%; }
/* The strip is a fixed-height scroll box (200px by default), so the autocomplete's stock 280px
   dropdown can't fit inside it -- without a cap, picking a suggestion means scrolling the whole
   History/Rulings frame. Cap it to the frame and let the suggestion list scroll on its own. */
.game-board-bottom .autocomplete-dropdown { max-height: 150px; }
/* Both scroll boxes GROW WITH THE FRAME rather than being pinned at a fixed height: they used to be
   `max-height: 140px`, which meant dragging the frame taller bought nothing but empty space under
   two unchanged little boxes. The height now lives on .game-board-bottom (above) and is divided
   between them here, so a resize does what it looks like it should. */
.game-rulings-results.game-rulings-results {
  flex: 1;
  min-height: 0;
  max-height: none;
  overflow-y: auto;
}
@media (max-width: 700px) {
  .game-board-bottom-panes { grid-template-columns: 1fr; }
  /* Stacked, one 200px frame would give each pane ~80px. Let it size to its content again and put
     the per-pane caps back, which is what kept this readable on a phone in the first place.
     Scoped to the DOCKED state: a frame that's been dragged loose carries its own inline height and
     should keep behaving like the placed frame it is. */
  .game-board-bottom:not(.game-board-bottom--free) { height: auto; resize: none; }
  .game-rulings-results.game-rulings-results,
  .game-board-log { max-height: 140px; flex: none; }
}

.game-board-log {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  border-top: 1px solid var(--border);
  padding-top: var(--space-xs);
  font-size: 0.7857rem;
  color: var(--text-dim);
}
.game-log-row { padding: 1px 0; }

.game-player-away-badge { font-size: 0.7857rem; color: var(--warn); margin-left: var(--space-xs); }
.game-player-defeated-badge { font-size: 0.7857rem; color: var(--error); margin-left: var(--space-xs); }
.game-player-board--defeated { opacity: 0.55; }

/* Victory/defeat -- see gameSession.js's render(). Absolutely positioned over the (already
   position:fixed) .game-board-overlay, so they cover the board without needing their own stacking
   context plumbing. Deliberately no animation library (none exists in this repo) -- static
   @keyframes only, same category of effect as notifications.js's spawnInboxFirefly, just a
   one-time full-screen moment instead of a repeating per-event animation. */
.game-result-overlay {
  /* `fixed` for the same reason as the history drawer/board modal -- a victory or defeat screen has
     to cover the VIEWPORT, not the board overlay's scrollable content box (with a tall free-layout
     arrangement, an absolute one would only cover the scrolled-to portion). */
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  /* Top-aligned, not centered: the result banner shouldn't sit over the middle of the board, where
     it covers exactly the cards you want to look at while the game wraps up. */
  align-items: flex-start;
  justify-content: center;
  text-align: center;
}
/* Top-weighted vignette instead of a full-screen wash, and click-through everywhere except the
   panel itself (see .game-result-content) -- a defeated player in a still-running pod has to be
   able to keep watching and scrolling the table, which a blocking full-screen overlay prevented. */
.game-result-overlay { pointer-events: none; }
.game-result-overlay--victory {
  background: linear-gradient(to bottom, #3a2e05 0%, color-mix(in srgb, #1a1400 55%, transparent) 30%, transparent 60%);
}
.game-result-overlay--defeat {
  background: linear-gradient(to bottom, #2a0a0a 0%, color-mix(in srgb, #150606 55%, transparent) 30%, transparent 60%);
  animation: game-defeat-fade-in 1.2s ease;
}
.game-result-content {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-md);
  padding: var(--space-lg, 24px);
  /* A panel at the top rather than a full-screen wash, so the board stays visible underneath. */
  margin-top: 6vh;
  pointer-events: auto; /* re-enables clicks for the panel's own button (overlay is click-through) */
  background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
}
.game-result-title {
  font-size: 2.5rem;
  font-weight: 800;
}
.game-result-overlay--victory .game-result-title {
  color: #ffd76a;
  text-shadow: 0 0 20px rgba(255, 215, 100, 0.8), 0 0 60px rgba(255, 215, 100, 0.4);
  animation: game-victory-title-pop 0.8s ease;
}
.game-result-overlay--defeat .game-result-title {
  color: #9a9a9a;
  letter-spacing: 3px;
}
.game-result-detail { color: var(--text-dim); font-size: 1.1rem; }

@keyframes game-victory-title-pop {
  0% { transform: scale(0.3); opacity: 0; }
  60% { transform: scale(1.15); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
@keyframes game-defeat-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

.game-result-sparks { position: absolute; inset: 0; overflow: hidden; z-index: 1; pointer-events: none; }
.game-result-spark {
  position: absolute;
  bottom: -10px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #ffe28a;
  box-shadow: 0 0 8px 2px rgba(255, 226, 138, 0.9);
  animation: game-victory-spark-rise 3.4s ease-in infinite;
  opacity: 0;
}
.game-result-spark:nth-child(1) { left: 4%; animation-delay: 0s; }
.game-result-spark:nth-child(2) { left: 13%; animation-delay: 0.4s; }
.game-result-spark:nth-child(3) { left: 22%; animation-delay: 0.8s; }
.game-result-spark:nth-child(4) { left: 33%; animation-delay: 1.2s; }
.game-result-spark:nth-child(5) { left: 44%; animation-delay: 1.6s; }
.game-result-spark:nth-child(6) { left: 55%; animation-delay: 0.2s; }
.game-result-spark:nth-child(7) { left: 63%; animation-delay: 0.6s; }
.game-result-spark:nth-child(8) { left: 71%; animation-delay: 1.0s; }
.game-result-spark:nth-child(9) { left: 80%; animation-delay: 1.4s; }
.game-result-spark:nth-child(10) { left: 88%; animation-delay: 1.8s; }
.game-result-spark:nth-child(11) { left: 95%; animation-delay: 2.2s; }
.game-result-spark:nth-child(12) { left: 50%; animation-delay: 2.6s; }
@keyframes game-victory-spark-rise {
  0% { transform: translateY(0) scale(1); opacity: 0; }
  10% { opacity: 1; }
  90% { opacity: 0.6; }
  100% { transform: translateY(-420px) scale(0.3); opacity: 0; }
}

/* Game history: a right-edge drawer inside the board overlay (see gameSession.js), not a centered
   modal -- so the board stays readable next to it. Slides in from the right; `.hidden` (display:
   none) is what actually toggles it, the transform transition just gives the reveal some motion
   on the frames after it's displayed. */
.game-history-drawer {
  /* `fixed`, not `absolute`: the board overlay is a PADDED, SCROLLING container, so an absolute
     child anchors to its padding box and its full scroll height -- which would inset the drawer
     from the screen edge and, once a free-layout arrangement makes the board scroll, stretch it past
     the visible bottom. Fixed pins it to the viewport's right edge, which is what "tied to the right
     side of the screen" actually means. It still lives inside the overlay in the DOM, so it hides
     along with the board. */
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 55;
  width: min(420px, 92vw);
  display: flex;
  background: var(--bg-panel);
  border-left: 1px solid var(--accent-dim);
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.45);
  animation: game-drawer-slide-in 0.22s ease;
}
@keyframes game-drawer-slide-in {
  from { transform: translateX(100%); }
  to { transform: translateX(0); }
}
.game-history-drawer-inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  padding: var(--space-md);
  width: 100%;
  min-height: 0; /* lets the log below actually scroll instead of overflowing the drawer */
}
/* The title bar doubles as the frame's drag handle once it's been detached (see
   attachHistoryFrameDrag). */
.game-history-drawer-title { margin: 0; font-size: 1.1428rem; cursor: grab; touch-action: none; }

/* Detached from the right edge and living in the same absolute coordinate space as the player
   boards, so the two snap against each other (see snapPosition). Resizable like a board, too. */
.game-history-drawer--free {
  position: absolute;
  inset: auto;
  width: min(420px, 92vw);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  resize: both;
  overflow: auto;
  min-width: 260px;
  min-height: 220px;
  animation: none; /* the slide-in belongs to the docked drawer, not a placed frame */
}

/* Live-camera play panel (see gameSession.js's renderCameraPanel) -- only rendered on your own
   board, and only in a 'live' session. */
.game-camera-panel {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  padding-top: var(--space-xs);
  border-top: 1px solid var(--border);
}
.game-camera-controls { display: flex; gap: var(--space-xs); flex-wrap: wrap; }

/* A player's live board view -- own camera (<video>) or a relayed frame (<img>), both filling this
   stage. `contain` (not `cover`) is required for correctness, not looks: click-to-scan maps click
   coordinates back onto the source pixels, and cropping the view would make those coordinates lie
   (see boardViewClickRatios, which accounts for the resulting letterboxing). */
.game-board-view {
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
  max-height: 40vh;
  background: #000;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  cursor: crosshair; /* the whole surface is clickable to identify a card */
}
.game-board-view-media { width: 100%; height: 100%; object-fit: contain; display: block; }
.game-board-view-waiting {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-dim);
  font-size: 0.8571rem;
  text-align: center;
  padding: var(--space-sm);
}

/* Which source an opponent's board view is coming from -- a direct peer stream, or the slow relay
   fallback (see gameSession.js's renderBoardView). */
.game-feed-badge {
  margin-left: var(--space-xs);
  padding: 0 6px;
  border-radius: 999px;
  font-size: 0.7143rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  border: 1px solid var(--border);
}
.game-feed-badge--live {
  color: var(--ok);
  border-color: color-mix(in srgb, var(--ok) 55%, var(--border));
}

/* The scanner's live aim: a quiet outline tracking the borders of whichever card the pointer is over
   (see gameSession.js's attachHoverOutline). Deliberately understated next to the click pulse -- this
   one is always on while the mouse is in the view, so it shows where a scan WOULD go without
   competing for attention with a scan that's actually running. */
.game-scan-outline {
  position: absolute;
  z-index: 2; /* under a pulse, over the frame */
  border: 2px solid color-mix(in srgb, var(--neon-orange) 70%, transparent);
  border-radius: 5px;
  box-shadow: 0 0 10px color-mix(in srgb, var(--neon-orange) 35%, transparent);
  pointer-events: none;
  transition: left 90ms ease-out, top 90ms ease-out, width 90ms ease-out, height 90ms ease-out;
}
/* No card could be measured, so this is the fixed fallback box (see SCAN_CROP_FRACTION). Dashed and
   dimmed: a scan here still works, but it isn't aimed at anything in particular, and the outline
   shouldn't imply otherwise. */
.game-scan-outline--loose {
  border-style: dashed;
  border-color: color-mix(in srgb, var(--text-dim) 70%, transparent);
  box-shadow: none;
}

/* Click-registered pulse: a neon-orange glow around the border of the selected card, fired
   immediately and independently of the scan itself -- recognition needs a network round trip, and
   showing nothing until it returns would read as the click being ignored. Position and size come
   from inline style (see scanRegionBox), so the box outlines the region actually being read.
   The glow loops for as long as the scan runs, then turns green on a hit and red on a miss and fades
   out, so the outcome lands where the person was already looking.

   The border must not move or scale while it pulses -- it's an outline of a real card on a real
   table, and animating its geometry would make it point somewhere the card isn't. Only the glow and
   opacity animate. */
.game-scan-pulse {
  position: absolute;
  z-index: 3; /* above the frame and the "waiting for camera" placeholder */
  border-radius: 5px; /* a card's own corner radius, roughly, at board-view scale */
  border: 2px solid var(--neon-orange);
  pointer-events: none;
  animation: game-scan-pulse-ring 1.3s ease-in-out infinite;
}
/* Scanning the fallback box rather than a measured card border -- dashed, matching the hover
   outline's own "not aimed at anything" styling. */
.game-scan-pulse--loose { border-style: dashed; }
.game-scan-pulse--hit {
  border-color: var(--ok);
  box-shadow: 0 0 14px var(--ok), inset 0 0 14px color-mix(in srgb, var(--ok) 55%, transparent);
  animation: game-scan-pulse-settle 0.9s ease-out forwards;
}
.game-scan-pulse--miss {
  border-color: var(--error);
  box-shadow: 0 0 14px var(--error), inset 0 0 14px color-mix(in srgb, var(--error) 55%, transparent);
  animation: game-scan-pulse-settle 0.9s ease-out forwards;
}
@keyframes game-scan-pulse-ring {
  0%, 100% {
    opacity: 1;
    box-shadow: 0 0 6px var(--neon-orange), inset 0 0 6px color-mix(in srgb, var(--neon-orange) 40%, transparent);
  }
  50% {
    opacity: 0.75;
    box-shadow:
      0 0 20px var(--neon-orange),
      0 0 34px color-mix(in srgb, var(--neon-orange) 55%, transparent),
      inset 0 0 18px color-mix(in srgb, var(--neon-orange) 55%, transparent);
  }
}
@keyframes game-scan-pulse-settle {
  0% { opacity: 1; }
  70% { opacity: 1; }
  100% { opacity: 0; }
}

/* A recognized card as it appears in the history feed: thumbnail, name, and the English printing it
   was identified as. The whole chip is the hover target (see historyRow). */
.game-scan-chip {
  display: flex;
  gap: var(--space-sm);
  padding: var(--space-xs) var(--space-sm);
  background: var(--bg-input);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  align-items: flex-start;
}
.game-scan-chip-thumb { width: 44px; border-radius: 4px; flex-shrink: 0; }
.game-scan-chip-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.game-scan-chip-name { font-weight: 700; }
.game-scan-chip-printing { font-size: 0.7857rem; color: var(--neon-blue); }
.game-log-row--card { display: flex; flex-direction: column; gap: var(--space-2xs); padding: var(--space-2xs) 0; }
.game-log-actor { font-size: 0.7857rem; color: var(--text-dim); }

.game-history-log {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: 0.8571rem;
}

/* A centered modal scoped to the board overlay rather than the whole page (.modal-overlay's
   job) -- same visual treatment, but it can't outlive the board it belongs to. */
/* Search library (tutors) -- see gameSession.js's openLibrarySearch. A wide, scrolling grid because
   the whole point is scanning ~90 cards for the one you want; the card images do the work, so the
   tiles are as large as fit and the name sits under each as a fallback for missing art. */
.game-library-modal-inner {
  width: min(1100px, 94vw);
  max-height: 88vh;
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}
.game-library-controls {
  display: flex;
  gap: var(--space-sm);
  align-items: center;
  flex-wrap: wrap;
}
.game-library-search { flex: 1; min-width: 16ch; }
.game-library-filter-toggle {
  display: flex;
  align-items: center;
  gap: var(--space-2xs);
  font-size: 0.8571rem;
  white-space: nowrap;
}
.game-library-grid {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: var(--space-xs);
  padding: var(--space-2xs);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-input);
}
.game-library-tile {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 2px;
  background: none;
  border: 2px solid transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
  text-align: center;
  color: var(--text);
  font: inherit;
}
.game-library-tile:hover { border-color: var(--accent-dim); }
.game-library-tile img { width: 100%; border-radius: 4px; display: block; }
.game-library-tile--chosen { border-color: var(--accent); }
.game-library-tile-label {
  font-size: 0.7143rem;
  color: var(--text-dim);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.game-library-tile-name { font-size: 0.7857rem; padding: var(--space-sm) 0; word-break: break-word; }
.game-library-chosen {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  max-height: 22vh;
  overflow-y: auto;
}
.game-library-chosen-row {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  flex-wrap: wrap;
}
.game-library-chosen-name { flex: 1; min-width: 12ch; font-weight: 700; }
@media (max-width: 700px) {
  .game-library-grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); }
}

/* Table chat (see gameSession.js's chat section). A right-edge drawer with the same shape as the
   history drawer, so the two read as siblings rather than as two different ideas of "a panel". */
.game-chat-drawer {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 56; /* just above the history drawer, since it's opened on purpose over everything */
  width: min(380px, 92vw);
  background: var(--bg-panel);
  border-left: 1px solid var(--border);
  padding: var(--space-md);
  overflow: hidden;
}
.game-chat-inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  height: 100%;
}
/* The title bar doubles as the frame's drag handle once it's been detached, same as the history
   drawer's (see gameSession.js's makeDetachableFrame). */
.game-chat-title { margin: 0; font-size: 1rem; cursor: grab; touch-action: none; }

/* Left-edge splitter, injected by gameSession.js's makeDetachableFrame for a right-docked drawer --
   drag it left to widen. The native corner resize handle can't serve here: it grows an element
   rightward, but a `right: 0` drawer extends LEFTWARD as its width grows, so dragging right would
   widen it away from the drag. Wider than it looks (the ::before is the visible hairline) so it's
   actually grabbable, including with a finger. */
.game-frame-resizer {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 8px;
  cursor: ew-resize;
  touch-action: none; /* let pointermove drive the resize instead of the browser scrolling */
  z-index: 1; /* over the drawer's own content, so the grab area isn't stolen by the log beneath */
}
.game-frame-resizer::before {
  content: '';
  position: absolute;
  left: 3px;
  top: 0;
  bottom: 0;
  width: 2px;
  background: transparent;
  transition: background 0.12s ease;
}
.game-frame-resizer:hover::before,
.game-frame-resizer--dragging::before { background: var(--accent); }
/* Detached, the native corner handle takes over and this would just be a dead strip down the side.
   One rule per detachable drawer that has a splitter -- history and chat are the two right-edge
   drawers, and both use the same left-edge splitter for exactly the same reason. */
.game-chat-drawer--free .game-frame-resizer,
.game-history-drawer--free .game-frame-resizer { display: none; }

/* Detached from the right edge and living in the same absolute coordinate space as the player
   boards, so the two snap against each other (see snapPosition). Resizable like a board, too --
   `overflow: auto` replaces the docked drawer's `hidden`, which is what the resize handle needs. */
.game-chat-drawer--free {
  position: absolute;
  inset: auto;
  width: min(380px, 92vw);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  resize: both;
  overflow: auto;
  min-width: 260px;
  min-height: 220px;
}
.game-chat-log {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}
.game-chat-row {
  padding: var(--space-2xs) var(--space-xs);
  background: var(--bg-input);
  border-radius: var(--radius-sm);
  border-left: 3px solid var(--border);
}
/* Your own messages get the accent edge -- enough to scan who said what without colouring every row. */
.game-chat-row--mine { border-left-color: var(--accent); }
.game-chat-row-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-xs);
}
.game-chat-author { font-weight: 700; font-size: 0.7857rem; }
.game-chat-time { font-size: 0.7143rem; color: var(--text-dim); }
.game-chat-text { font-size: 0.8571rem; word-break: break-word; }
.game-chat-form { display: flex; gap: var(--space-xs); }
.game-chat-input { flex: 1; min-width: 0; }
/* Unread count on the topbar button. */
.game-chat-btn--unread { color: var(--accent); font-weight: 700; }

/* Social settings tab. */
.settings-toggle-row {
  display: flex;
  align-items: flex-start;
  gap: var(--space-sm);
  padding: var(--space-xs) 0;
}
.settings-toggle-row > span { display: flex; flex-direction: column; gap: 2px; }
.social-filter-preview {
  padding: var(--space-sm);
  background: var(--bg-input);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-size: 0.8571rem;
}
@media (max-width: 700px) {
  .game-chat-drawer { width: 100vw; }
}

.game-board-modal {
  /* `fixed` for the same reason as the history drawer above -- it must center on the VIEWPORT, not
     on the board overlay's scrollable content height. */
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.6);
}

/* Card Rulings panel in the friends dock (see friends.js's showRulingsFor) */
.rulings-results {
  max-height: 240px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  margin-top: var(--space-xs);
}
.rulings-card-name { font-weight: 700; }
.rulings-row {
  background: var(--bg-input);
  border-radius: var(--radius);
  padding: var(--space-xs) var(--space-sm);
}
.rulings-row-date { font-size: 0.7143rem; color: var(--text-dim); margin-bottom: 2px; }
.rulings-row-text { font-size: 0.8571rem; }

/* Settings -> Keybinds (see shared/public/keybinds.js) */
.keybind-list { display: flex; flex-direction: column; gap: var(--space-xs); margin-top: var(--space-xs); }
.keybind-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  padding: var(--space-xs) var(--space-sm);
  background: var(--bg-input);
  border-radius: var(--radius);
}
.keybind-label { display: flex; flex-direction: column; gap: 2px; }
.keybind-key { min-width: 96px; font-family: monospace; font-weight: 700; }
.keybind-key--capturing {
  border-color: var(--accent);
  color: var(--accent);
  animation: game-mana-await-pulse 1.1s ease-in-out infinite;
}

.game-configure-list { display: flex; flex-direction: column; gap: var(--space-2xs); }
.game-configure-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  padding: var(--space-xs) var(--space-sm);
  background: var(--bg-input);
  border-radius: var(--radius);
}
.game-configure-row-label { font-weight: 600; display: flex; align-items: center; gap: var(--space-xs); }
.game-configure-row-actions { display: flex; gap: var(--space-2xs); }
/* Drag-to-reorder states (see gameSession.js's renderConfigureList) -- the arrow buttons remain the
   keyboard/touch path, so this is purely a mouse affordance. */
.game-configure-row { cursor: grab; }
.game-configure-grip { color: var(--text-dim); cursor: grab; }
.game-configure-row--dragging { opacity: 0.5; cursor: grabbing; }
.game-configure-row--drop-target { outline: 2px dashed var(--accent); outline-offset: 2px; }

/* ---------- The Layout window (see gameSession.js's renderLayoutModal) ---------- */
.game-layout-templates {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  margin-bottom: var(--space-sm);
}
.game-layout-option {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  width: 100%;
  padding: var(--space-xs) var(--space-sm);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-panel);
  color: inherit;
  text-align: left;
  cursor: pointer;
  font: inherit;
}
.game-layout-option:hover { border-color: var(--accent-dim); }
.game-layout-option--active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
.game-layout-option-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.game-layout-option-name { font-weight: 600; }
.game-layout-option-hint { font-size: 0.7857rem; color: var(--text-dim); }

/* A miniature of the arrangement, built from the same kind of grid the real thing uses -- quicker
   to recognise than any wording of "two thirds by full height" could be. */
.game-layout-thumb {
  display: grid;
  gap: 2px;
  width: 54px;
  height: 38px;
  flex: none;
}
.game-layout-thumb i { display: block; background: var(--accent-dim); border-radius: 2px; }
.game-layout-thumb--auto { grid-template-columns: repeat(3, 1fr); }
.game-layout-thumb--auto i:nth-child(4) { grid-column: span 3; } /* the row that wrapped */
.game-layout-thumb--highlight { grid-template-columns: 2fr 1fr; grid-template-rows: repeat(3, 1fr); }
.game-layout-thumb--highlight i:first-child { grid-row: 1 / -1; background: var(--accent); }
.game-layout-thumb--grid { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); }

.game-layout-focus-row {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  margin-bottom: var(--space-sm);
}
.game-layout-focus-row select { flex: 1; min-width: 0; }
.game-layout-subhead { font-size: 0.9286rem; margin: var(--space-sm) 0 var(--space-2xs); }
.game-layout-saved { display: flex; flex-direction: column; gap: var(--space-2xs); }
.game-layout-saved-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  padding: var(--space-2xs) var(--space-xs);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.game-layout-saved-text { display: flex; flex-direction: column; min-width: 0; }
.game-layout-saved-name { font-weight: 600; overflow-wrap: anywhere; }
.game-layout-saved-meta { font-size: 0.7857rem; color: var(--text-dim); }
.game-layout-saved-actions { display: flex; gap: var(--space-2xs); flex: none; }
.game-layout-save-form {
  display: flex;
  gap: var(--space-xs);
  margin-top: var(--space-sm);
}
.game-layout-save-form input { flex: 1; min-width: 0; }

@media (max-width: 760px) {
  .game-board-players { grid-template-columns: 1fr; }
  /* The templates collapse into that single column with it. A highlighted seat given two thirds of
     a 375px screen leaves the others about 120px each, and four equal cells inside 78vh is a tenth
     of a phone screen apiece -- both unreadable, and neither is what the template was for. The
     chosen template stays in localStorage and comes back on a wide screen.

     Nothing here needs !important: syncTemplateClasses only ever writes a custom property inline
     (--game-tpl-rows), never these properties, so an ordinary rule outranks it -- which is exactly
     why the row count is passed as a custom property in the first place. */
  .game-board-players--tpl-highlight,
  .game-board-players--tpl-grid {
    grid-template-columns: 1fr;
    grid-template-rows: none;
    grid-auto-rows: auto;
    height: auto;
  }
  .game-board-players--tpl-highlight .game-player-board--focus {
    grid-column: auto;
    grid-row: auto;
  }
  /* Smaller baseline on a phone, but still scale-aware -- a flat px override here would ignore
     --game-card-scale entirely and undo per-board resizing on small screens. */
  .game-card {
    width: calc(38px * var(--game-card-size, 1));
    height: calc(53px * var(--game-card-size, 1));
    font-size: calc(9px * var(--game-card-size, 1));
  }
  .game-history-drawer { width: 100vw; }
  .game-result-title { font-size: 1.8rem; }
}

/* ================= Play board on a phone (see gameSession.js) ==================================
   The board was built mouse-first: freely draggable, resizable frames on a wide screen. Almost all
   of that degrades sensibly on a narrow one, but a few pieces actively break, and they're gathered
   here rather than scattered so the phone layout can be reasoned about in one place.

   The load-bearing one is free-layout. Dragging a board saves absolute x/y to localStorage, and
   those coordinates are from whatever screen it was arranged on -- open the same game on a phone and
   boards sit at x=780 on a 375px viewport, i.e. off-screen and unreachable, with no way back except
   finding a Reset layout button that is itself off-screen. So free positioning is switched OFF
   below the breakpoint and the boards fall back to a single readable column. Nothing is lost: the
   stored arrangement stays in localStorage and comes back on a wide screen. */
@media (max-width: 700px) {
  .game-board-overlay {
    padding: var(--space-sm);
    gap: var(--space-xs);
  }

  /* Free-arrange off: ignore the saved absolute positions entirely and stack the boards. The
     !important is deliberate and narrow -- gameSession.js writes left/top/width/height as INLINE
     style on each board node, which no ordinary rule can outrank. */
  .game-board-players--free {
    display: grid;
    grid-template-columns: 1fr;
    min-height: 0;
  }
  .game-board-players--free .game-player-board {
    position: static !important;
    left: auto !important;
    top: auto !important;
    width: auto !important;
    height: auto !important;
    margin: 0;
  }

  /* (Templates are switched off for a narrow screen too, at the 760px breakpoint above -- same
     reasoning, applied one breakpoint earlier because a two-column template runs out of readable
     width before free positioning runs out of screen.) */

  .game-player-board {
    /* 260px + the overlay's own padding overflows a 375px viewport, which is what turns a phone
       board into a sideways-scrolling page. */
    min-width: 0;
    min-height: 0;
    /* The corner resize handle needs a pointer to grab; on touch it only steals drags from the
       board's own scrolling. */
    resize: none;
  }
  /* Dragging a board by its header is a mouse affordance too -- and `touch-action: none` on the
     header means a finger starting there can't scroll the page, which on a phone reads as the board
     being stuck. Give scrolling back. */
  .game-player-header {
    cursor: default;
    touch-action: auto;
  }

  /* The topbar's eleven buttons wrap into a wall that pushes the board off-screen. One scrolling
     row keeps the board where it belongs. */
  .game-board-topbar-actions,
  .game-phase-strip {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: thin;
    -webkit-overflow-scrolling: touch;
  }
  .game-board-topbar-actions > *,
  .game-phase-pip {
    flex-shrink: 0;
  }

  /* Sticky above the on-screen keyboard and the iOS home indicator, rather than under them. */
  .game-board-action-bar {
    padding-bottom: calc(var(--space-sm) + env(safe-area-inset-bottom, 0px));
  }

  /* A camera view at 40vh plus a stacked board leaves nothing else visible. */
  .game-board-view { max-height: 30vh; }

  /* Full-width sheets rather than narrow drawers -- there's no room to dock beside anything. */
  .game-library-modal-inner {
    width: 100vw;
    max-height: 92vh;
    border-radius: 0;
  }
  .game-combat-row,
  .game-library-chosen-row {
    align-items: flex-start;
  }
}

/* ================= Spectating (see gameSession.js's render, server.js's session_subscribe) =======
   A viewer with no seat at this table. Hidden information needs no work here -- viewFor already
   redacts every hand and library for anyone who isn't that seat's owner, and a spectator is nobody's
   seat -- and the server refuses their actions outright, since applyAction has no seat to act on.

   What's left is not offering controls that can only fail. Hiding them here rather than in the
   renderer keeps the change to one class instead of a condition threaded through every control. */
.game-spectator-banner {
  padding: var(--space-xs) var(--space-sm);
  background: var(--bg-panel);
  border: 1px solid var(--accent-dim);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  font-size: 0.8571rem;
}
/* Shown to the PLAYERS: who is watching them. */
.game-watchers {
  font-size: 0.7857rem;
  color: var(--text-dim);
}

.game-board-overlay--spectating #game-board-next-phase-btn,
.game-board-overlay--spectating #game-board-next-turn-btn,
.game-board-overlay--spectating #game-board-roll-die-btn,
.game-board-overlay--spectating #game-board-flip-coin-btn,
.game-board-overlay--spectating #game-board-concede-btn,
.game-board-overlay--spectating #game-board-declare-btn,
.game-board-overlay--spectating #game-board-leave-table-btn,
.game-board-overlay--spectating .game-board-action-bar,
.game-board-overlay--spectating .game-stack-actions,
.game-board-overlay--spectating .game-combat-panel,
.game-board-overlay--spectating #game-chat-form {
  display: none !important;
}
/* Cards stay hoverable (that's the point of watching) but nothing is selectable or draggable. */
.game-board-overlay--spectating .game-card {
  cursor: default;
}
.game-board-overlay--spectating .game-player-header {
  cursor: default;
}

/* ===================== Goldfish mode + Tutorial coach (see gameCoach.js) ===================== */

/* The London mulligan's bottoming step (see gameSession.js's renderMulliganPanel). Fixed and
   centered for the same reason .game-choice-panel above is: while cards are owed to the bottom the
   SERVER refuses every other action from this player, so this is not one panel among several -- it
   is the only thing that makes the game move again. z-index sits just under the choice panel's 515,
   since the two can never be outstanding at once but the ordering should still be deliberate. */
.game-mulligan-panel {
  position: fixed;
  left: 50%;
  top: 12%;
  transform: translateX(-50%);
  z-index: 514;
  width: min(46rem, 94vw);
  max-height: 76vh;
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  padding: var(--space-md);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  background: var(--bg-panel);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.game-mulligan-prompt { font-weight: 700; }
.game-mulligan-grid { max-height: 52vh; }

/* --- the coach panel ---------------------------------------------------------------------------
   A right-edge column rather than a centered modal: unlike a pending choice, coaching never blocks
   the game, and the entire point is to read it WHILE looking at the board it describes. */
.game-coach-panel {
  position: fixed;
  right: var(--space-sm);
  top: 6rem;
  bottom: var(--space-sm);
  z-index: 512;
  width: min(24rem, 92vw);
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  padding: var(--space-sm);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  background: var(--bg-panel);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
  overflow: hidden;
}
.game-coach-header { display: flex; align-items: center; gap: var(--space-xs); }
.game-coach-title { flex: 1; margin: 0; font-size: 1rem; }
.game-coach-auto-label {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 0.7143rem;
  color: var(--text-dim);
  white-space: nowrap;
}
.game-coach-status { font-size: 0.7857rem; color: var(--text-dim); }
.game-coach-headline { font-weight: 700; line-height: 1.35; }
.game-coach-plan { font-size: 0.8571rem; color: var(--text-dim); line-height: 1.45; }
.game-coach-steps {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
}
.game-coach-step {
  display: flex;
  align-items: flex-start;
  gap: var(--space-xs);
  width: 100%;
  padding: var(--space-xs);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg-input);
  color: var(--text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.game-coach-step:hover { border-color: var(--accent-dim); }
.game-coach-step--active { border-color: var(--accent); background: var(--bg-panel); }
.game-coach-step-num {
  flex: none;
  width: 1.5rem;
  height: 1.5rem;
  line-height: 1.5rem;
  text-align: center;
  border-radius: 50%;
  background: var(--accent-dim);
  color: var(--text);
  font-size: 0.7143rem;
  font-weight: 700;
}
.game-coach-step-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.game-coach-step-label { font-weight: 700; font-size: 0.8571rem; }
.game-coach-step-why { font-size: 0.7857rem; color: var(--text-dim); line-height: 1.4; }
.game-coach-step-principle {
  font-size: 0.7143rem;
  color: var(--accent);
  font-style: italic;
  line-height: 1.4;
}
/* The habit a goldfish game would otherwise teach badly (see skill/pilot/references/goldfishing.md)
   -- called out in its own box precisely because it's the part a player skims past. */
.game-coach-watchout {
  padding: var(--space-xs);
  border-left: 3px solid var(--warn);
  background: var(--bg-input);
  border-radius: var(--radius-sm);
  font-size: 0.7857rem;
  line-height: 1.4;
}
.game-coach-actions { display: flex; gap: var(--space-xs); }
.game-coach-actions .btn { flex: 1; }
.game-coach-ask { display: flex; gap: var(--space-xs); }
.game-coach-question {
  flex: 1;
  min-width: 0;
  padding: var(--space-2xs) var(--space-xs);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg-input);
  color: var(--text);
  font: inherit;
  font-size: 0.8571rem;
}

/* --- the spotlight -----------------------------------------------------------------------------
   Fixed, and positioned every animation frame from the target's measured rect (see gameCoach.js's
   place()), so it tracks a card through the board's FLIP animations, free-layout drags and its own
   panes' scrolling. pointer-events: none throughout -- the ring must never intercept the very click
   it is asking for. */
.game-coach-spotlight {
  position: fixed;
  z-index: 513;
  pointer-events: none;
  border: 2px solid var(--accent);
  border-radius: var(--radius);
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.28), 0 0 18px 2px var(--accent);
  animation: game-coach-pulse 1.6s ease-in-out infinite;
}
@keyframes game-coach-pulse {
  0%, 100% { box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.28), 0 0 12px 1px var(--accent); }
  50% { box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.28), 0 0 26px 6px var(--accent); }
}
.game-coach-callout {
  position: fixed;
  z-index: 514;
  width: min(22rem, 88vw);
  padding: var(--space-xs) var(--space-sm);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  background: var(--bg-panel);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  animation: game-coach-callout-in 0.18s ease-out;
}
@keyframes game-coach-callout-in {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: none; }
}
.game-coach-callout-title { font-weight: 700; margin-bottom: 2px; }
.game-coach-callout-why { font-size: 0.8571rem; color: var(--text-dim); line-height: 1.45; }
.game-coach-callout-principle {
  margin-top: var(--space-2xs);
  font-size: 0.7857rem;
  color: var(--accent);
  font-style: italic;
}

/* Anyone who asked not to be moved at gets the ring and the callout without the animation on
   either -- the highlight is information, the pulsing is decoration. */
@media (prefers-reduced-motion: reduce) {
  .game-coach-spotlight { animation: none; }
  .game-coach-callout { animation: none; }
}

@media (max-width: 700px) {
  /* On a phone the board is already the whole screen, so the coach takes the bottom half of it
     rather than a side column there's no room for. */
  .game-coach-panel {
    left: var(--space-2xs);
    right: var(--space-2xs);
    top: auto;
    bottom: var(--space-2xs);
    width: auto;
    max-height: 58vh;
  }
  .game-mulligan-panel { top: var(--space-xs); width: 96vw; padding: var(--space-sm); }
  .game-coach-callout { width: min(20rem, 92vw); }
}

/* The Coach button while its panel is open -- a pressed state, so the toggle reads as a toggle. */
.game-board-topbar-actions .btn-active { outline: 2px solid var(--accent); outline-offset: 1px; }
