/*
 * Kitchen Table -- visual system
 *
 * Design system: design-system/kitchen-table/MASTER.md
 * Style: Modern Dark (cinematic, ambient light, glassmorphism)
 * Palette: felt green + gold on deep slate
 * Motion: expo.out -- cubic-bezier(0.16, 1, 0.3, 1)
 *
 * Everything that moves animates transform/opacity only, so it stays on the
 * compositor thread. All motion is disabled under prefers-reduced-motion.
 */

@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Cinzel:wght@500;600;700&display=swap');

/*
 * `hidden` means hidden, whatever else this sheet says.
 *
 * The UA's own `[hidden] { display: none }` has no more weight than a class, so
 * ANY `display:` an element also matches overrides it -- silently, and only for
 * the elements that happen to have one. The client toggles the `hidden`
 * PROPERTY in half a dozen places and every one of them was a coin flip:
 *
 *   - `.roll-choice` is `display: flex`, so "Play first / Draw first" sat on
 *     screen for the whole roll, beside a caption reading "Click your die".
 *     Clicking one answered a question the server had not asked and came back
 *     "there is nothing to choose right now".
 *   - `#music-row` is a `.field-row`, also `display: flex`. It is hidden when
 *     no tracks are installed -- which is every fresh clone, since audio/ is
 *     gitignored -- so a player with no music would have been shown the music
 *     controls anyway.
 *   - `.watch-bar` had the same bug and was patched on its own, which is what
 *     made the pattern worth generalising rather than fixing a third time.
 *
 * `!important` on purpose: the point is that no later rule can win, because
 * "later rule wins" is precisely the failure.
 */
[hidden] { display: none !important; }

/*
 * ===========================================================================
 * THE LANGUAGE
 * ===========================================================================
 *
 * The palette is not invented. It is sampled from the game's own paintings --
 * the plates in web/assets, quantised into colour buckets. The two lobby plates
 * agree almost exactly:
 *
 *   ground     #031219  #121418   the dark the whole scene sits in
 *   deep       #142a33  #172834   fog and stone in shadow
 *   stone      #143546  #183247   lit rock, the middle distance
 *   arcane     #2e5b7a  #1c4362   where the magic starts to read as blue
 *   arcane-lit #5bacd4  #4b95bc   the spell itself, 8% of the frame
 *   flare      #b0ebf1  #a7ecf4   the brightest 3% -- the strike, the ward
 *
 * So the interface is lit the way the art is lit: a blue-black world, cold
 * blue-white where power is being used, and a small amount of ember gold for
 * fire and for gilding. Gold is the only warm thing in a cold picture, which is
 * exactly why it must stay rare.
 *
 * TWO LIGHTS, TWO JOBS. The rule that keeps this from becoming decoration:
 *
 *   ARCANE (blue-white) is the system speaking. Focus rings, the step you are
 *   on, selection, targeting, anything live or in progress. It is a state.
 *
 *   EMBER (gold) is you, and ornament. Primary actions, the crest, the
 *   filigree, titles. It is a commitment or a flourish.
 *
 * An element never wears both at once, and green/red stay for their two
 * meanings only -- ready and destructive. Everything else is neutral.
 *
 * SURFACES RISE OUT OF THE DARK, in four steps, each with its own edge:
 *
 *   void   the page ground, behind everything
 *   plate  a panel laid on it            top hairline of light
 *   well   sunk into a plate (inputs)    inner shadow, lower lip
 *   relief raised off a plate (buttons)  bright top lip, dark foot
 *
 * Edges are hairlines of *light* -- a low-alpha arcane or ember -- never grey
 * borders. Neutrals carry a cyan bias for the same reason: a pure grey next to
 * this art reads as unpainted.
 * ===========================================================================
 */
:root {
  /* --- the dark ------------------------------------------------------- */
  --void: #05101a;            /* never pure black -- avoids OLED smear */
  --deep: #0b1b26;
  --stone: #143546;
  --bg: var(--void);

  /* --- surfaces ------------------------------------------------------- */
  /* The bare table is deliberately dark and desaturated: the playmats are the
     colourful surfaces, and they only read as such if the wood under them
     doesn't compete. */
  --felt: #0a1512;
  --felt-lit: #10241b;
  --panel: rgba(14, 30, 42, 0.82);
  --panel-solid: #0e1e2a;
  --raised: #17303f;
  --line: rgba(146, 205, 232, 0.12);
  --line-strong: rgba(146, 205, 232, 0.24);

  /* --- ink ------------------------------------------------------------ */
  /* Cyan-biased, not grey: these sit on blue-black and must look lit by the
     same storm. Raised across the board -- the old --dim and --faint were low
     enough that secondary copy read as disabled rather than as secondary, and
     on artwork that is the difference between "quiet" and "unreadable".
     Contrast floors on the panel ground: text 15:1, dim 9:1, faint 6:1. */
  --text: #f2fbff;
  --dim: #b8d2e2;
  --faint: #8aa6b9;

  /* --- lettering ------------------------------------------------------ */
  /*
   * Two faces, with one job each. Inter is the interface: labels, values,
   * anything you read to operate the thing. Cinzel is inscriptional roman
   * capitals -- carved-in-stone lettering -- and it is used only for titles and
   * section headings, where the job is to say "this is a game" before anything
   * is read at all. Uniform 13px sans on every line is what made this read as a
   * form; the fix is not bigger text, it is text with a hierarchy.
   *
   * The fallback chain matters: a self-hosted server on a LAN with no internet
   * never gets the webfont, so it degrades to whatever serif the machine has
   * rather than to the body face.
   */
  --display: Cinzel, 'Trajan Pro', 'Iowan Old Style', Palatino, Georgia, serif;

  /* --- the two lights ------------------------------------------------- */
  /* Arcane: the system speaking. Sampled off the lightning in the plate rather
     than off the spell glow -- the bolt is the brightest, coldest thing in the
     picture and it is what the eye already goes to. */
  --arcane: #63b8e4;
  --arcane-lit: #8ad6f7;
  --flare: #d6f4ff;           /* the core of the bolt: the brightest 3% */

  /* Ember: your actions, and gilding. The only warm thing in a cold picture --
     the torch at the treeline, pushed warmer and brighter so a primary button
     reads as fire beside all that blue. */
  --ember: #e8a24c;
  --ember-lit: #ffc978;
  --gold: var(--ember);       /* the old names, kept so nothing breaks */
  --gold-lit: var(--ember-lit);

  /* --- meanings ------------------------------------------------------- */
  /* Two semantic colours, and they mean exactly one thing each: ready, and
     destructive. Both pulled toward the palette so they belong to the scene --
     jade rather than lime, blood rather than fire engine. */
  --green: #197a49;
  --green-lit: #3fbe86;
  --red: #c0342f;
  --red-lit: #f0736b;
  --blue: var(--arcane);
  --violet: #8b5cf6;

  /* --- geometry ------------------------------------------------------- */
  --card-w: 92px;        /* hand */
  /* Battlefield cards are smaller than hand cards: a developed board holds far
     more permanents than a hand holds cards, and two rows have to fit inside a
     mat. Sized so creature row + land row + header fit a 214px mat. */
  --bf-card-w: 62px;
  --bf-land-w: 54px;     /* lands, shingled */
  --hand-overlap: 46px;  /* how far the hand rides up over its own mat */
  --zone-rail-w: 58px;   /* deck/graveyard/exile down the left edge of a mat */
  --r-sm: 6px;
  --r-md: 10px;
  --r-lg: 16px;

  /* --- motion --------------------------------------------------------- */
  --ease: cubic-bezier(0.16, 1, 0.3, 1);   /* expo.out */
  --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
  --t-fast: 150ms;
  --t-med: 260ms;
  --t-slow: 520ms;

  /* --- elevation ------------------------------------------------------ */
  --lift: 0 12px 28px rgba(0, 0, 0, 0.55);
  --lift-lg: 0 22px 50px rgba(0, 0, 0, 0.7);
  --glow-gold: 0 0 0 2px var(--ember), 0 0 24px rgba(232, 162, 76, 0.6);
  --glow-red: 0 0 0 2px var(--red), 0 0 22px rgba(192, 52, 47, 0.5);
  --glow-blue: 0 0 0 2px var(--arcane-lit), 0 0 24px rgba(138, 214, 247, 0.55);
  --glow-arcane: 0 0 0 1px rgba(138, 214, 247, 0.6), 0 0 20px rgba(99, 184, 228, 0.4);
}

* { box-sizing: border-box; }

/* The chrome the browser supplies gets the same treatment as the chrome we
   draw, or the seams show: a default blue selection and a grey scrollbar are
   both louder than anything in this palette. */
::selection { background: rgba(99, 184, 228, 0.36); color: var(--text); }
* { scrollbar-width: thin; scrollbar-color: rgba(99, 184, 228, 0.4) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: rgba(91, 172, 212, 0.28);
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: rgba(91, 172, 212, 0.5); background-clip: content-box; }

html {
  height: 100%;
  background: var(--bg);
}
/* body must stay transparent: its ::before carries the playmat at z-index -2,
   and a negative-z-index child paints *below* its parent's background box.
   An opaque body background would hide the table entirely. */
body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: transparent;
  color: var(--text);
  font: 400 13px/1.5 Inter, "Segoe UI", system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* Ambient table light. Two slow-drifting radial blobs over felt.
   When a generated playmat is selected it sits underneath, dimmed and
   desaturated so board pieces stay readable on top of it. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  background:
    radial-gradient(ellipse 70% 55% at 50% 62%, var(--felt-lit) 0%, var(--felt) 45%, transparent 78%),
    radial-gradient(ellipse 90% 70% at 50% 50%, #0e1a2b 0%, var(--bg) 70%);
}
/* Playmats are NOT the page background -- each one belongs to a seat and is
   painted onto that player's own zone (see .matted below), so everyone can see
   what everyone else brought to the table. The page behind stays neutral. */
body::after {
  content: "";
  position: fixed;
  inset: -20%;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(circle calc(30 * var(--vw, 1vw)) at 22% 28%, rgba(53, 180, 100, 0.10), transparent 60%),
    radial-gradient(circle calc(26 * var(--vw, 1vw)) at 78% 72%, rgba(217, 164, 65, 0.09), transparent 60%);
  animation: drift 34s var(--ease) infinite alternate;
}
@keyframes drift {
  from { transform: translate3d(-2%, -1%, 0) scale(1); }
  to   { transform: translate3d(2%, 1.5%, 0) scale(1.06); }
}

/* -------------------------------------------------------------- controls -- */
button {
  font: 500 13px/1 Inter, system-ui, sans-serif;
  color: var(--text);
  /* Secondary controls are metal lit by the storm rather than by the torch:
     same construction as the primary, without the brass. The top lip is what
     stops a button reading as a div. */
  background: linear-gradient(180deg, #21506b, #143246);
  border: 1px solid rgba(99, 184, 228, 0.30);
  border-radius: var(--r-sm);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07), 0 1px 2px rgba(0, 0, 0, 0.45);
  padding: 9px 14px;
  cursor: pointer;
  transition: background var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
}
button:hover:not(:disabled) { background: linear-gradient(180deg, #2b638466, #1a3f58); border-color: rgba(138, 214, 247, 0.5); }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { opacity: 0.38; cursor: not-allowed; }
/* Focus is the system saying where it is, so it is arcane. Gold would read as
   "this is the action to take", which is a different statement. */
button:focus-visible,
input:focus-visible, select:focus-visible, textarea:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--arcane-lit);
  outline-offset: 2px;
}

/* Struck brass: a bright top lip, a warm face, a dark foot, and a real press.
   The old flat two-stop gradient is the single most "web form" thing on the
   screen, and it is also the control the eye goes to first. */
button.primary {
  background: linear-gradient(180deg, #ffe6ae 0%, var(--gold-lit) 18%, var(--gold) 62%, #a9761f 100%);
  color: #2a1d05;
  border-color: rgba(255, 232, 180, 0.55);
  font-weight: 600;
  letter-spacing: 0.2px;
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.28);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55),
              inset 0 -1px 0 rgba(0, 0, 0, 0.22),
              0 2px 0 #6f4d13, 0 4px 12px rgba(0, 0, 0, 0.5);
}
button.primary:hover:not(:disabled) {
  background: linear-gradient(180deg, #fff0c8 0%, #f5cf80 18%, #e2ad4a 62%, #b58224 100%);
  border-color: rgba(255, 240, 200, 0.75);
}
/* Pressing it moves it down onto its own shadow rather than scaling it. */
button.primary:active:not(:disabled) {
  transform: translateY(2px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4),
              inset 0 2px 6px rgba(0, 0, 0, 0.3),
              0 0 0 #6f4d13, 0 1px 4px rgba(0, 0, 0, 0.5);
}
button.primary:hover:not(:disabled) { filter: brightness(1.08); background: linear-gradient(180deg, #f7d488, var(--gold-lit)); }
button.danger { border-color: rgba(220, 38, 38, 0.5); color: var(--red-lit); }
button.danger:hover:not(:disabled) { background: rgba(220, 38, 38, 0.14); }
button.small { padding: 5px 10px; font-size: 11.5px; }

/* Fields are wells, not boxes: sunk into the surface with a dark inner shadow
   and a light lower lip, so they read as part of a made object rather than as
   rows on a form. */
input, textarea, select {
  font: inherit;
  color: var(--text);
  background: linear-gradient(180deg, rgba(3, 7, 12, 0.9), rgba(9, 15, 24, 0.72));
  border: 1px solid rgba(255, 255, 255, 0.09);
  border-radius: var(--r-sm);
  padding: 10px 12px;
  width: 100%;
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.55), 0 1px 0 rgba(255, 255, 255, 0.05);
  transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}
/*
 * The options in a native dropdown are painted by the OS, not by the page: on
 * Windows the popup ground stays light while `color` is inherited from the
 * select, which is our near-white -- so every option was white on white and
 * the deck list could not be read at all. `.skill-select option` already knew
 * this; nothing else did.
 */
select option, select optgroup {
  background: var(--panel-solid);
  color: var(--text);
}

input:hover, textarea:hover, select:hover { border-color: rgba(91, 172, 212, 0.38); }
input:focus, textarea:focus, select:focus {
  border-color: rgba(91, 172, 212, 0.7);
  box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.55), 0 0 0 3px rgba(91, 172, 212, 0.16);
}
textarea { resize: vertical; font-family: ui-monospace, Consolas, monospace; font-size: 12px; line-height: 1.6; }

/* ---------------------------------------------------------------- shell -- */
#app { display: grid; grid-template-rows: 52px 1fr; height: 100%; }

#topbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 16px;
  /* Opaque, and lit from above like every other plate. The blur here was a
     full-width GPU pass repainted whenever the table moved underneath it, and
     it bought nothing: the bar is at the top of the screen where the felt is
     darkest anyway. */
  background: linear-gradient(180deg, #10242f, #0a1a24);
  border-bottom: 1px solid var(--line);
  box-shadow: inset 0 1px 0 rgba(176, 235, 241, 0.10), 0 2px 12px rgba(0, 0, 0, 0.5);
  z-index: 40;
}
#topbar .title {
  font-family: var(--display);
  font-weight: 600;
  font-size: 14px;
  letter-spacing: 2.6px;
  color: var(--ember-lit);
  text-transform: uppercase;
}
#topbar .spacer { flex: 1; }

.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 5px 12px;
  font-size: 12px;
  color: var(--dim);
  white-space: nowrap;
}
.pill strong { color: var(--text); font-weight: 600; }
.pill.hot {
  border-color: rgba(217, 164, 65, 0.55);
  color: var(--gold-lit);
  background: rgba(217, 164, 65, 0.10);
}
.pill.hot strong { color: var(--gold-lit); }

#main { display: grid; grid-template-columns: 1fr 296px; overflow: hidden; }

/* The board reads as a table seen from your seat: opponents' mats across from
   you, bare table through the middle, your mat in front of you. The centre row
   takes the slack so both sets of mats stay the same size as each other. */
#table {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;   /* ring, then your seat */
  position: relative;
  overflow: hidden;
  /* The surface everything else sits on. Deliberately dark and featureless --
     mats, cards and counters all stack on top, so any strong grain here would
     become noise behind a card rather than atmosphere. */
  background: #0a0d12 url(/assets/table-surface.png) center / cover no-repeat;
}
/* Knocked back so the wood reads as a room the board is in, not as a picture
   competing with the board. */
#table::before {
  content: "";
  position: absolute; inset: 0;
  pointer-events: none;
  background:
    radial-gradient(ellipse at 50% 45%, rgba(0,0,0,0.25), rgba(0,0,0,0.72) 78%),
    linear-gradient(180deg, rgba(5,8,12,0.55), rgba(5,8,12,0.35) 40%, rgba(5,8,12,0.6));
  z-index: 0;
}
#ring, #me { position: relative; z-index: 1; }

/* Bare table in the middle of the ring -- the hook for future "environment"
   tables, and where shared objects would sit. */
#table-center {
  grid-area: c;
  position: relative;
  display: grid;
  place-items: center;
  min-height: 0;
}
/* The seam itself: a brass inlay set into the boards, not a drawn rule. Scaled
   to the width and cropped vertically so the metal line lands in the band
   whatever height the strip ends up. */
#table-center {
  background: url(/assets/table-seam.png) center / 100% auto no-repeat;
}
#table-center::before {
  content: "";
  width: min(58%, 460px);
  height: 1px;
  /* The hairline that leans toward whoever is acting still sits on top of the
     inlay, so the seam says whose turn it is as well as where the line is. */
  background: linear-gradient(90deg, transparent, rgba(217, 164, 65, 0.22), transparent);
  opacity: 0.6;
}

/* ----------------------------------------------------------- opponents -- */
/* The seating ring. Opponents occupy edge positions -- left, top, right -- the
   way people actually sit round a table, rather than stacking in a list. Named
   areas keep the arrangement declarative; the client assigns each opponent a
   slot in turn order, going clockwise from your seat at the bottom. */
#ring {
  display: grid;
  gap: 10px;
  padding: 12px 14px 0;
  min-height: 0;
  /* The centre keeps a visible strip of bare table without hogging space the
     seats need. Side seats span BOTH rows so they are full-height columns,
     which is what makes the arrangement read as a ring rather than a header. */
  /* The centre is a seam, not a room. It used to take 0.5fr -- 118px of a
     900px screen, 13% of the window doing nothing -- while the board it
     separated got 18%. It is contested ground: wide enough to read as a gap
     between two armies, and not one pixel wider. */
  grid-template-rows: minmax(0, 1fr) 34px;
  /* The active seat's column swells; animate the track change so the table
     visibly turns its attention rather than snapping. */
  transition: grid-template-columns var(--t-slow) var(--ease);
}

#ring.seats-1 {
  grid-template-columns: 1fr;
  grid-template-areas: "t" "c";
}
#ring.seats-2 {
  grid-template-columns: 1fr 1fr;
  grid-template-areas: "tl tr" "c  c";
}
#ring.seats-3 {
  grid-template-columns: minmax(200px, 0.9fr) minmax(0, 1.4fr) minmax(200px, 0.9fr);
  grid-template-areas: "l t r" "l c r";
}
#ring.seats-4 {
  grid-template-columns: minmax(188px, 0.8fr) 1fr 1fr minmax(188px, 0.8fr);
  grid-template-areas: "l tl tr r" "l c  c  r";
}
#ring.seats-5 {
  grid-template-columns: minmax(176px, 0.7fr) 1fr 1fr 1fr minmax(176px, 0.7fr);
  grid-template-areas: "l tl t tr r" "l c  c c  r";
}
#ring > .pod[data-slot="l"]  { grid-area: l;  }
#ring > .pod[data-slot="r"]  { grid-area: r;  }
#ring > .pod[data-slot="t"]  { grid-area: t;  }
#ring > .pod[data-slot="tl"] { grid-area: tl; }
#ring > .pod[data-slot="tr"] { grid-area: tr; }

/* Hover-to-expand. A dense seat lifts over the table centre at readable size
   while you look at it, then settles back -- no scrollbar, no click required,
   and the table underneath is the empty middle rather than another player's
   board. Pointer-fine only, so touch devices are unaffected. */
@media (hover: hover) and (pointer: fine) {
  #ring > .pod {
    transition: transform var(--t-med) var(--ease), box-shadow var(--t-med) var(--ease);
  }
  /* Only when there is actually a ring. With one opponent the seat is the
     whole top half of the table, and lifting it 1.28x on hover reads as the
     board lurching -- reported from play as "I hover over the bot's side and
     the zoom goes wonky". The affordance is for a crowded four-player table
     where a seat is genuinely too small to read, so it is gated on there
     being more than one seat rather than on one seat being dense. */
  #ring:has(> .pod + .pod) > .pod.cramped:hover {
    z-index: 40;
    box-shadow: var(--lift-lg), 0 0 0 1px rgba(217, 164, 65, 0.5);
  }
  #ring:has(> .pod + .pod) > .pod.cramped[data-slot="l"]:hover  { transform: scale(1.42); transform-origin: left center; }
  #ring:has(> .pod + .pod) > .pod.cramped[data-slot="r"]:hover  { transform: scale(1.42); transform-origin: right center; }
  #ring:has(> .pod + .pod) > .pod.cramped[data-slot="t"]:hover  { transform: scale(1.28); transform-origin: center top; }
  #ring:has(> .pod + .pod) > .pod.cramped[data-slot="tl"]:hover { transform: scale(1.32); transform-origin: left top; }
  #ring:has(> .pod + .pod) > .pod.cramped[data-slot="tr"]:hover { transform: scale(1.32); transform-origin: right top; }
}

/* Side seats are tall and narrow, so their permanents wrap into columns. */
#ring > .pod[data-slot="l"] .bf-row,
#ring > .pod[data-slot="r"] .bf-row { flex-wrap: wrap; }
#ring > .pod[data-slot="l"] .bf-row.lands,
#ring > .pod[data-slot="r"] .bf-row.lands { flex-wrap: nowrap; }

#opponents.cols-2 { grid-template-columns: repeat(2, 1fr); }
#opponents.cols-3 { grid-template-columns: repeat(3, 1fr); }

.pod {
  position: relative;
  background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.012));
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 10px 12px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  transition: border-color var(--t-med) var(--ease), box-shadow var(--t-med) var(--ease);
}

/* ---------------------------------------------------------- seat mats -- */
/* A player's chosen mat, laid under their own zone. The art sits in a
   pseudo-element so the scrim can sit between it and the cards without
   dimming the cards themselves. */
.matted { position: relative; }
.matted::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  /* Longhands, not the `background` shorthand: a var() substitution that fails
     inside a shorthand invalidates the whole declaration at computed-value
     time, silently dropping the image and leaving `background: none`.
     Light scrim only -- several mats are already dark and an aggressive scrim
     crushes them to black. */
  background-image:
    linear-gradient(180deg, rgba(8, 13, 22, 0.18), rgba(8, 13, 22, 0.34)),
    var(--seat-mat, none);
  background-size: cover, cover;
  background-position: center, center;
  background-repeat: no-repeat, no-repeat;
  /* Inner shadow reads as a physical mat lying on the table. */
  box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.55);
  pointer-events: none;
}
.matted > * { position: relative; z-index: 1; }

/* The seat taking its turn. Paired with the widened grid column, this is the
   "spotlight" -- attention follows the turn without anyone changing seats. */
.pod.active {
  border-color: rgba(217, 164, 65, 0.55);
  box-shadow:
    inset 0 0 60px rgba(217, 164, 65, 0.10),
    0 0 0 1px rgba(217, 164, 65, 0.28),
    0 10px 34px rgba(0, 0, 0, 0.45);
}
/* Uses ::after, not ::before -- ::before carries the seat's playmat, and a
   `background` shorthand here would wipe the mat image out from under it. */
.pod.priority::after {
  content: "";
  position: absolute;
  left: 0; top: 14px; bottom: 14px;
  z-index: 2;
  width: 3px;
  border-radius: 3px;
  background: var(--gold);
  box-shadow: 0 0 12px var(--gold);
}
.pod.dead { opacity: 0.4; filter: grayscale(0.85); }
.pod.targetable {
  border-color: var(--gold);
  box-shadow: var(--glow-gold);
  cursor: crosshair;
  animation: pulse-target 1.6s var(--ease) infinite;
}
.pod.defending { border-color: var(--red); box-shadow: var(--glow-red); }

@keyframes pulse-target {
  0%, 100% { box-shadow: 0 0 0 2px var(--gold), 0 0 16px rgba(217,164,65,0.4); }
  50%      { box-shadow: 0 0 0 2px var(--gold), 0 0 30px rgba(217,164,65,0.75); }
}

/* Compact: this strip was eating vertical space the board needed. Only the
   .pod-id cluster inside it is a click target. */
.pod-head {
  display: flex; align-items: center; gap: 11px;
  margin-bottom: 4px;
  flex: 0 0 auto;
}
/* Your own seat while a trigger is asking for a player. Your mat has no pod
   border to glow, so the identity strip carries the highlight instead. */
.pod-head.targetable {
  border-radius: var(--r-md);
  padding: 4px 8px;
  box-shadow: var(--glow-gold);
  cursor: crosshair;
  animation: pulse-target 1.6s var(--ease) infinite;
}
.pod-id { display: flex; align-items: center; gap: 9px; min-width: 0; }
/* A smaller ring on the mats: at 58px the identity strip was taking almost a
   quarter of the mat's height away from the board. Full size is kept in the
   inspect overlay, where there is room for it. */
.pod-head .life-ring { width: 42px; height: 42px; }
/*
 * The life counter is a rendered object now, not a drawn one.
 *
 * A 3D model gave what neither a prompt nor a gradient could: exact geometry at
 * an exact angle, lit to match the rest of the table, with real alpha. The
 * camera is orthographic so all three wheel faces are the same shape -- under
 * perspective each would be a slightly different size and flat digits laid over
 * them would sit wrong.
 *
 * The digits stay CSS, because they have to roll and the number has to be
 * exact. Same split as the dice: the art supplies the object, the text supplies
 * the truth.
 */
.life-barrel {
  position: relative;
  z-index: 1;
  width: 4.1em;
  aspect-ratio: 768 / 568;                 /* the render's own proportions */
  background: url(/assets/life-housing.png) center / contain no-repeat;
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  line-height: 1;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.65));
}
.life-sign {
  position: absolute; left: 1%; top: 50%;
  transform: translateY(-50%);
  color: #fff; z-index: 2;
}

/*
 * The window, measured off the render: the wheel faces between the two end
 * plates. It spans all three sculpted rows on purpose -- the model carries a
 * partial face above and below the lit one, which is exactly where a real
 * counter shows the neighbouring digits mid-roll, so the strip's own overflow
 * does that for nothing.
 */
/*
 * Just the lit centre face. Spanning all three sculpted rows was a nice idea and
 * unreadable in practice: three digits of equal brightness stacked in sixteen
 * pixels reads as a grid of numbers, not as a value. The rows above and below
 * stay bare modelled faces, which is what they look like on the real object
 * between clicks.
 */
.life-window {
  position: absolute;
  left: 26%; top: 26.4%;
  width: 48.6%; height: 25.5%;
  display: flex;
  overflow: hidden;
}
.life-wheel {
  position: relative;
  overflow: hidden;
  flex: 1 1 0;
  height: 100%;
  /* No background of its own: the rendered face behind it is the background. */
}
/* Only the falloff, so digits sink into the housing's shadow instead of sitting
   flat on it. The curve itself is modelled. */
.life-wheel::after {
  content: "";
  position: absolute; inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg,
    rgba(0,0,0,0.60), rgba(0,0,0,0) 34%,
    rgba(0,0,0,0) 66%, rgba(0,0,0,0.60));
}
/*
 * Ten digits, one window. The strip is ten windows tall and each digit is a
 * tenth of it, so exactly one shows and a step of one digit is a tenth of the
 * strip. It was 300% tall holding ten spans of 33.333% -- a thousand percent of
 * content in a three hundred percent box -- so flex crushed them and several
 * digits appeared at once.
 */
.life-strip {
  display: flex; flex-direction: column;
  height: 1000%;
  transform: translateY(calc(var(--d, 0) * -10%));
  transition: transform 420ms cubic-bezier(0.22, 0.9, 0.24, 1);
  will-change: transform;
}
.life-strip span {
  height: 10%;
  display: grid; place-items: center;
  font-size: 0.52em;
  color: #ffffff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
}
/* The lit digits carry a soft glow so the value lifts off the modelled face --
   the number is the one thing on this object anyone actually reads. */
.life-wheel:not(.lead-zero) .life-strip span {
  text-shadow:
    0 0 6px rgba(214, 236, 255, 0.55),
    0 0 2px rgba(255, 255, 255, 0.75),
    0 1px 2px rgba(0, 0, 0, 0.9);
}
/* A leading nought is on the wheel but is not the number. Dimmed to slate, and
   deliberately not glowing, so the lit digits read as the value on their own --
   020 should be seen as 20 without anyone having to work it out. */
.life-wheel.lead-zero .life-strip span {
  color: rgba(190, 210, 228, 0.30);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
}

/*
 * Taking damage. Runs once, on the render that carries the loss -- gaining life
 * is good news and can be read at leisure, but losing it is the thing you have
 * to notice, and this is where the eye already is.
 */
.life-barrel.hit { animation: life-hit 900ms var(--ease); }
.life-barrel.hit .life-wheel:not(.lead-zero) .life-strip span {
  animation: life-hit-digits 900ms var(--ease);
}
@keyframes life-hit {
  0%   { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.65)); }
  12%  { filter: drop-shadow(0 0 10px rgba(226, 72, 60, 0.95)) drop-shadow(0 2px 4px rgba(0,0,0,0.65)); }
  100% { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.65)); }
}
@keyframes life-hit-digits {
  0%   { color: #fff; }
  12%  { color: #ff6a5c; text-shadow: 0 0 9px rgba(226, 72, 60, 0.9), 0 1px 2px rgba(0,0,0,0.9); }
  100% { color: #fff; }
}
@media (prefers-reduced-motion: reduce) {
  .life-barrel.hit,
  .life-barrel.hit .life-wheel .life-strip span { animation: none; }
}
@media (prefers-reduced-motion: reduce) {
  .life-strip { transition: none; }
}
.life-ring.bump { animation: life-bump 420ms var(--ease); }
@keyframes life-bump {
  0% { transform: scale(1); }
  38% { transform: scale(1.16); }
  100% { transform: scale(1); }
}

.pod-name { font-weight: 600; font-size: 13.5px; }
/* A player who has dropped, at the table rather than in the lobby. Amber, not
   red: their seat is being held and they are expected back, which is a
   different thing from a problem. */
.pod-name.is-gone { color: #b9a06a; }
.pod-away {
  margin-left: 6px;
  padding: 1px 5px;
  border: 1px solid rgba(185, 160, 106, 0.5);
  border-radius: 999px;
  font-size: 9.5px; font-weight: 700;
  letter-spacing: 0.4px; text-transform: uppercase;
  color: #b9a06a;
  vertical-align: middle;
}
/* The crown and the initiative sit beside the name of whoever holds them.
   Gold and lit, because both are things you take FROM someone -- the table
   needs to see the moment they move, and where they went. */
.role-mark {
  margin-left: 6px;
  font-size: 12.5px;
  color: var(--gold-lit);
  text-shadow: 0 0 8px rgba(217, 164, 65, 0.75);
  cursor: help;
}
.role-mark.initiative { color: var(--arcane-lit); text-shadow: 0 0 8px rgba(127, 215, 255, 0.7); }
.pod-meta { color: var(--faint); font-size: 11px; display: flex; gap: 10px; flex-wrap: wrap; margin-top: 2px; }
.pod-meta b { color: var(--dim); font-weight: 600; }
/* Mat interior: battlefield on the left, zone rail pinned right. */
.mat-body { display: flex; gap: 8px; flex: 1; min-height: 0; }

/* Never scrolls. Identical permanents collapse into piles and the client
   shrinks card size until the board fits (see fitBoards), so a crowded seat
   gets denser rather than growing a scrollbar. */
/* Left to right: zone rail (inserted before this box), main board, side column.
   Main board runs lands at the back and creatures at the front; the side column
   runs planeswalkers, then sagas/battles, then artifacts and enchantments along
   the bottom. See battlefieldFor() for why this is convention, not rules. */
.pod-bf {
  display: flex; flex-direction: row; gap: 6px;
  flex: 1; min-height: 0; overflow: hidden;
}
.bf-main {
  display: flex; flex-direction: column; gap: 4px;
  flex: 1; min-width: 0; min-height: 0; overflow: hidden;
  /* Each row against its own edge, with the gap between them. Lands sit nearest
     their owner's hand and creatures nearest the seam, which is both how a
     board is laid out on a table and the arrangement that puts the most space
     between the two armies. Works for either side: the player's half is
     column-reverse, so "first" is the bottom for them and the top for the
     opponent -- lands far from the middle, creatures against it, both ways. */
  justify-content: space-between;
}
/*
 * The battle line.
 *
 * Combat happens in the seam between the two mats, so creatures belong on the
 * edge facing it and support falls back behind them. An opponent's pod already
 * reads this way -- their creatures sit at the bottom of their mat, nearest the
 * middle -- but yours was mirrored, putting your lands where your army should
 * be and your creatures against the bottom of the screen. The two armies were
 * facing away from each other.
 *
 * Flipping only your own half puts them nose to nose across the seam, which is
 * also what gives an attack somewhere to move to.
 */
#my-bf .bf-main { flex-direction: column-reverse; }

/* "Front" rows take the slack above them so they sit against the near edge of
   the mat, whatever the player put there. */
.bf-front {
  display: flex; flex-direction: column; gap: 4px;
  flex: 0 1 auto; min-height: 0;
}
.bf-side {
  display: flex; flex-direction: column; gap: 4px;
  flex: 0 0 auto; min-height: 0; max-width: 38%;
  overflow: hidden;
}
.bf-right { padding-left: 6px; border-left: 1px solid var(--line); }
.bf-left { padding-right: 6px; border-right: 1px solid var(--line); }
.bf-side .bf-row { flex-wrap: wrap; flex: 0 0 auto; }
/* Whatever is assigned to the bottom of a side column hugs its lower edge. */
.bf-lower {
  display: flex; flex-direction: column; gap: 4px;
  margin-top: auto; flex: 0 1 auto; min-height: 0; overflow: hidden;
}
/* Side columns are narrow, so their cards run a little smaller. */
.bf-side .card { width: calc(var(--bf-card-w) * 0.86); }

/* The hint paragraph above ends tight against the next label. */
#ov-prefs .step-hint + .field-row { margin-top: 14px; }

/* Board-layout preference grid: one region select per kind of permanent. */
.layout-grid {
  display: grid; grid-template-columns: repeat(3, 1fr);
  gap: 9px; margin-top: 10px;
}
.layout-grid .field { min-width: 0; }
@media (max-width: 620px) { .layout-grid { grid-template-columns: repeat(2, 1fr); } }

/* Nonland permanents get full size; lands are shingled into a compact strip,
   because a real board is mostly lands and they only need to show name,
   tapped state and count. */
/* Centred rather than jammed against the left edge. A board laid out from the
   middle is how one looks on a table, and it keeps the first card clear of the
   zone rail instead of touching it. */
.bf-row { display: flex; flex-wrap: wrap; gap: 4px; align-content: start; flex: 0 0 auto; justify-content: center; }
.bf-row .card { width: var(--bf-card-w); }

/* A true quarter turn. It was 88deg, on the theory that two degrees off square
   reads as a card set down by hand -- but a card is a hard-edged rectangle
   sitting in a row of other hard-edged rectangles, so the eye reads the gap
   against its neighbours as a mistake rather than as character. Tapping is a
   state, and it should look deliberate. */
.bf-row .card.tapped { transform: rotate(90deg) scale(0.8); }

/* A pile of identical permanents: layered edges behind the top card plus a
   count. Clicking acts on one card from the pile (tap one land, not all
   eight); shift-click spreads the pile out. */
/* A pile has to read as several cards, not one card with a rendering fault.
   At 3px and 6px the offsets were inside the card's own border radius and
   shadow, so two Mountains looked like a single Mountain drawn slightly
   wrong. 7px and 13px put a clear sliver of each card underneath. */
.card.stacked {
  box-shadow:
    7px 7px 0 -1px #0b1220, 7px 7px 0 0 rgba(255,255,255,0.22),
    13px 13px 0 -1px #0b1220, 13px 13px 0 0 rgba(255,255,255,0.14),
    0 3px 10px rgba(0,0,0,0.5);
}
.card.stacked:hover {
  box-shadow:
    7px 7px 0 -1px #0b1220, 7px 7px 0 0 rgba(255,255,255,0.22),
    13px 13px 0 -1px #0b1220, 13px 13px 0 0 rgba(255,255,255,0.14),
    var(--lift);
}
.card .pile {
  position: absolute; top: 3px; left: 3px;
  background: rgba(6, 10, 17, 0.9);
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: 999px; padding: 0 5px;
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.2px;
}

/* Lands shingle: each shows a sliver plus the leftmost card in full, so eight
   lands cost about the width of three cards while staying individually
   clickable and showing tapped state. */
.bf-row.lands { flex-wrap: nowrap; overflow: hidden; padding-bottom: 2px; }
.bf-row.lands .card { width: var(--bf-land-w); }

/* Shingled lands, but ONLY on a row that would otherwise overflow.
 *
 * This overlap used to be unconditional, so two lands on an empty board sat on
 * top of each other with a 26px sliver showing -- reported from a real game as
 * "the bot's lands are stacked weird", and rightly. A fan is a space-saving
 * measure; applying it when there is space to spare is just a board that does
 * not look like a board.
 *
 * `.tight` is set by fitBoards, which measures the row rather than counting it:
 * how many lands fit depends on the mat's width and the card size it settled
 * on, and any threshold picked here would be wrong at some window size. */
.bf-row.lands.tight .card { margin-right: calc(-1 * var(--bf-land-w) + 26px); }
.bf-row.lands.tight .card:last-child { margin-right: 0; }
.bf-row.lands.tight .card:hover,
.bf-row.lands.tight .card:focus-visible { z-index: 30; margin-right: 4px; }

/* ---------------------------------------------------------- zone rail -- */
.zones {
  display: flex; flex-direction: column; gap: 5px;
  /* Shared, because your own seat positions the life counter to clear this rail
     and the two got out of step when the rail moved to the left edge. */
  flex: 0 0 auto; width: var(--zone-rail-w);
}
/* The top card is shown CONTAINED, never cover-cropped: Scryfall's terms
   require the printed 63:88 ratio with no cropping (see the README), and a
   46x42 chip filled with `cover` was cutting the art. The chip itself cannot be
   card-shaped -- the rail only gets ~136px of height on your own mat, which
   would force a 30px-wide card -- so the art letterboxes inside a wider chip
   instead. Click to browse the whole pile. */
.zone {
  position: relative;
  flex: 1; min-height: 26px;
  border-radius: 5px;
  border: 1px dashed rgba(255, 255, 255, 0.22);
  background: rgba(0, 0, 0, 0.34) center / contain no-repeat;
  display: grid; place-items: end center;
  cursor: default;
  transition: border-color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.zone.filled { border-style: solid; cursor: pointer; }
.zone.filled:hover { border-color: var(--arcane-lit); box-shadow: var(--glow-arcane); transform: translateY(-2px); }
.zone.deck { background-image: linear-gradient(155deg, #1d4055, #0b1d28); border-style: solid; }
.zone .n {
  position: absolute; top: 2px; right: 2px;
  font-size: 11px; font-weight: 700; color: #fff;
  text-shadow: 0 1px 4px #000;
  background: rgba(6, 10, 17, 0.82);
  border-radius: 999px; padding: 0 5px;
}
.zone em {
  position: absolute; bottom: 1px; left: 0; right: 0;
  font-style: normal; font-size: 7.5px; text-align: center;
  color: var(--faint); letter-spacing: 0.4px; text-transform: uppercase;
}
.zone.filled em { color: var(--dim); text-shadow: 0 1px 3px #000; }

/*
 * A PILE THAT LOOKS LIKE A PILE.
 *
 * These were flat boxes with the top card letterboxed inside, so a graveyard
 * holding one card and one holding twenty were the same object with a
 * different number on it. How full a graveyard is happens to be one of the
 * most-read things on a Magic table -- it is the delve count, the threshold
 * count, and how much the opponent's recursion has to work with.
 *
 * Depth is drawn as stacked EDGES behind the chip rather than a fanned stack,
 * because the rail is 58px wide and gets about 136px of height for three or
 * four zones. There is no room for real cards; there is room to suggest
 * thickness, which is the part that carries the information.
 *
 * `--depth` is set in zoneRail and saturates at four: the difference between
 * one card and six is worth seeing, between twenty and forty it is not.
 */
.zone { --depth: 0; }
.zone::before,
.zone::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: linear-gradient(160deg, #223244, #131c27);
  pointer-events: none;
  opacity: 0;
  transition: transform var(--t-fast) var(--ease), opacity var(--t-fast) var(--ease);
}
/* Two edges is enough to read as "a stack"; the third and fourth are conveyed
   by pushing these further out rather than by adding more elements. */
.zone.d1::before { opacity: 0.55; transform: translate(1.5px, 1.5px); }
.zone.d2::before { opacity: 0.7; transform: translate(2px, 2px); }
.zone.d2::after { opacity: 0.45; transform: translate(3.5px, 3.5px); }
.zone.d3::before { opacity: 0.8; transform: translate(2.5px, 2.5px); }
.zone.d3::after { opacity: 0.55; transform: translate(5px, 5px); }
.zone.d4::before { opacity: 0.9; transform: translate(3px, 3px); }
.zone.d4::after { opacity: 0.65; transform: translate(6px, 6px); }
/* The chip's own face sits above its shadows, and the art above that. */
.zone > * { position: relative; z-index: 2; }
.zone.filled { background-position: center; z-index: 1; }
/* An empty zone is a flat outline with nothing behind it -- which is the
   honest picture, and makes a filled one read as full by contrast. */
.zone:not(.filled)::before,
.zone:not(.filled)::after { opacity: 0; }

/*
 * The deck wears the SLEEVE.
 *
 * It is the one pile whose face nobody ever sees, so a back is what it should
 * show -- and a real sleeve says "these are cards" in a way a gradient slab
 * never did. `cover` rather than `contain` here, deliberately and unlike the
 * card faces: a sleeve is a repeating design with no printed artwork to crop,
 * so the Scryfall no-cropping rule does not apply to it, and letterboxing a
 * back inside a 58px chip would waste most of the chip.
 */
.zone.deck {
  background-image: url('/assets/sleeve-kt.png');
  background-size: cover;
  background-position: center;
}
/* The stack edges behind the deck are sleeve-coloured too, or the pile reads
   as a deck sitting on top of three graveyards. Sampled from the sleeve's own
   border rather than guessed. */
.zone.deck::before,
.zone.deck::after { background: linear-gradient(160deg, #1d4055, #0b1d28); }

/*
 * SOMEBODY IS LOOKING AT THIS CARD.
 *
 * At a real table you can see an opponent reading your graveyard. Online that
 * happens invisibly, and it is information worth having -- it tells you what
 * they are worried about.
 *
 * Ember, because this is a PERSON doing something, not the system pointing
 * (which is arcane throughout). Animated slowly so it reads as a held gaze
 * rather than a state change, and drawn as a ring outside the card so it never
 * covers art or a badge.
 */
.card.peeked {
  box-shadow: 0 0 0 2px var(--ember-lit), 0 0 14px 2px rgba(232, 162, 76, 0.45);
  animation: peek-breathe 1.8s var(--ease) infinite;
}
@keyframes peek-breathe {
  0%, 100% { box-shadow: 0 0 0 2px var(--ember-lit), 0 0 10px 1px rgba(232, 162, 76, 0.35); }
  50%      { box-shadow: 0 0 0 2px var(--ember-lit), 0 0 18px 4px rgba(232, 162, 76, 0.6); }
}
/*
 * AN OPPONENT'S HAND, ON THE BOARD.
 *
 * `handFan` has always built these nodes and NOTHING HAS EVER STYLED THEM --
 * `.hand-fan` and `.mini` appear nowhere in this file -- so an opponent's hand
 * was a row of invisible divs and a number in the identity line. At a table the
 * hand is the most-watched object your opponent owns: how many cards they are
 * holding is half of every decision you make against them.
 *
 * Backs are the real sleeve, so a hand, a face-down permanent and the top of a
 * library are all the same object seen from the same side. Revealed cards --
 * a Duress, a card shown to you, a hand turned round for a player who is out --
 * show their face among the backs, which is exactly how it looks in paper.
 *
 * Shingled rather than spread: a seven-card hand has to fit a pod header, and
 * overlapping is what a held hand does anyway.
 */
.hand-fan {
  display: flex;
  align-items: center;
  gap: 0;
  padding-left: 2px;
  min-height: 34px;
}
.mini {
  width: 24px; height: 34px;
  flex: 0 0 auto;
  margin-left: -8px;                  /* shingle */
  border-radius: 3px;
  background: center / cover no-repeat;
  background-image: url('/assets/sleeve-kt.png');
  border: 1px solid rgba(10, 14, 22, 0.85);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
  transition: transform var(--t-fast) var(--ease), margin var(--t-fast) var(--ease);
}
.mini:first-child { margin-left: 0; }
/* A revealed card is face up and sits proud of the backs, because it is the
   one your opponent can see and yours to act on. */
.mini.revealed {
  border-color: rgba(232, 162, 76, 0.75);
  box-shadow: 0 0 0 1px rgba(232, 162, 76, 0.35), 0 2px 6px rgba(0, 0, 0, 0.6);
  z-index: 2;
}
.mini.revealed:hover { transform: translateY(-4px) scale(1.35); z-index: 5; }
/* "showing hand" -- a courtesy a player chose, so it is labelled rather than
   left to be inferred from a hand that has suddenly gone face up. */
.shown-chip {
  font: 700 8px/1 Inter, system-ui, sans-serif;
  letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--ember-lit);
  border: 1px solid rgba(232, 162, 76, 0.45);
  border-radius: 999px; padding: 2px 5px; margin-right: 5px;
  white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
  .mini { transition: none; }
}

/* And the pile it is in, for the case the whole feature exists for: somebody
   reading your graveyard is hovering a card you have not got open, so the card
   node is not on your screen and only the chip can carry the signal. */
.zone.peeked {
  border-color: var(--ember-lit) !important;
  box-shadow: 0 0 0 1px var(--ember), 0 0 14px 2px rgba(232, 162, 76, 0.5);
  animation: peek-breathe 1.8s var(--ease) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .card.peeked,
  .zone.peeked { animation: none; }
}

/* ------------------------------------------------------- command zone -- */
/*
 * The commander, face up, in the rail with the other zones. Gold rather than
 * arcane: every other chip is a pile the system keeps for you, and this one is
 * a card that belongs to a player -- the only object on the table that is
 * theirs in the way a commander is.
 *
 * Solid-bordered even when the art has not loaded, because unlike a graveyard
 * this zone is never "empty and therefore uninteresting": a commander at home
 * is a commander about to be cast at you.
 */
.zone.command {
  border-style: solid;
  border-color: rgba(232, 162, 76, 0.55);
  cursor: pointer;
}
.zone.command:hover,
.zone.command:focus-visible {
  border-color: var(--ember-lit);
  box-shadow: 0 0 0 1px var(--ember), 0 4px 14px rgba(0, 0, 0, 0.55);
  transform: translateY(-2px);
}
.zone.command em { color: var(--ember-lit); text-shadow: 0 1px 3px #000; }
/* On the battlefield: the slot stays, so the rail does not silently lose a
   chip, but it reads as vacated rather than as a pile with nothing in it. */
.zone.command.away {
  border-style: dashed;
  border-color: rgba(232, 162, 76, 0.3);
  background-image: none;
  cursor: default;
}
.zone.command.away em { color: rgba(232, 162, 76, 0.55); }
/* The commander tax -- what the NEXT cast costs on top of the printed price.
   The one number every Commander player tracks, and it lived nowhere. */
.cmd-tax {
  background: var(--ember) !important;
  color: #1a1206 !important;
  text-shadow: none !important;
}

/* A commander on the battlefield. In a pod this is what separates "a 4/4" from
   "three more connections and I am dead". */
.card.is-commander { box-shadow: 0 0 0 2px var(--ember), 0 2px 8px rgba(0, 0, 0, 0.5); }
.cmd-badge {
  position: absolute; top: 2px; left: 2px; z-index: 6;
  width: 14px; height: 14px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 9px; line-height: 1;
  background: var(--ember); color: #1a1206;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}

/* Commander damage taken, per commander. Warm at fourteen, which is the point
   the rest of the table starts doing arithmetic about you. */
.cmd-dmg { white-space: nowrap; }
.cmd-dmg b { color: var(--ember-lit); }
.cmd-dmg.hot b { color: var(--red-lit); }

/* --------------------------------------------------------------- yours -- */
#me { position: relative; background: transparent; }

/* Your seat at the bottom of the ring. Taller than the others because it is
   where you actually make decisions -- the same way your own side of a real
   table is the part you can reach. */
#my-bf {
  display: flex; flex-direction: column;
  /* Tall enough for a header, a creature row and a shingled land row without
     clipping -- the three things a developed board always has. */
  /* Symmetric with the opponent's half. The two mats used to be 272 and 353 --
     the player with the fewest pixels was the one actually playing. Reclaiming
     the centre gap gave ~84px back; it belongs here rather than to the seat
     that is only being watched. */
  height: var(--my-mat-h, 356px);
  margin: 0 14px;
  /* The extra bottom padding is the strip the hand floats over; board contents
     stop above it, so the hand never covers a permanent. */
  padding: 10px 12px calc(var(--hand-overlap) + 6px);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.015));
  overflow: hidden;
}

/* The hand is held, not laid on the mat -- it sits on the bare table below. */
#my-hand { background: transparent; }

/* Fanned hand -- cards arc and lift, the way a held hand reads. */
/* The hand sits flush with the bottom edge with no wasted margin, but stays
   fully visible -- a clipped hand is unreadable, and cards are 128px tall at
   the hand's 92px width. Height is derived from that rather than guessed. */
#my-hand {
  display: flex;
  justify-content: center;
  align-items: flex-end;
  /* Exactly one card tall plus a little breathing room. Cards sit upright in a
     row, so no headroom for rotation is needed. */
  height: calc(var(--card-w) / 0.71591 + 10px);
  padding: 0 14px 6px;
  perspective: 1400px;
  /* Float the hand up over the mat's lower strip to reclaim that space. The
     mat reserves matching bottom padding (--hand-overlap) so nothing actually
     on the battlefield is ever hidden underneath. */
  position: relative;
  z-index: 6;
  margin-top: calc(-1 * var(--hand-overlap));
}
/* Cards overlap slightly so a wide hand stays compact; the leftmost sliver of
   each is enough to see there is a card there, and hover lifts the full face. */
#my-hand .card {
  margin: 0 -10px;
  transform-origin: 50% 100%;
  transition: transform var(--t-med) var(--ease), box-shadow var(--t-med) var(--ease);
}
#my-hand .card:hover {
  transform: translateY(-46px) scale(1.18);
  z-index: 30;
  box-shadow: var(--lift-lg);
}

/* ------------------------------------------------------------ mulligan -- */
/* Keeping or shipping an opening hand is the single most consequential read in
   the game, and it was being made in a 860px box with cards the size of the ones
   in your hand. This takes the whole screen and sizes the cards to fill it: the
   only thing on screen is the decision. */
/* The generic overlay padding exists to keep modals off the window edge; the
   mulligan is not a modal, it is the whole screen. */
#ov-mull { padding: 0; }
/*
 * The sideboard sheet. Same shape as the mulligan sheet and for the same
 * reason: it is a whole-screen decision with a clock on it, not a dialog you
 * dismiss. Percentages rather than viewport units throughout -- the parent
 * overlay is the one box reliably the size of the window under the
 * interface-size zoom.
 */
#ov-sideboard .sb-sheet {
  width: 100%;
  height: 100%;
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: 14px;
  padding: 18px 22px;
}
.sb-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.sb-clock {
  font-size: 34px;
  font-variant-numeric: tabular-nums;
  letter-spacing: 1px;
  color: var(--text);
  padding: 4px 14px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  background: rgba(3, 7, 12, 0.6);
}
/* Under thirty seconds it stops being information and starts being a warning. */
.sb-clock.urgent { color: var(--red); border-color: var(--red); }

.sb-piles { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; min-height: 0; }
.sb-pile { display: flex; flex-direction: column; min-height: 0; }
.sb-pile h3 {
  font-size: 11px; text-transform: uppercase; letter-spacing: 1.4px;
  color: var(--faint); margin: 0 0 8px;
}
.sb-count { color: var(--text); font-variant-numeric: tabular-nums; }
.sb-list {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: rgba(3, 7, 12, 0.45);
  padding: 6px;
}
/* A row per distinct card with its count, not one row per copy: a 60-card
   deck is about 23 rows this way and 60 rows the other, and nobody can scan
   sixty rows against a clock. */
.sb-row {
  display: flex; align-items: center; gap: 8px;
  padding: 5px 8px; border-radius: 4px; cursor: pointer;
  border: 1px solid transparent;
}
.sb-row:hover { background: rgba(91, 172, 212, 0.12); border-color: rgba(91, 172, 212, 0.38); }
.sb-row .qty { color: var(--faint); font-variant-numeric: tabular-nums; min-width: 18px; }
.sb-row .nm { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sb-row .arrow { color: var(--faint); opacity: 0; }
.sb-row:hover .arrow { opacity: 1; }

.sb-foot { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.sb-actions { display: flex; gap: 10px; }

#ov-mull .mull-sheet {
  /* Percentages, not viewport units: the parent overlay is fixed at inset:0,
     which is the one box that is reliably the size of the window under the
     interface-size zoom. `100vh` here is the raw window height and overflowed
     by the zoom factor, pushing Keep and Mulligan off the bottom edge. */
  width: 100%;
  height: 100%;
  display: grid;
  grid-template-rows: auto 1fr auto;
  gap: 8px;
  padding: 12px 10px 14px;
  background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(16, 36, 27, 0.6), transparent 70%),
              rgba(6, 11, 19, 0.985);
  /* Deliberately NO backdrop-filter. This covers the whole viewport and its own
     background is all but opaque, so a blur here shows through as nothing while
     costing a full-screen GPU pass every frame -- stacked on top of the one the
     overlay underneath already does. Two full-viewport blurs on a wide monitor
     is what made this screen lag. */
}

/* The overlay behind a full-bleed sheet has nothing left to blur. */
#ov-mull { backdrop-filter: none; background: rgba(5, 9, 15, 0.92); }
.mull-head { text-align: center; }
.mull-head h2 { margin: 0 0 2px; font-size: clamp(18px, calc(2.2 * var(--vw, 1vw)), 26px); color: var(--gold); }
.mull-head .sub { color: var(--dim); font-size: clamp(12px, calc(1.2 * var(--vw, 1vw)), 15px); }

/* The hand laid out flat rather than fanned: you are reading it to decide, not
   picking a card out of a held hand. Cards scale to whatever room is left after
   the header and buttons, capped so a 3-card hand does not become absurd. */
.mull-hand {
  display: flex; flex-wrap: nowrap; justify-content: center; align-items: center;
  gap: 10px;
  min-height: 0;
  overflow: hidden;
  /* Fill the width, but never so much that the row grows taller than the space
     it has -- past that point the cards are capped and the row centres itself. */
  width: 100%;
  max-width: min(100%, calc(78 * var(--vh, 1vh) / 1.4 * 7 + 60px));
  margin-inline: auto;
}
/* One row, sharing the full width between them. */
.mull-hand .card {
  flex: 1 1 0;
  min-width: 0;
  width: auto;
  height: auto;
  aspect-ratio: 63 / 88;
  max-width: none;
}
.mull-hand .card img { width: 100%; height: 100%; object-fit: contain; }

.mull-foot { display: grid; justify-items: center; gap: 8px; }
.mull-actions { display: flex; gap: 12px; }
.mull-actions .big { min-width: 148px; font-size: 15px; padding: 12px 22px; }

/* A hand of six or seven needs to wrap on anything short; drop the card height
   so two rows still fit rather than forcing a scroll. */

.mull-hand .card { cursor: pointer; }
/* Cards chosen to go on the bottom dim and drop, so the hand you are keeping is
   the part that still reads as yours. */
.mull-hand .card.bottoming {
  opacity: 0.42;
  transform: translateY(10px) scale(0.94);
  box-shadow: none;
  outline: 1px solid var(--line);
}

/* ---------------------------------------------------------------- card -- */
.card {
  position: relative;
  flex: 0 0 auto;
  width: var(--card-w);
  aspect-ratio: 63 / 88;          /* exact printed ratio -- never crops art */
  border-radius: var(--r-sm);
  background: #1b2433;
  border: 1px solid rgba(0, 0, 0, 0.85);
  overflow: hidden;
  cursor: pointer;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5);
  transition: transform var(--t-med) var(--ease), box-shadow var(--t-med) var(--ease);
  will-change: transform;
}
.card img { width: 100%; height: 100%; object-fit: contain; display: block; }

/* A face-down permanent -- morph, disguise, manifest.
   Drawn as a back rather than an empty card frame, because "the table cannot
   see this" and "this failed to render" must not look the same.

   The real sleeve, now that there is one: a face-down card and the top of the
   deck are the same object seen from the same side, and they should not be two
   different pictures. This is where the placeholder note above said the sleeve
   would eventually land. */
.card.face-down {
  background-image: url('/assets/sleeve-kt.png');
  background-size: cover;
  background-position: center;
  border-color: rgba(148, 175, 214, 0.42);
  box-shadow: inset 0 0 0 3px rgba(10, 14, 22, 0.85), 0 3px 10px rgba(0, 0, 0, 0.5);
}
.card.face-down .back-mark {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font-size: calc(var(--card-w) * 0.34);
  font-weight: 800;
  color: rgba(190, 210, 240, 0.30);
  text-shadow: 0 2px 5px rgba(0, 0, 0, 0.6);
  pointer-events: none;
}

.card:hover { transform: translateY(-6px) scale(1.03); box-shadow: var(--lift); z-index: 20; }
.card.tapped { transform: rotate(90deg) scale(0.9); }
.card.tapped:hover { transform: rotate(90deg) scale(0.94); }

.card.selected { box-shadow: var(--glow-gold); }

/*
 * A card in the hand row that is not in your hand: it lives in exile and
 * something has given you permission to cast it. The glow is what tells the
 * two apart at a glance, and it pulses so it reads as a standing permission
 * rather than a selection you made.
 */
.card.from-exile { box-shadow: var(--glow-arcane); animation: exile-pulse 2.4s ease-in-out infinite; }
.card.from-exile::after {
  content: 'exile';
  position: absolute;
  left: 50%;
  bottom: 4px;
  transform: translateX(-50%);
  padding: 1px 6px;
  border-radius: 999px;
  font-size: 9px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  background: rgba(10, 14, 20, 0.82);
  color: var(--arcane-lit);
  pointer-events: none;
}
@keyframes exile-pulse {
  0%, 100% { box-shadow: var(--glow-arcane); }
  50% { box-shadow: 0 0 0 2px var(--arcane-lit), 0 0 26px rgba(91, 172, 212, 0.6); }
}
@media (prefers-reduced-motion: reduce) {
  .card.from-exile { animation: none; }
}
.card.attacking { box-shadow: var(--glow-red); }
.card.blocking { box-shadow: var(--glow-blue); }
/* Targetable is the system offering you a choice, so it is arcane -- and it
   carries a mark as well as a glow, because a glow is a colour and colour must
   never be the only thing saying "you may click this". */
.card.targetable {
  box-shadow: var(--glow-blue);
  animation: pulse-target 1.6s var(--ease) infinite;
}
.card.targetable::after {
  content: "✛";
  position: absolute; top: 3px; right: 4px;
  z-index: 3;
  font-size: 13px; line-height: 1;
  color: var(--flare);
  text-shadow: 0 1px 3px #000, 0 0 8px rgba(91, 172, 212, 0.9);
  pointer-events: none;
}

/* Summoning sickness. The haze alone was too easy to read as art direction, so
   it is paired with a badge that says so outright -- this is the most common
   reason a creature will not attack, on the one turn you are least likely to
   remember it. A hasty creature never gets either. */
.card.sick::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(160deg, rgba(59,130,246,0.34), transparent 58%);
  pointer-events: none;
}
.sick-badge {
  position: absolute;
  top: 2px;
  left: 2px;
  z-index: 3;
  padding: 1px 4px;
  border-radius: 999px;
  background: rgba(59, 130, 246, 0.95);
  color: #fff;
  font: 700 8px/1.25 Inter, system-ui, sans-serif;
  letter-spacing: 0.4px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
  pointer-events: none;
}

/* Text face for custom cards and anything without art. */
.card .face {
  height: 100%; padding: 6px;
  display: flex; flex-direction: column;
  font-size: 8px; line-height: 1.25;
  background: linear-gradient(165deg, #2a3547, #161d29);
}
.card .face .nm { font-weight: 700; font-size: 9px; letter-spacing: 0.2px; }
.card .face .tp {
  color: var(--faint); font-size: 7.5px;
  margin: 3px 0; padding-top: 3px;
  border-top: 1px solid rgba(255,255,255,0.12);
}
.card .face .ox { flex: 1; overflow: hidden; color: #c3ccd7; }
.card .face .pt { align-self: flex-end; font-weight: 700; font-size: 11px; color: var(--gold-lit); }

/* Overlays sit clear of the bottom credit strip so the artist name stays
   legible -- required by Scryfall's image terms. */
/*
 * Power/toughness, over the box where it is PRINTED.
 *
 * This used to sit at `bottom: 9%`, which measures out at 74-90% down the card
 * -- the rules text box, not the P/T box, which is lower. So it covered text on
 * every creature, and a big number covered a lot of it: "122/122" spanned 30% to
 * 95% of the card's width, two thirds of the card, straight across the rules.
 *
 * Dropped onto the printed P/T box instead. That is the one place on a card
 * where covering the print costs nothing, because what is underneath is the
 * same quantity -- the printed 2/2 that this badge exists to correct. And it
 * overhangs the right edge like the counters do, so the first thing a wide
 * number eats is empty space beside the card rather than the card.
 *
 * `bottom: 7%` and not lower: the artist and copyright line is the bottom ~5%.
 */
.card .badge {
  position: absolute; bottom: 7%; right: -7px;
  background: rgba(0, 0, 0, 0.88);
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 4px; padding: 1px 5px;
  font-size: 11px; font-weight: 700;
  font-variant-numeric: tabular-nums;
}
/* A creature that is not the size it is printed says so.
   Colour is not the only signal -- the number itself has already changed, and
   that is what a player reads -- so this is reinforcement rather than the
   whole message, which is the rule everywhere else on this table. The border
   carries it too, for anyone who cannot separate the two hues. */
.card .badge.pt-up {
  color: #7ee7a8;
  border-color: rgba(126, 231, 168, 0.85);
  box-shadow: 0 0 8px rgba(126, 231, 168, 0.35);
}
.card .badge.pt-down {
  color: #ff8f8f;
  border-color: rgba(255, 143, 143, 0.85);
  box-shadow: 0 0 8px rgba(255, 143, 143, 0.35);
}
/* Loyalty reuses the badge slot -- same position as power/toughness, which is
   also where loyalty is printed, and already clear of the artist and copyright
   line at the card's bottom edge (see the Scryfall terms in the README). */
.card .badge.loyalty {
  background: rgba(24, 16, 40, 0.94);
  border-color: rgba(190, 160, 255, 0.55);
  color: #d9c8ff;
}
/*
 * The damage badge is GONE. Damage is subtracted from the toughness in the P/T
 * badge and the whole thing turns red, so a 5/5 with 3 damage reads 5/2 rather
 * than a "5/5" in one corner and a "3" in another.
 *
 * One overlay instead of two, and it answers the question a player is actually
 * asking when they look -- how much more does it take to kill this -- rather
 * than handing them two numbers and a subtraction at the moment they are
 * deciding whether to block.
 */
.card .badge.pt-hurt {
  color: #ff8f8f;
  border-color: rgba(255, 143, 143, 0.85);
  box-shadow: 0 0 8px rgba(255, 143, 143, 0.35);
}
/* Damage prevention sitting on a permanent.
   Top-left, opposite the counters and clear of the damage marker at the bottom,
   because a shield and the damage it is about to eat must both be readable at
   once. Blue and lit: this is protection, and it reads as the opposite of the
   red damage number. A redirect is marked differently on purpose -- it is not
   protection, the damage still lands, just not here. */
/*
 * A shield, drawn across the middle of the card.
 *
 * Every other overlay is a number in a margin, because every other overlay is
 * about one attribute. A prevention shield is about the whole permanent -- "you
 * cannot hurt this" -- so it goes over the whole permanent rather than being
 * filed in a corner as a small blue pill, which is what it was and which read
 * as one more badge among several.
 *
 * Translucent on purpose. The art has to stay visible through it: the point is
 * a shield IN FRONT OF the creature, and an opaque one would be a sticker over
 * it. Fill is deliberately weak and the work is done by the stroke and the
 * glow, which describe a shape without hiding what is behind it.
 */
.card .shield {
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 46%;
  aspect-ratio: 100 / 116;
  display: grid; place-items: center;
  /* So the number inside can size itself against the shield rather than
     against a pixel guess -- the card scales from 130px wide down to 68. */
  container-type: size;
  pointer-events: none;
  z-index: 4;
}
.card .shield svg {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  overflow: visible;
}
.card .shield svg path {
  fill: rgba(127, 215, 255, 0.16);
  stroke: rgba(168, 231, 255, 0.9);
  stroke-width: 4;
  stroke-linejoin: round;
  filter: drop-shadow(0 0 5px rgba(127, 215, 255, 0.55));
}
.card .shield .n {
  position: relative;
  font-size: 38cqh;
  font-weight: 700;
  line-height: 1;
  color: #eaf8ff;
  text-shadow: 0 0 6px rgba(127, 215, 255, 0.9), 0 1px 3px rgba(0, 0, 0, 0.85);
}
/* A redirect is not protection -- the damage still happens, somewhere else --
   so it is gold rather than arcane blue, the way it always was. */
.card .shield.redirect svg path {
  fill: rgba(217, 164, 65, 0.16);
  stroke: rgba(240, 200, 120, 0.9);
  filter: drop-shadow(0 0 5px rgba(217, 164, 65, 0.55));
}
.card .shield.redirect .n {
  color: #fff3d8;
  text-shadow: 0 0 6px rgba(217, 164, 65, 0.9), 0 1px 3px rgba(0, 0, 0, 0.85);
}

/* `.card .ctr` -- a green counter pill at the top right -- lived here with
   nothing in the client creating it. Counters are `.ctr-tray` off the right
   edge. Removed rather than left as a thing the next person has to check. */
.card .cov {
  position: absolute; top: 5px; left: 5px;
  width: 6px; height: 6px; border-radius: 50%;
  box-shadow: 0 0 6px currentColor;
}
.cov.full { background: var(--green-lit); color: var(--green-lit); }
.cov.partial { background: var(--gold); color: var(--gold); }
.cov.manual { background: var(--red-lit); color: var(--red-lit); }

/* ------------------------------------------------------------- side rail -- */
#side {
  /* Opaque rather than blurred: this is a tall, permanently-visible column, and
     a backdrop-filter over it is repainted whenever anything behind it moves --
     which, with the drifting table gradient, is every frame. */
  background: var(--panel-solid);
  border-left: 1px solid var(--line);
  display: grid;
  /*
   * Phase, the log/chat tabs, the feed itself, actions.
   *
   * The log and the chat panel are both children of this grid but only ever one
   * is visible -- `[hidden]` is display:none, so the other claims no row. That
   * is why four rows are declared for five children.
   *
   * The stack used to be a row here and is now on the board. Its `auto` height
   * changed with every spell and took the log's height with it, which is the
   * "constantly resizing" half of why it was unreadable.
   */
  grid-template-rows: auto auto 1fr auto;
  overflow: hidden;
}

/* ------------------------------------------------------- log / chat tabs -- */
.feed-tabs {
  display: flex;
  border-bottom: 1px solid var(--line);
}
.feed-tab {
  flex: 1 1 0;
  position: relative;
  padding: 7px 4px 6px;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  font: inherit;
  font-size: 11px; font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase;
  color: #7f8ea1;
  cursor: pointer;
}
.feed-tab:hover { color: var(--ink, #e8eef5); }
.feed-tab.on {
  color: var(--gold-lit, #e6c877);
  border-bottom-color: var(--gold-lit, #e6c877);
}
.feed-tab:focus-visible { outline: 2px solid var(--gold-lit, #e6c877); outline-offset: -2px; }
/* Somebody said something while you were reading the log. A dot rather than a
   count: the number is not the point, and a badge that says 14 invites you to
   feel behind. */
.feed-dot {
  display: inline-block;
  width: 6px; height: 6px;
  margin-left: 5px;
  vertical-align: 1px;
  border-radius: 50%;
  background: var(--gold-lit, #e6c877);
}

/* ----------------------------------------------------------------- chat -- */
/*
 * Written as CLASSES rather than ids, because there are two of these now: the
 * panel behind the log tab on the table, and the one in the lobby's side
 * column. They are the same conversation and they should look identical -- the
 * ids stayed for the JS to hang on to, but nothing in here selects one.
 */
#chat-panel {
  display: flex; flex-direction: column;
  min-height: 0;                          /* or the log refuses to scroll */
  overflow: hidden;
}
.chat-log {
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto;
  padding: 12px 14px;
  font-size: 12px;
  display: flex; flex-direction: column; gap: 6px;
}
.chat-empty { color: #6d7c8d; font-style: italic; }
.chat-line { line-height: 1.45; color: #c6d3df; overflow-wrap: anywhere; }
.chat-who {
  margin-right: 6px;
  font-weight: 700;
  color: #8fa6bd;
}
/* Your own lines, so a fast conversation is readable at a glance. */
.chat-line.mine .chat-who { color: var(--gold-lit, #e6c877); }
/* What the table itself says. Centred, quieter and with rules either side, so
   it reads as a moment in the room rather than as somebody's remark. */
.chat-line.is-system {
  display: flex; align-items: center; gap: 8px;
  margin: 3px 0;
  color: #8a99ab;
  font-size: 11px; font-style: italic;
  text-align: center;
}
.chat-line.is-system::before,
.chat-line.is-system::after {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--line, rgba(255, 255, 255, 0.12)), transparent);
}
.chat-form {
  display: flex; gap: 6px;
  padding: 8px 10px;
  border-top: 1px solid var(--line);
}
.chat-input {
  flex: 1 1 auto; min-width: 0;
  padding: 6px 9px;
  font: inherit; font-size: 12px;
  color: var(--ink, #e8eef5);
  background: rgba(0, 0, 0, 0.34);
  border: 1px solid var(--line);
  border-radius: var(--r-sm, 6px);
}
.chat-input:focus-visible {
  outline: none;
  border-color: var(--gold-lit, #e6c877);
}
.chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
.chat-send { padding: 6px 12px; font-size: 12px; }

/*
 * Table talk in the lobby.
 *
 * In the side column, under the roster, because that column is already the
 * answer to "who is here" and this is the answer to "what are they saying".
 * It deliberately does not go in the panel: the panel is the steps you work
 * through in order, and a conversation is not a step.
 *
 * The column was sized to its contents (`align-self: start`), which is why
 * there was room for this and nothing in it -- an empty roster left ~780px of
 * unused column at 1600x1000. It now takes the panel's box, so the two tops
 * still line up and the chat fills whatever the roster does not.
 */
.lobby-chat {
  display: flex; flex-direction: column;
  min-height: 120px;
  flex: 1 1 auto;
  overflow: hidden;
  background: linear-gradient(180deg, rgba(8, 22, 32, 0.72), rgba(4, 14, 22, 0.72));
  border: 1px solid rgba(217, 164, 65, 0.22);
  border-radius: var(--r-md, 10px);
}
/* The heading is part of the box here, not a floating column label, so it
   takes a rule under it rather than the bare letter-spaced caption above. */
.lobby-chat > .lobby-side-title {
  padding: 9px 12px 8px;
  border-bottom: 1px solid var(--line);
}
.lobby-chat .chat-log { padding: 10px 12px; }
.lobby-chat .chat-form { padding: 8px; }
.sec { border-bottom: 1px solid var(--line); padding: 12px 14px; }
.sec h3 {
  margin: 0 0 9px;
  font-family: var(--display);
  font-size: 11px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 1.6px;
  color: #8fa6b8;
}

/* Phase rail -- current step reads at a glance. */
#phases { display: flex; flex-wrap: wrap; gap: 4px; }
#phases span {
  font-size: 9.5px; font-weight: 600; letter-spacing: 0.4px;
  padding: 4px 7px; border-radius: 4px;
  background: rgba(146, 205, 232, 0.06);
  color: var(--faint);
  transition: background var(--t-med) var(--ease), color var(--t-med) var(--ease),
              box-shadow var(--t-med) var(--ease), transform var(--t-med) var(--ease);
}
/* Steps already taken this turn stay legible -- they are the record of what
   happened, and reading them back is how you check a bot's turn. */
#phases span.done { color: var(--dim); background: rgba(146, 205, 232, 0.10); }
/* The step you are in is the system telling you where the game is: arcane. */
#phases span.on {
  background: linear-gradient(180deg, var(--flare), var(--arcane-lit));
  color: #04202c;
  box-shadow: 0 0 16px rgba(91, 172, 212, 0.55);
  transform: scale(1.06);
}

/* ==========================================================================
 * THE STACK, on the board
 *
 * It was a row in the side rail: thumbnail-sized, and an `auto` grid row, so
 * it grew and shrank with every spell and took the log's height with it.
 * Reported as "a little tiny window on the left above the log, which is
 * constantly resizing and is not readable".
 *
 * Both halves of that are the same mistake. The stack is the one piece of
 * state in Magic where NOT NOTICING costs you the game, and it was in the
 * quietest part of the screen at the smallest size on offer.
 *
 * It is not a modal. Kevin: "it shouldn't be blocking, it should just show it
 * prominently" -- a dialog every time anybody casts would be unplayable, and
 * the point is to keep playing while you decide. So it sits OVER the table,
 * takes no layout from anything, and can be retracted when you need to see
 * what is behind it.
 * ========================================================================== */
/*
 * Percentages, not `calc(n * var(--vh))`.
 *
 * `#table` is a positioned parent, so a percentage resolves against it and is
 * both simpler and safer here. The multiply-a-variable form depends on `--vh`
 * having been written by the UI-scale pass, and until it has, `calc(72 * )` is
 * not a length -- the panel collapses to two pixels and stays there. app.js
 * documents the same trap on the lobby, which collapsed to nothing on a load
 * into a viewport that had not been laid out yet.
 */
#stack-stage {
  position: absolute;
  top: 5%;
  right: 14px;
  z-index: 40;
  /* Wide enough for a readable card and its name side by side. At 210 the
     text column was about ninety pixels and every name wrapped. */
  width: 244px;
  max-height: 72%;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  background: linear-gradient(180deg, rgba(14, 26, 38, 0.96), rgba(8, 15, 23, 0.97));
  box-shadow: var(--lift-lg);
  animation: ss-arrive var(--t-med) var(--ease);
  /* The board underneath stays clickable everywhere this panel is not. */
  pointer-events: auto;
}
@keyframes ss-arrive {
  from { opacity: 0; transform: translateX(18px) scale(0.97); }
  to { opacity: 1; transform: none; }
}

.ss-head {
  display: flex; align-items: center; gap: 8px;
  padding: 9px 10px 8px 12px;
  border-bottom: 1px solid var(--line);
}
.ss-title {
  font-family: var(--display);
  font-size: 12px; letter-spacing: 1.1px; text-transform: uppercase;
  color: var(--ember-lit);
}
.ss-count {
  margin-left: auto;
  font-size: 10.5px; color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.ss-fold {
  width: 20px; height: 20px;
  padding: 0; display: grid; place-items: center;
  border: 1px solid var(--line);
  border-radius: 5px;
  background: none; color: var(--faint);
  font-size: 13px; line-height: 1; cursor: pointer;
}
.ss-fold:hover { color: var(--flare); border-color: rgba(138, 214, 247, 0.5); }

/*
 * Every card, in resolve order, and scrollable when it has to be.
 *
 * `column-reverse` puts the TOP of the stack at the top of the panel, which is
 * where the eye starts and which is what resolves first. Deep stacks scroll
 * rather than shrinking their cards: "I should be able to see all the cards on
 * the stack and browse through them" is not answered by making them smaller.
 */
.ss-cards {
  display: flex; flex-direction: column-reverse;
  gap: 6px;
  padding: 10px;
  overflow-y: auto;
  min-height: 0;
}
.ss-you {
  display: flex; flex-direction: column; gap: 7px;
  padding: 9px 10px 10px;
  border-top: 1px solid rgba(138, 214, 247, 0.35);
  background: rgba(99, 184, 228, 0.12);
  color: var(--arcane-lit);
  font-size: 11px; font-weight: 600;
  letter-spacing: 0.4px;
}
.ss-you-txt { padding-left: 2px; }
/*
 * The decline, and it has to be OBVIOUS.
 *
 * "Not over in the bottom corner unobvious" -- which is exactly where it was,
 * as the rail's primary button at the far end of the screen from the panel
 * that had just told you to look. A quiet pill here would have repeated the
 * mistake at closer range.
 *
 * Full width under the text, filled, unmissable. Arcane rather than ember:
 * letting the stack resolve is the system's ordinary next step, not a
 * commitment -- ember is reserved for choices you make FOR yourself, and
 * spending it here would make declining look like the exciting option.
 */
.ss-let {
  width: 100%;
  padding: 7px 10px;
  border-radius: var(--r-sm);
  border: 1px solid rgba(138, 214, 247, 0.7);
  background: linear-gradient(180deg, rgba(99, 184, 228, 0.34), rgba(46, 91, 122, 0.34));
  color: var(--flare);
  font: inherit; font-size: 11.5px; font-weight: 600;
  letter-spacing: 0.4px;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.ss-let:hover {
  background: linear-gradient(180deg, rgba(138, 214, 247, 0.5), rgba(99, 184, 228, 0.4));
  transform: translateY(-1px);
}
.ss-let:active { transform: none; }
.ss-let:focus-visible { outline: 2px solid var(--arcane-lit); outline-offset: 2px; }
/*
 * ARCANE, and only while you can act.
 *
 * The language reserves arcane for the system speaking -- a state, not a
 * commitment -- and "it is your turn to respond" is exactly that. The glow is
 * the attention-demanding part, and it stops the moment you cannot act, so it
 * never becomes wallpaper.
 */
#stack-stage.live {
  border-color: rgba(138, 214, 247, 0.6);
  box-shadow: var(--lift-lg), 0 0 26px rgba(99, 184, 228, 0.3);
}

/* Retracted: the header alone, still saying how many are waiting. */
#stack-stage.folded .ss-cards,
#stack-stage.folded .ss-you { display: none; }
#stack-stage.folded { width: 148px; }

@media (prefers-reduced-motion: reduce) {
  #stack-stage { animation: none; }
}

#stack { display: flex; flex-direction: column-reverse; gap: 5px; min-height: 34px; }
/*
 * The stack as cards waiting to resolve, rather than rows in a list.
 *
 * It was a tinted strip with a heavy rule down one side and a thumbnail wedged
 * against the text -- list furniture, in a product where everything else is an
 * object. These are the actual cards, sitting in the order they will resolve.
 */
/*
 * A row, not a boxed card in a boxed panel.
 *
 * Inside the stack panel these sat as bordered tiles inside a bordered
 * container, each one repeating the frame around it -- three nested outlines
 * for one spell. The card already has an edge; the row only needs to say where
 * one item stops and the next begins.
 */
.stack-item {
  display: flex; align-items: center; gap: 10px;
  padding: 6px;
  border-radius: var(--r-md);
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid var(--line);
  animation: stack-in var(--t-med) var(--ease);
}
#stack .stack-item {
  border: none;
  background: none;
  padding: 4px 2px;
  align-items: flex-start;
}
#stack .stack-item + .stack-item { border-top: 1px solid var(--line); padding-top: 10px; }
@keyframes stack-in {
  from { opacity: 0; transform: translateX(14px); }
  to { opacity: 1; transform: none; }
}
/* Big enough to READ, now that it is not squeezed into a 200px rail. The
   whole complaint was that a stack you cannot read is a stack you cannot
   respond to. */
.stack-item .card.on-stack {
  width: 92px; flex: 0 0 auto; cursor: pointer;
}
.stack-item .nm {
  font-family: var(--display);
  font-size: 12.5px; line-height: 1.3; color: var(--text);
}
.stack-item .who { color: var(--faint); font-size: 10.5px; margin-top: 2px; }

/*
 * #stack is column-reverse, so the FIRST child is drawn at the bottom and the
 * last one drawn is the top of the stack -- which is the one that resolves
 * next. Saying so is the whole point of showing a stack at all.
 */
/*
 * The one that resolves next.
 *
 * Marked with a lit edge down its own side rather than by tinting the whole
 * row: the rows no longer have frames to recolour, and a wash over one of them
 * competes with the card art it is supposed to be pointing at.
 */
#stack .stack-item:last-child {
  background: none;
  border-left: 2px solid var(--arcane-lit);
  padding-left: 8px;
  border-radius: 0;
}
#stack .stack-item:last-child .nm { color: var(--flare); }
#stack .stack-item:last-child .nm::after {
  content: "resolves next";
  display: block;
  font-family: var(--body, inherit);
  font-size: 9.5px; letter-spacing: 0.8px; text-transform: uppercase;
  color: var(--arcane-lit);
  margin-top: 3px;
}

#log { overflow-y: auto; padding: 12px 14px; font-size: 11.5px; }
#log div { padding: 2px 0; color: #b6c6d4; }
#log div.hi { color: var(--gold-lit); font-weight: 600; }
#log div.fresh { animation: log-in 400ms var(--ease); }
@keyframes log-in {
  from { opacity: 0; transform: translateY(-5px); }
  to { opacity: 1; transform: none; }
}

/* Contextual primary action -- the single button that always tells you what
   the game is waiting for. Borrowed straight from Arena's flow. */
#actions {
  padding: 12px 14px;
  border-top: 1px solid var(--line);
  display: flex; flex-direction: column; gap: 8px;
}
#act-primary {
  width: 100%;
  padding: 15px 14px;
  font-size: 14.5px; font-weight: 700;
  letter-spacing: 0.4px;
  border-radius: var(--r-md);
}
#act-primary.waiting {
  background: rgba(255,255,255,0.05);
  color: var(--faint);
  border-color: var(--line);
}
#act-primary.urgent { animation: urgent 2s var(--ease) infinite; }
@keyframes urgent {
  0%, 100% { box-shadow: 0 0 0 0 rgba(217, 164, 65, 0.5); }
  50% { box-shadow: 0 0 0 9px rgba(217, 164, 65, 0); }
}
#act-secondary { display: flex; gap: 6px; flex-wrap: wrap; }
#act-secondary button { flex: 1; min-width: 84px; }
/* Manual priority is a deliberate, visible state -- it changes how the whole
   game paces, so it should not look like a normal button while it is on. */
#act-secondary button.manual-on {
  border-color: var(--gold);
  color: var(--gold-lit);
  background: rgba(217, 164, 65, 0.12);
}
#act-hint { color: var(--faint); font-size: 11px; text-align: center; min-height: 15px; }

/* ------------------------------------------------------------ manual bar -- */
#manual-panel { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
#manual-panel button {
  font-size: 11px; padding: 8px 4px;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
}
#manual-panel svg { width: 15px; height: 15px; opacity: 0.75; }

/* --------------------------------------------------------------- banner -- */
/* Full-width sweep announcing turn and phase changes. */
#banner {
  position: fixed;
  left: 0; right: 296px; top: 46%;
  z-index: 90;
  pointer-events: none;
  text-align: center;
  opacity: 0;
}
#banner .txt {
  display: inline-block;
  padding: 14px 54px;
  font-size: 27px; font-weight: 700;
  letter-spacing: 7px; text-transform: uppercase;
  color: var(--gold-lit);
  text-shadow: 0 3px 30px rgba(0, 0, 0, 0.9);
  background: linear-gradient(90deg, transparent, rgba(9, 15, 25, 0.94) 18%, rgba(9, 15, 25, 0.94) 82%, transparent);
  border-top: 1px solid rgba(217, 164, 65, 0.45);
  border-bottom: 1px solid rgba(217, 164, 65, 0.45);
}
#banner.run { animation: banner 1750ms var(--ease); }
@keyframes banner {
  0%   { opacity: 0; transform: scale(0.94); }
  14%  { opacity: 1; transform: scale(1); }
  76%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.03); }
}

/* --------------------------------------------------------------- effects -- */
#fx-layer { position: fixed; inset: 0; z-index: 85; pointer-events: none; overflow: hidden; }

.floater {
  position: absolute;
  font-size: 26px; font-weight: 700;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.95);
  animation: float-up 1250ms var(--ease) forwards;
}
.floater.dmg { color: var(--red-lit); }
.floater.heal { color: var(--green-lit); }
@keyframes float-up {
  0%   { opacity: 0; transform: translate(-50%, 0) scale(0.6); }
  18%  { opacity: 1; transform: translate(-50%, -14px) scale(1.22); }
  38%  { transform: translate(-50%, -22px) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -74px) scale(0.94); }
}

/* Card in flight -- hand to stack, stack to battlefield. */
.ghost {
  position: absolute;
  border-radius: var(--r-sm);
  overflow: hidden;
  box-shadow: var(--lift-lg);
  pointer-events: none;
}
.ghost img { width: 100%; height: 100%; object-fit: contain; display: block; }

/* Expanding ring when a permanent lands. */
.ring {
  position: absolute;
  border-radius: 50%;
  border: 2px solid var(--gold);
  animation: ring-out 700ms var(--ease) forwards;
}
@keyframes ring-out {
  from { opacity: 0.85; transform: translate(-50%, -50%) scale(0.25); }
  to   { opacity: 0; transform: translate(-50%, -50%) scale(2.3); }
}

.shake { animation: shake 380ms var(--ease); }
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  18% { transform: translateX(-7px); }
  38% { transform: translateX(6px); }
  62% { transform: translateX(-4px); }
  82% { transform: translateX(2px); }
}

/* Attack arrows, drawn attacker -> defender.
   width/height are required: <svg> is a replaced element with an intrinsic
   300x150 default, so `inset: 0` alone will not stretch it. */
#arrows {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  z-index: 25; pointer-events: none;
}
#arrows path { stroke: var(--red); stroke-width: 2.5; fill: none; filter: drop-shadow(0 0 6px rgba(220,38,38,0.85)); }
/* A head, not a dot: a dot marks a spot, an arrowhead says which way it goes. */
#arrows polygon { fill: var(--red); filter: drop-shadow(0 0 6px rgba(220, 38, 38, 0.85)); }

/* -------------------------------------------------------------- overlays -- */
.overlay {
  position: fixed; inset: 0;
  background: rgba(5, 9, 15, 0.8);
  backdrop-filter: blur(8px);
  display: none; align-items: center; justify-content: center;
  z-index: 100;
  padding: 20px;
}
.overlay.show { display: flex; animation: fade var(--t-med) var(--ease); }

/*
 * Stepping out of a choice to look at the board.
 *
 * "Keep this on top or put it on the bottom" is not a question you can answer
 * from the card alone -- it depends on what is already in play, and the modal
 * that asks it covers exactly that. So the choice can be set aside and picked
 * back up, without being answered or cancelled: the overlay goes away, its
 * state stays, and one control brings it back.
 *
 * Hidden with visibility rather than `display:none` so nothing inside it
 * reflows -- a grid that re-lays-out on the way back would lose scroll
 * position and, worse, make the cards jump under the cursor.
 */
.overlay.peeking { visibility: hidden; pointer-events: none; }
.peek-toggle {
  position: fixed;
  left: 50%; transform: translateX(-50%);
  bottom: calc(2 * var(--vh, 1vh));
  z-index: 210;
  padding: 9px 18px;
  border-radius: 999px;
  border: 1px solid var(--gold-lit);
  background: rgba(10, 16, 24, 0.94);
  color: var(--gold-lit);
  font-family: var(--display); font-size: 12px;
  letter-spacing: 1.1px; text-transform: uppercase;
  cursor: pointer;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.65);
}
.peek-toggle:hover { background: rgba(20, 30, 44, 0.97); }
/* While the board is being looked at the control is the only thing on screen
   from the choice, so it says what it will do rather than where it came from. */
.peek-toggle.returning { border-color: var(--arcane-lit); color: var(--arcane-lit); }

/*
 * The prompt panel is not a modal, and treating it as one was a bug you could
 * not play through. Half of what it raises is answered by *moving a card* --
 * "discard two", "sacrifice a creature", "put a card on top" -- and a
 * full-screen backdrop over the hand made those literally unclickable: the
 * game asked a question and then covered the only way to answer it.
 *
 * So it docks in the corner and lets every click through except its own.
 */
.overlay.passthrough {
  background: none;
  backdrop-filter: none;
  pointer-events: none;
  align-items: flex-start;
  justify-content: flex-end;
  padding: 12px;
}
.overlay.passthrough .modal {
  pointer-events: auto;
  width: min(400px, calc(94 * var(--vw, 1vw)));
  max-height: calc(74 * var(--vh, 1vh));
  margin: 0;
  padding: 16px 18px;
  /* It has to read as "answer me" without dimming what you must click. */
  box-shadow: var(--lift-lg), var(--glow-gold);
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }

/*
 * The plate everything is struck on.
 *
 * Was a flat rectangle with one border, which is why it read as a dialog. It
 * is built like the buttons now, because it is the same material: a cold
 * gradient for the metal, a lit top lip so the edge catches light, and a hair
 * of ember bleeding in from the top-left as though the torch in the artwork
 * reaches it. The lip is the detail that stops a panel reading as a div -- the
 * comment on `button` says so, and then only the buttons had one.
 */
.modal {
  position: relative;
  background:
    /* the torch, just reaching the top-left corner */
    radial-gradient(120% 80% at 0% 0%, rgba(232, 162, 76, 0.09), transparent 55%),
    linear-gradient(180deg, #182434, #0d1622);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  width: min(1000px, calc(96 * var(--vw, 1vw)));
  max-height: calc(92 * var(--vh, 1vh));
  overflow: auto;
  padding: 24px 26px;
  box-shadow:
    var(--lift-lg),
    inset 0 1px 0 rgba(255, 255, 255, 0.08),
    inset 0 0 0 1px rgba(0, 0, 0, 0.35);
  animation: modal-in var(--t-med) var(--ease);
}
/* Rises INTO place rather than appearing: the eye follows the movement to the
   panel, which is most of why a well-animated dialog feels quick even when it
   is not faster. */
@keyframes modal-in {
  from { opacity: 0; transform: translateY(18px) scale(0.975); }
  to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .modal { animation: fade var(--t-fast) var(--ease); }
}
/*
 * A panel, not a form.
 *
 * These read as an operating-system dialog: a flat box, a bold sans title, a
 * grey line of help text, contents. The token block at the top of this file
 * already diagnosed why -- "uniform 13px sans on every line is what made this
 * read as a form; the fix is not bigger text, it is text with a hierarchy" --
 * and then `.modal h2` went on using the interface face anyway. Cinzel exists
 * in this system for exactly this: inscriptional capitals that say "this is a
 * game" before a word of it is read.
 *
 * The title and its help text become a NAMEPLATE: they bleed to the panel
 * edges and sit above a hairline that starts brass at the left and cools into
 * arcane before it fades, which is the same two-light rule the rest of the
 * interface follows -- ember for your side of it, arcane for the system's.
 */
.modal h2 {
  margin: 0 0 6px;
  font-family: var(--display);
  font-size: 25px; font-weight: 700;
  letter-spacing: 0.6px;
  color: var(--ember-lit);
  text-shadow: 0 1px 0 rgba(0, 0, 0, 0.55);
}
.modal .sub {
  color: var(--dim);
  font-size: 12.5px; line-height: 1.65;
  /* Bleeds to the panel edge so the rule under it spans the whole plate. The
     negative margin matches .modal's horizontal padding. */
  margin: 0 -26px 20px;
  padding: 0 26px 15px;
  background: linear-gradient(90deg,
      rgba(232, 162, 76, 0.55),
      rgba(146, 205, 232, 0.20) 38%,
      transparent 80%)
    bottom left / 100% 1px no-repeat;
}
/* A title with no help text under it still gets its rule. */
.modal h2:last-child,
.modal h2:not(:has(+ .sub)) {
  margin: 0 -26px 20px;
  padding: 0 26px 14px;
  background: linear-gradient(90deg,
      rgba(232, 162, 76, 0.55),
      rgba(146, 205, 232, 0.20) 38%,
      transparent 80%)
    bottom left / 100% 1px no-repeat;
}

.row { display: flex; gap: 12px; align-items: flex-end; margin-bottom: 12px; flex-wrap: wrap; }
.row > * { flex: 1; min-width: 130px; }
.row > .narrow { flex: 0 0 auto; min-width: 0; }
/* Labels are captions on an object, not headers on a form: small, spaced, and
   quiet enough that the value is what you read. */
label {
  display: block;
  font-size: 10px; letter-spacing: 1.1px; text-transform: uppercase;
  color: #9fc2d8;
  margin-bottom: 6px; font-weight: 600;
}
hr.sep { border: 0; border-top: 1px solid var(--line); margin: 18px 0; }

.grid-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 10px; }
/* A graveyard is READ, not skimmed. At 104px the type line and the text box are
   texture rather than information, and these are the two grids where the whole
   point is to judge particular cards -- what is in a pile, and what to keep on
   top of the library. The search browser keeps the denser tiles: there you are
   scanning a hundred results for a name, which is a different job. */
#zone-cards, #scry-cards {
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
  gap: 14px;
}
#ov-zone .modal { width: min(1180px, calc(96 * var(--vw, 1vw))); }
.grid-cards .card { width: 100%; }

/* Printing picker. Bigger tiles than the card grid: the whole point is to
   look at the art, so the set line is secondary to the picture. */
.grid-prints {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 14px;
  margin-top: 16px;
  overflow-y: auto;
  padding: 2px;
}
.print {
  border: 2px solid transparent;
  border-radius: var(--r-md);
  padding: 4px;
  cursor: pointer;
  background: rgba(255, 255, 255, 0.03);
  transition: border-color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.print:hover { border-color: var(--line-strong); transform: translateY(-2px); }
.print.chosen { border-color: var(--gold); box-shadow: var(--glow-gold); }
.print img { width: 100%; border-radius: var(--r-sm); display: block; }
.print .meta { font-size: 11px; color: var(--dim); margin-top: 5px; line-height: 1.35; }
.print .meta b { color: var(--text); font-weight: 600; }
.print .meta .who { color: var(--faint); }

/* ------------------------------------------------------------- lobby -- */
/* Two columns: identity and table state on the left under the title art,
   the things you actually do on the right, numbered in the order you do them. */
.lobby {
  position: relative;              /* the frame ornament lies over both columns */
  display: grid;
  grid-template-columns: minmax(300px, 0.9fr) minmax(340px, 1fr);
  /* `minmax(0, 1fr)`, not the implicit `auto`. An auto row is sized to its
     tallest child, so max-height below did not constrain the columns -- it
     clipped them: the steps list grew past the panel, the row grew with it,
     and the bottom of *both* columns was cut off. The hero lost its title and
     roster that way, and every time the steps got taller it lost more.
     A zero-floor row lets the children take the panel's height and scroll
     inside it, which is what their own overflow rules were already waiting
     for. */
  grid-template-rows: minmax(0, 1fr);
  /* Fills its cell instead of sitting at a fixed height in the middle of it.
     The panel was capped at 92vh but centred inside a flex overlay, so on a
     tall window it stayed short and the artwork stayed squished with dead
     space above and below. Now the column grows and the art grows with it. */
  grid-column: 2; grid-row: 2;
  width: 100%;
  max-width: 1180px;
  height: 100%;
  /* ...but not without limit. The art column's width stops growing at ~547px
     when the modal hits max-width, so past about a 1300px-tall window the
     panel just gets thinner and thinner in proportion -- 547x1150 at 1440p --
     and the contained plate leaves an ever-wider blurred band above and below.
     Capping the height keeps the panel's proportions in the range the plates
     are actually painted for, and centring means the surplus becomes even
     margin rather than a drop at the bottom. */
  max-height: 1040px;
  align-self: center;
  min-height: 0;
  /* Warmer and deeper than the old flat slate: a little brown in the shadow
     reads as lamplight on wood rather than as a settings dialog. */
  background:
    radial-gradient(ellipse 120% 80% at 50% -10%, rgba(91, 172, 212, 0.12), transparent 62%),
    linear-gradient(180deg, #102432, #081824 62%, #05101a);
  border: 1px solid rgba(217, 164, 65, 0.30);
  border-radius: var(--r-lg);
  /* Gold hairline inside the border -- an inlay, the way a good box has a line
     of brass just inside its edge. Cheap, static, and does more for "crafted
     object" than any amount of gradient. */
  box-shadow: var(--lift-lg),
              inset 0 0 0 1px rgba(255, 255, 255, 0.04),
              inset 0 1px 0 rgba(255, 255, 255, 0.07);
  overflow: hidden;
  animation: modal-in var(--t-med) var(--ease);
}

.lobby-hero {
  position: relative;
  display: flex; flex-direction: column;
  border-right: 1px solid var(--line);
  min-height: 0;
}
/*
 * The plate is shown WHOLE, and the panel is filled by a blurred copy of it.
 *
 * This panel is not a fixed shape. Its width pins at ~547px once the modal hits
 * its max, but its height follows the window, so measured across real sizes it
 * runs from 0.85 (436x512 at 1280x720) through 0.67 (547x816 at 1080p) to 0.48
 * (547x1150 at 1440p). Nearly a 2x spread. Under `cover` that meant every plate
 * was cropped on one axis or the other at most window sizes, and the axis it
 * ate was unpredictable -- on a duel composition it took the outflung arms and
 * the spell, which is the picture.
 *
 * So: `contain` for the plate, which cannot crop, and the space it cannot reach
 * gets a scaled, blurred, darkened copy of the same image rather than a bar.
 * The join is invisible because the colours are by definition the plate's own.
 *
 * What this buys is that source aspect ratio stops being a constraint. Any
 * shape is displayed intact, so hand-made art does not have to be generated at
 * a particular size to be usable.
 *
 * The old `center 38%` bias is gone with `cover`; a contained plate is centred
 * and there is nothing to bias.
 */
.lobby-hero-art {
  position: absolute; inset: 0;
  /* The fallback for a checkout that has never run the art pipeline. With
     a plate loaded into .hero-plate, that layer covers this completely. */
  background: linear-gradient(160deg, #1d2b3f, #0b1220);
  overflow: hidden;
}

/*
 * One plate, on its own layer so another can be faded up over it.
 *
 * Each layer draws the whole treatment from its own two pseudo-elements -- the
 * blurred fill and the contained plate -- off a single `--img`. That is the
 * reason for the extra elements: the treatment needs two backgrounds, an
 * element has exactly two pseudo-elements, and a cross-fade needs two of the
 * whole thing.
 */
.hero-plate {
  position: absolute; inset: 0;
  opacity: 0;
  transition: opacity 1800ms ease;
}
.hero-plate.is-on { opacity: 1; }
/* The swap still happens; it just stops being a fade. */
@media (prefers-reduced-motion: reduce) {
  .hero-plate { transition: none; }
}

/*
 * The fill, which used to be the worst thing on the screen.
 *
 * Kevin: "I hate the ornament that goes around the outside frame, it looks
 * stretched and ugly. I like the idea, but the execution is lacking. Do
 * something better or nothing at all."
 *
 * He is describing this: a `cover`-scaled copy of the plate, blurred 36px and
 * wrapped round the contained plate like a mat. 36px is not enough blur at
 * this size -- shapes survived it, and a recognisable shape stretched to a
 * different aspect than the picture it borders reads as a smear, which is
 * exactly the fault the lobby's own filigree was removed for.
 *
 * The idea is right: the surround should be made of the plate's own colours,
 * so the panel is lit by the picture rather than framed against slate. The
 * execution now takes the blur far enough that NOTHING survives it but colour
 * -- no edges, no forms, nothing that can look stretched because there is no
 * shape left to distort. Darker too, so it reads as spill light off the plate
 * rather than as a second, worse copy of it.
 */
.hero-plate::before {
  content: "";
  position: absolute; inset: -22%;
  z-index: 0;
  background: var(--img, none) center / cover no-repeat;
  filter: blur(90px) saturate(1.05) brightness(0.32);
}

/*
 * The plate itself, sized to its own fitted rectangle rather than to the
 * panel, and faded at the edges instead of stopping dead.
 *
 * `background-size: contain` centres the picture inside the whole panel, which
 * means the seam -- where sharp plate meets soft fill -- is somewhere in the
 * MIDDLE of this box, at a position that moves with the window. Nothing in CSS
 * can put a gradient there, which is why the edge was a hard cut: not a choice,
 * a limitation.
 *
 * Now that every plate is cropped to one ratio (scripts/uniform-plates.py) the
 * fitted rectangle is computable, and `fitHeroPlates` in app.js publishes it as
 * --plate-w / --plate-h. The box is that rectangle, so its edges ARE the
 * picture's edges and the mask lands exactly on the seam.
 */
.hero-plate::after {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  width: var(--plate-w, 100%);
  height: var(--plate-h, 100%);
  transform: translate(-50%, -50%);
  z-index: 1;
  background: var(--img, none) center / cover no-repeat;
  /* Short, and unequal: the long edges get the softer fade because they are
     where the fill has most room to show, and a fade the same width on all
     four sides reads as a vignette rather than as a picture dissolving. */
  -webkit-mask-image:
    linear-gradient(to right, transparent 0, #000 6%, #000 94%, transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
  -webkit-mask-composite: source-in;
  mask-image:
    linear-gradient(to right, transparent 0, #000 6%, #000 94%, transparent 100%),
    linear-gradient(to bottom, transparent 0, #000 4%, #000 96%, transparent 100%);
  mask-composite: intersect;
}
/* When the parallax layers are present they replace the flat hero image
   entirely; the plain background above stays as the fallback for a checkout
   that has not run the art pipeline. */
.lobby-hero-art.has-layers { background-image: none; }

/* ------------------------------------------------------ lobby backdrop -- */
/*
 * The lobby gets its own scene behind it rather than a blurred view of the
 * table. Two things follow from that. The art is composed as a frame around a
 * hole -- drama at the edges, near-black through the middle where the panel
 * sits -- so the panel never has to fight it. And the backdrop-filter comes
 * off: it existed to make an arbitrary board readable underneath, and blurring
 * a picture chosen to sit under a panel just costs a full-screen GPU pass.
 */
#ov-lobby {
  backdrop-filter: none;
  background:
    linear-gradient(180deg, rgba(4, 8, 14, 0.55), rgba(4, 8, 14, 0.78)),
    var(--backdrop, none) center / cover no-repeat,
    #05090f;
}

/*
 * The wooden table as the backdrop instead of a painted scene.
 *
 * A different problem from a landscape: wood is a TEXTURE, so `cover` on a
 * wide screen blows the grain up until it reads as brown soup. It tiles at a
 * fixed size instead, and takes a warmer, lighter scrim than the forest --
 * a scene wants to be pushed back, a table wants to look lit.
 */
:root.backdrop-wood #ov-lobby {
  background:
    radial-gradient(ellipse 80% 70% at 50% 30%, rgba(255, 214, 150, 0.10), transparent 70%),
    linear-gradient(180deg, rgba(10, 7, 4, 0.52), rgba(6, 4, 2, 0.72)),
    var(--backdrop, none) center / 720px auto repeat,
    #120c07;
}

/*
 * The lobby floor plan.
 *
 * Three things across, and the roster is one of them rather than something
 * stacked on top of the artwork. Pushed RIGHT of centre on purpose: the right
 * edge of the scene is dead space and the left is where the brandmark and the
 * roster want to be, so the whole arrangement leans that way.
 *
 * A COLUMN layout, not a centred flex child, because the status line has to
 * sit on the floor of the screen underneath everything else.
 */
#ov-lobby.show {
  display: grid;
  grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
  /* A thin row above the panel for the track control, the panel itself, and
     the status line on the floor. */
  grid-template-rows: auto minmax(0, 1fr) auto;
  align-content: stretch;
  justify-items: stretch;
  gap: 14px 22px;
  padding: clamp(16px, 4vh, 48px) clamp(16px, 3vw, 40px) 14px;
  /* Room for the brandmark, which sits over the top left of the scene. */
  padding-top: clamp(96px, 14vh, 170px);
}

.lobby-side {
  grid-column: 1; grid-row: 2;
  display: flex; flex-direction: column; gap: 10px;
  min-height: 0;
  /* The panel's own box, so the two tops line up and the column has a bottom
     for the chat to reach. It used to be `align-self: start`, sized to its
     contents -- which was right when the only content was a roster, and left
     the column three quarters empty once there was something to put under it. */
  align-self: center;
  height: 100%;
  max-height: 1040px;
  padding-top: 4px;
  /* The COLUMN no longer scrolls; the two things in it do. A scroll here moved
     the chat's input box off the bottom of the screen instead of keeping it
     pinned under its own log. */
  overflow: hidden;
}
/* The roster takes what it needs and gives the rest to the chat, rather than
   both splitting the column evenly -- a two-player table would otherwise leave
   a half-height list of two names above a cramped conversation. */
.lobby-side > .roster { flex: 0 1 auto; min-height: 0; overflow-y: auto; }
.lobby-side-title {
  margin: 0;
  font: 600 11px/1 var(--ui, inherit);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(217, 164, 65, 0.75);
}
/*
 * Transport controls, above the panel and on the scene.
 *
 * Mute, volume and skip are things you reach for while listening, so they sit
 * in the open instead of three clicks into Preferences. Drawn as lit metal on
 * the scene rather than as form controls: a row of grey rounded rectangles
 * over the artwork is what made this read as an account settings page.
 *
 * Everything is quiet until pointed at. The row is the least important thing
 * on the screen right up until the moment you want it.
 */
/*
 * Sits on the panel's top edge, right-aligned to the PANEL.
 *
 * `justify-self: end` was aligning it to the grid column, which is far wider
 * than the panel -- the panel caps at 1180px and the column does not -- so the
 * controls floated off into the dead space beyond the panel's corner with no
 * edge to belong to. Matching the panel's own box and pushing the contents to
 * its far end puts them exactly above its top-right corner, which is where
 * they read as part of the panel rather than as debris on the artwork.
 *
 * `align-items: end` in the row and no bottom margin keep them ON the edge
 * rather than hovering above it.
 */
/*
 * Bottom-left corner of the WINDOW, and nothing to do with the panel.
 *
 * It used to live in the grid, above the panel and aligned to its right edge,
 * and every attempt to line the two up fought something: the panel carries an
 * entry transform, its box stops tracking the grid column once `max-width`
 * binds, and the alignment that looked right at one window size was wrong at
 * the next. None of that was worth solving for four small icons that nobody
 * looks at while reading the panel.
 *
 * Out of flow entirely, pinned to a corner. `.overlay` is `position: fixed`, so
 * absolute here resolves against the viewport and the corner is the corner at
 * every size, with no measurement involved. It also means the lobby grid's
 * first row now has nothing in it and collapses, which is the layout being
 * honest -- there is no transport row any more.
 */
.track-bar {
  position: absolute;
  left: 18px;
  bottom: 12px;
  z-index: 2;
  display: flex; align-items: center; gap: 10px;
  margin: 0;
  color: rgba(217, 164, 65, 0.55);
  transition: color var(--t-fast, 120ms) ease;
}
.track-bar:hover { color: rgba(217, 164, 65, 0.85); }

.track-btn {
  display: grid; place-items: center;
  width: 26px; height: 26px;
  padding: 0;
  background: none; border: 0; border-radius: 0; box-shadow: none;
  color: inherit;
  cursor: pointer;
  transition: color var(--t-fast, 120ms) ease, filter var(--t-fast, 120ms) ease;
}
.track-btn svg { width: 17px; height: 17px; display: block; }
.track-btn:hover,
.track-btn:focus-visible {
  background: none;
  color: rgba(247, 214, 140, 1);
  filter: drop-shadow(0 0 8px rgba(217, 164, 65, 0.5));
}
.track-btn:active { transform: translateY(1px); }
.track-btn.is-muted { color: rgba(214, 106, 92, 0.85); }
/* DnB mode: a word, not a glyph, so it needs type rather than an icon box. */
.track-mode {
  width: auto; padding: 0 9px;
  font-size: 10.5px; font-weight: 800;
  letter-spacing: 0.6px;
}
/* Lit while it is the mode you are in -- ember, like every other thing on this
   interface that means "you chose this". */
.track-mode.is-on {
  color: #201203;
  background: linear-gradient(180deg, var(--ember-lit), var(--ember));
  border-color: rgba(232, 162, 76, 0.75);
}
/* Nothing tagged `dnb`, so there is nothing to switch to. Left visible rather
   than hidden: the mode exists, the tracks are simply not installed, and a
   button that vanishes teaches nothing. The title says which. */
.track-mode:disabled { opacity: 0.35; cursor: not-allowed; }

/* A hairline of gold rather than a form slider. */
.track-vol {
  -webkit-appearance: none; appearance: none;
  width: 74px; height: 14px;
  background: none; border: 0; padding: 0;
  cursor: pointer;
}
.track-vol::-webkit-slider-runnable-track {
  height: 2px; border-radius: 1px;
  background: currentColor; opacity: 0.5;
}
.track-vol::-moz-range-track {
  height: 2px; border-radius: 1px;
  background: currentColor; opacity: 0.5;
}
.track-vol::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 9px; height: 9px; margin-top: -3.5px;
  border-radius: 50%; border: 0;
  background: rgba(247, 214, 140, 0.95);
}
.track-vol::-moz-range-thumb {
  width: 9px; height: 9px;
  border-radius: 50%; border: 0;
  background: rgba(247, 214, 140, 0.95);
}
.track-vol:focus-visible { outline: 1px solid rgba(217, 164, 65, 0.6); outline-offset: 4px; }

/* Speed control for a bot battle. Sits on the floor of the screen, out of the
   board's way, and only exists while you are watching one. */
.watch-bar {
  position: fixed;
  left: 50%; bottom: 10px;
  transform: translateX(-50%);
  z-index: 90;
  display: flex; align-items: center; gap: 6px;
  padding: 5px 8px;
  background: rgba(8, 13, 20, 0.88);
  border: 1px solid var(--line);
  border-radius: 999px;
  box-shadow: var(--lift-lg, 0 8px 24px rgba(0, 0, 0, 0.5));
}
/* Now covered by the global [hidden] guard near the top of this file; kept
   because it costs nothing and says what it means at the point of use. */
.watch-bar[hidden] { display: none; }
.watch-label {
  font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--faint, rgba(255, 255, 255, 0.45));
  padding: 0 4px;
}
.watch-btn {
  background: none; border: 1px solid transparent; border-radius: 999px;
  padding: 2px 9px;
  font-size: 11px; font-weight: 600;
  color: var(--dim);
  cursor: pointer;
}
.watch-btn:hover { color: var(--text); border-color: var(--line-strong); }
.watch-btn.is-on {
  color: var(--gold-lit);
  border-color: rgba(217, 164, 65, 0.55);
}
.watch-bar.is-dead { border-color: rgba(255, 120, 120, 0.6); }
.watch-bar.is-dead .watch-label { color: #ff8f8f; }

/* Losing the socket used to be a toast that faded, over a board that had
   stopped moving -- indistinguishable from the game hanging. This does not
   fade. */
.dead-banner {
  position: fixed;
  left: 50%; top: 14px;
  transform: translateX(-50%);
  z-index: 200;
  padding: 7px 16px;
  background: rgba(60, 12, 12, 0.95);
  border: 1px solid rgba(255, 120, 120, 0.65);
  border-radius: 999px;
  color: #ffdcdc;
  font-size: 12px; font-weight: 600;
  cursor: pointer;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}

/*
 * Refused at the door.
 *
 * A full cover rather than a toast: there is no table behind this and nothing
 * to look at, so anything less reads as the app having failed to load. It is
 * also the one screen a brand-new player may meet before anything else, which
 * is why it explains itself rather than only reporting.
 */
.key-gate {
  position: fixed; inset: 0;
  z-index: 300;
  display: grid; place-items: center;
  padding: 24px;
  background: rgba(6, 10, 15, 0.94);
  backdrop-filter: blur(3px);
}
.key-gate-box {
  width: min(420px, 100%);
  display: flex; flex-direction: column; gap: 12px;
  padding: 22px 22px 20px;
  background: var(--panel, rgba(14, 20, 28, 0.98));
  border: 1px solid var(--gold-lit, rgba(217, 164, 65, 0.5));
  border-radius: var(--r-lg, 16px);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.7);
}
.key-gate-box h2 {
  margin: 0;
  font-family: var(--display, serif);
  font-size: 19px; font-weight: 600;
  color: var(--gold-lit, #e6c877);
}
.key-gate-box p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--ink-dim, #a8b4c2); }
.key-gate-box input {
  padding: 10px 12px;
  font-size: 16px;                        /* 16px or iOS zooms the page on focus */
  font-family: var(--mono, ui-monospace, monospace);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink, #e8eef5);
  background: rgba(0, 0, 0, 0.4);
  border: 1px solid var(--line, rgba(255, 255, 255, 0.18));
  border-radius: var(--r-sm, 6px);
}
.key-gate-box input:focus-visible {
  outline: none;
  border-color: var(--gold-lit, #e6c877);
  box-shadow: var(--glow-gold, 0 0 0 2px rgba(217, 164, 65, 0.35));
}

/* Signing in, on the same overlay as the key gate: it is the same moment from
   the player's side, and two different-looking doors would be two things to
   learn. What it does NOT inherit is the key field's treatment -- a key is a
   short code that reads better spaced out and upper-cased, and a person's name
   is neither of those things. Left as-is, "Kevin" was drawn "K E V I N". */
#sign-in .key-gate-box input {
  font-family: inherit;
  letter-spacing: normal;
  text-transform: none;
}
#sign-in .sign-in-error {
  color: var(--bad, #e2726e);
  font-size: 12.5px;
}
/* The command an unowned server needs. Selectable and monospaced, because the
   only useful thing to do with it is copy it into a terminal. */
#sign-in .sign-in-cmd {
  padding: 9px 11px;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 13px;
  color: var(--gold-lit, #e6c877);
  background: rgba(0, 0, 0, 0.45);
  border: 1px solid var(--line, rgba(255, 255, 255, 0.14));
  border-radius: var(--r-sm, 6px);
  user-select: all;
}
/* The way to the other mode, deliberately quieter than the button that
   finishes the thing you are already doing. */
#sign-in .sign-in-swap {
  align-self: center;
  padding: 4px 8px;
  font-size: 12.5px;
  color: var(--ink-dim, #a8b4c2);
  background: none;
  border: none;
  text-decoration: underline;
  text-underline-offset: 3px;
}
#sign-in .sign-in-swap:hover { color: var(--ink, #e8eef5); }

/* A rule with "or" sitting in it, separating the two ways in. */
#sign-in .sign-in-or {
  display: flex; align-items: center; gap: 10px;
  margin: 2px 0;
  font-size: 11.5px; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--ink-dim, #a8b4c2);
}
#sign-in .sign-in-or::before,
#sign-in .sign-in-or::after {
  content: ''; flex: 1;
  border-top: 1px solid var(--line, rgba(255, 255, 255, 0.14));
}
/* Quieter than the primary button on purpose: most people arriving already
   have an account here, and the button they want should be the loud one. */
#sign-in .sign-in-google {
  display: block;
  padding: 10px 12px;
  font-size: 14px;
  text-align: center;
  text-decoration: none;
  color: var(--ink, #e8eef5);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--line, rgba(255, 255, 255, 0.2));
  border-radius: var(--r-sm, 6px);
}
#sign-in .sign-in-google:hover {
  background: rgba(255, 255, 255, 0.11);
  border-color: var(--gold-lit, rgba(217, 164, 65, 0.5));
}

/* The lobby rotation used to be listed down the side here, and the current
   track named in the bar. Both are gone: the entries were filenames rather
   than titles, so the column said nothing. See paintTransport() in app.js. */

/* The status line, on the floor. */
#ov-lobby .lobby-stats {
  grid-column: 1 / -1; grid-row: 3;
  text-align: center;
  align-self: end;
}

/* ------------------------------------------------------------- dungeon -- */
/*
 * The dungeon drawn as the graph it is. Rows are depth from the entrance,
 * columns are the branches, and the connections are actually drawn -- a list of
 * rooms says nothing about a dungeon that forks twice and rejoins twice.
 */
/* The printed card as the map, because that is what a dungeon card is. */
.dungeon-card {
  position: relative;
  display: block;
  width: min(100%, 460px);
  margin: 14px auto 6px;
  line-height: 0;
}
.dungeon-card img {
  width: 100%;
  border-radius: 14px;
  display: block;
  box-shadow: var(--lift-lg);
}
/* A token dropped in the room you are standing in. */
.dg-spot {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 34px; height: 34px;
  border-radius: 50%;
  display: grid; place-items: center;
  line-height: 1;
  pointer-events: none;
}
.dg-spot.here {
  background: radial-gradient(circle at 50% 35%, var(--flare), var(--arcane-lit) 60%, #14506b);
  border: 2px solid rgba(255, 255, 255, 0.85);
  box-shadow: 0 0 20px rgba(91, 172, 212, 0.9), 0 2px 6px rgba(0, 0, 0, 0.7);
}
/* A room you may venture into: a ring you can click, over the room itself. */
/* Sized to the chamber it sits over, so it reads as "this room" rather than as
   a button that happens to be nearby. */
.dg-spot.choice {
  width: 30%; height: 15%;
  border-radius: 10px;
  background: rgba(217, 164, 65, 0.16);
  border: 2px solid var(--ember);
  box-shadow: 0 0 18px rgba(217, 164, 65, 0.55);
  cursor: pointer;
  pointer-events: auto;
  animation: pulse-target 1.6s var(--ease) infinite;
}
.dg-spot.choice:hover { background: rgba(217, 164, 65, 0.3); }
.dg-spot .go {
  font: 600 9px/1 Inter, system-ui, sans-serif;
  letter-spacing: 1.4px; text-transform: uppercase;
  color: #2a1d05;
  background: var(--ember-lit);
  padding: 3px 6px; border-radius: 999px;
}

.dungeon-map {
  position: relative;
  width: 100%;
  height: min(560px, calc(62 * var(--vh, 1vh)));
  margin: 14px 0 6px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background:
    radial-gradient(ellipse 70% 60% at 50% 35%, rgba(91, 172, 212, 0.07), transparent 70%),
    linear-gradient(180deg, rgba(4, 12, 18, 0.6), rgba(4, 12, 18, 0.85));
  overflow: hidden;
}
.dg-edges {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
}
.dg-edges line {
  stroke: rgba(146, 205, 232, 0.22);
  stroke-width: 0.35;
  vector-effect: non-scaling-stroke;
}
/* The way you could go right now. */
.dg-edges line.live {
  stroke: var(--ember);
  stroke-width: 1.1;
  stroke-dasharray: 3 2;
}

.dg-node {
  position: absolute;
  transform: translate(-50%, -50%);
  /* Width is set per node from how many share its row -- see drawDungeonMap. */
  max-width: 210px;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: linear-gradient(180deg, #17303f, #0d1e29);
  box-shadow: inset 0 1px 0 rgba(176, 235, 241, 0.08), 0 3px 10px rgba(0, 0, 0, 0.55);
  text-align: center;
}
.dg-node .nm {
  font-family: var(--display);
  font-size: 12px; letter-spacing: 0.6px;
  color: var(--arcane-lit);
  line-height: 1.25;
}
.dg-node .fx {
  font-size: 10.5px; line-height: 1.4; color: var(--dim);
  margin-top: 4px;
  /* Rooms differ wildly in text length; clamp so the map stays a map. */
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
  overflow: hidden;
}
.dg-node .you, .dg-node .go {
  margin-top: 5px;
  font-size: 9.5px; letter-spacing: 1.2px; text-transform: uppercase;
}
.dg-node .you { color: var(--flare); }
.dg-node .go { color: var(--ember-lit); }

/* Where the marker is. */
.dg-node.here {
  border-color: rgba(176, 235, 241, 0.75);
  box-shadow: var(--glow-arcane), 0 4px 14px rgba(0, 0, 0, 0.6);
  background: linear-gradient(180deg, #1d4b60, #102b39);
  z-index: 3;
}
/* A room you may venture into. */
.dg-node.choice {
  cursor: pointer;
  border-color: rgba(217, 164, 65, 0.7);
  animation: pulse-target 1.6s var(--ease) infinite;
  z-index: 2;
}
.dg-node.choice:hover {
  background: linear-gradient(180deg, #38301a, #1d1a10);
  border-color: var(--ember-lit);
}

@media (max-width: 720px) {
  .dg-node .nm { font-size: 11px; }
  .dg-node .fx { -webkit-line-clamp: 2; font-size: 10px; }
}

/* --------------------------------------------------- generated ornament -- */
/*
 * The filigree plate is a full frame -- a hairline rectangle with scrollwork at
 * the corners -- so it is applied with `border-image`, which slices the image
 * into nine and stretches only the edges. Stretching the whole thing to the
 * panel would squash the scrollwork; this keeps every corner at its painted
 * proportions and lets the plain hairlines take up the slack.
 *
 * `screen` again: the plate is gold on black, and black is the alpha.
 */
.frame-orn {
  position: absolute; inset: 0;
  z-index: 5;
  pointer-events: none;
  border: 58px solid transparent;
  border-image-source: url('/assets/ui-filigree-corner.png');
  border-image-slice: 150;
  border-image-repeat: stretch;
  mix-blend-mode: screen;
  opacity: 0.95;
}
/* The plate FLUX painted carries its scrollwork on the right-hand corners only,
   so on its own it decorates half a panel and reads as a mistake. A mirrored
   copy over the top fills the other two. Screen compositing makes this free:
   two copies of gold-on-black add up to gold in four corners. */
.frame-orn::after {
  content: "";
  position: absolute; inset: -58px;
  border: 58px solid transparent;
  border-image-source: url('/assets/ui-filigree-corner.png');
  border-image-slice: 150;
  border-image-repeat: stretch;
  transform: scaleX(-1);
  /* Screen on the mirror too. Its own corners are black where the plate is
     empty, and an opaque black tile sitting over the original's gold is exactly
     what wiped the right-hand corners off the panel. */
  mix-blend-mode: screen;
}

/* The table's own mark, over the title. */
.crest {
  width: 54px; height: 54px;
  margin-bottom: -6px;
  background: url('/assets/ui-crest.png') center / contain no-repeat;
  mix-blend-mode: screen;
  opacity: 1;
  /* No `filter` here, however tempting a glow is: a filter gives the element
     its own stacking context, the screen blend then stops reaching the plate
     behind it, and the crest's black field paints as an opaque square box. */
}

/* ------------------------------------------------ movement that is free -- */
/*
 * Motion that cannot cost a repaint.
 *
 * The old scene was expensive for one specific reason: it animated
 * `background-position` on a large sprite sheet, which is a *paint* every
 * frame, and it did it under a full-screen backdrop blur. Transform and opacity
 * are different in kind -- a layer that only translates or fades is handed to
 * the compositor once and reused, so the per-frame cost does not scale with the
 * size of the picture underneath.
 *
 * So: two layers, both composited, both animating nothing but transform and
 * opacity, on slow cycles. `will-change` promotes them once rather than
 * thrashing. No filters, no blur, no background-position, no box-shadow, and
 * nothing under a backdrop-filter.
 *
 * The motes plate is gold-and-silver light painted on black, dropped in with
 * `screen` -- under which black contributes nothing, so it needs no alpha
 * channel. See the ornament note in scripts/generate-art.mjs.
 */
.motes {
  position: absolute;
  /* Oversized so the drift never exposes an edge. */
  inset: -12% -6%;
  z-index: 40;
  /* Tiled at its painted size rather than stretched to `cover`: blown up to
     fill a wide panel the bokeh becomes saucers, and the specks want to stay
     specks. */
  background: url('/assets/ui-motes.png') center / 460px 460px repeat;
  mix-blend-mode: screen;
  opacity: 0.32;
  will-change: transform, opacity;
  animation: motes-drift 42s ease-in-out infinite alternate,
             motes-breathe 9s ease-in-out infinite alternate;
}
@keyframes motes-drift {
  from { transform: translate3d(-1.5%, 2%, 0) scale(1.02); }
  to   { transform: translate3d(1.5%, -2.5%, 0) scale(1.06); }
}
@keyframes motes-breathe {
  from { opacity: 0.20; }
  to   { opacity: 0.42; }
}

/* The moon breathing. A radial gradient that only ever changes opacity, sat
   over the moon in the plate -- no asset, no paint. */
.moonglow {
  position: absolute;
  left: 50%; top: 26%;
  width: 46%; aspect-ratio: 1;
  transform: translate(-50%, -50%);
  z-index: 39;
  border-radius: 50%;
  background: radial-gradient(circle,
    rgba(214, 255, 240, 0.30) 0%, rgba(150, 226, 210, 0.14) 42%, transparent 70%);
  mix-blend-mode: screen;
  pointer-events: none;
  will-change: opacity;
  animation: moon-breathe 11s ease-in-out infinite alternate;
}
@keyframes moon-breathe { from { opacity: 0.45; } to { opacity: 1; } }

/* Both are decoration and both stop dead under calm mode and reduced motion. */
html.calm .motes, html.calm .moonglow { animation: none; }
@media (prefers-reduced-motion: reduce) {
  .motes, .moonglow { animation: none; }
}

/* The art has to lose an argument with the words on top of it. A vignette plus
   a bottom-weighted wash sits over every plate: the moon is the brightest thing
   in the picture and the roster grows up into exactly that part of the frame. */
/* A gentle vignette, and nothing more. This used to darken the lower third
   hard, because the roster sat on top of it; with the roster moved out to its
   own column there is nothing to make legible and the plate can just be seen. */
/* The vignette, over whichever plates are showing. The plate used to be drawn
   here too; it moved onto .hero-plate when the rotation gained a cross-fade. */
.lobby-hero-art::after {
  content: "";
  position: absolute; inset: 0;
  z-index: 50;
  pointer-events: none;
  background:
    radial-gradient(ellipse 96% 80% at 50% 42%, transparent 0%, rgba(6, 12, 18, 0.34) 100%);
}

/*
 * The parallax scene and its sprite lived here. Both are gone: the lobby art is
 * one still plate now, and the craft is in the chrome around it. The plates and
 * the sheet are still in web/assets and still listed by /api/art -- nothing
 * reads them. See buildLobbyScene() in app.js.
 */

/* The heavy bottom scrim is gone with the text it existed for. Nothing sits on
   the art any more, so darkening two thirds of the picture would be hiding it
   for no reason. What is left is a whisper at the very edges, to keep the
   plate from butting hard against the panel border. */
.lobby-hero::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(180deg,
    rgba(10,16,26,0.22) 0%,
    rgba(10,16,26,0.00) 22%,
    rgba(10,16,26,0.00) 78%,
    rgba(10,16,26,0.30) 100%);
}
/* The text column carries its own scrim rather than relying on the art's
   gradient: the roster grows upward as players join, and at six seats it would
   otherwise reach the brightest part of the image. */
.lobby-hero-body {
  position: relative; z-index: 1;
  margin-top: auto;
  /* Scaled from the bottom edge, because that edge is pinned to the panel and
     the art above it is what should give way. */
  transform-origin: bottom center;
  padding: 26px 24px 24px;
  display: flex; flex-direction: column; gap: 12px;
  min-height: 0;
  /*
   * A short fade into solid ground, in PIXELS rather than percentages: the
   * panel's height changes with the number of players and with the window, so
   * a gradient keyed to a percentage lands somewhere different every time. A
   * 40px transition and then an opaque surface reads the same at every height
   * -- and opaque is the point. This panel carries the one thing the lobby
   * exists to tell you, which is who is at the table; it should not be
   * competing with a spider for legibility.
   */
  background: linear-gradient(180deg,
    rgba(4, 12, 18, 0) 0,
    rgba(6, 15, 22, 0.97) 40px,
    #060f16 96px,
    #060f16 100%);
}
/*
 * Seats were a 7% white wash on a near-black ground, which is barely a surface
 * at all -- the row read as dim text floating on the scrim. They get a real
 * one, with an edge, so a name is as easy to read here as anywhere else.
 */
.lobby-hero-body .seat {
  background: linear-gradient(180deg, #14212c, #101a24);
  border-color: rgba(255, 255, 255, 0.14);
}
.lobby-hero h1 {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(30px, calc(3.4 * var(--vw, 1vw)), 44px);
  font-weight: 600;
  letter-spacing: 3px; line-height: 1.05;
  text-transform: uppercase;
  /* Struck metal: a bright top edge, a warm body, a dark foot. Three stops on a
     gradient does what an image would, at no download and no paint cost. */
  background: linear-gradient(178deg, #fff3d2 0%, var(--gold-lit) 42%, #b07d2a 100%);
  -webkit-background-clip: text; background-clip: text;
  color: transparent;
  filter: drop-shadow(0 2px 14px rgba(0, 0, 0, 0.9));
}
/* An engraved rule under the title, with a lozenge at its centre. Pure
   gradients: two hairlines that fade out at both ends, and a rotated square. */
.lobby-hero h1 + .rule { margin: 12px 0 2px; }
.rule {
  position: relative;
  height: 1px;
  background: linear-gradient(90deg,
    transparent, rgba(217, 164, 65, 0.55) 18%, rgba(217, 164, 65, 0.55) 82%, transparent);
}
.rule::after {
  content: "";
  position: absolute; left: 50%; top: 50%;
  width: 7px; height: 7px;
  transform: translate(-50%, -50%) rotate(45deg);
  background: linear-gradient(135deg, var(--gold-lit), #8a6220);
  box-shadow: 0 0 0 3px #0d141d;
}
/* Not in the display face. Cinzel is inscriptional capitals -- it has no true
   lowercase, so a sentence set in it reads as small caps and slows to a crawl.
   Titles only; running text stays in the interface face, warmed up. */
.lobby-hero .tagline {
  margin: 0;
  font-size: 13px; line-height: 1.7;
  color: var(--text);
  max-width: 40ch;
}

/* Live table roster -- the thing you actually watch while waiting. */
/* No scrollbar. Six seats is the most this game has, and a roster you have to
   scroll to see who is at the table is worse than a roster drawn slightly
   smaller -- so the panel scales its contents to fit instead (see fitHero). */
/* The tables that exist, under the field they fill in. Rows rather than a
   select: a table has three things worth reading before you choose it -- who
   is there, how many chairs are left, and what is being played -- and an
   option element can only carry one line of them. */
.tables { display: flex; flex-direction: column; gap: 3px; margin-top: -2px; }
.tables-label {
  font-size: 10px; font-weight: 700;
  letter-spacing: 0.8px; text-transform: uppercase;
  color: #8a99ab;
  margin-bottom: 3px;
}
.table-row {
  display: flex; align-items: baseline; gap: 9px;
  width: 100%;
  padding: 6px 9px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--line);
  border-radius: var(--r-sm, 5px);
  color: inherit; font: inherit; text-align: left;
  cursor: pointer;
  transition: background var(--t-fast, 120ms) var(--ease, ease),
              border-color var(--t-fast, 120ms) var(--ease, ease);
}
.table-row:hover:not(:disabled) {
  background: rgba(91, 172, 212, 0.14);
  border-color: rgba(91, 172, 212, 0.5);
}
.table-row:focus-visible {
  outline: 2px solid rgba(91, 172, 212, 0.8);
  outline-offset: 1px;
}
/* Shown but not clickable. Knowing a table is there and why you cannot sit at
   it is the answer; hiding it puts you back to guessing a name. */
.table-row.is-shut { opacity: 0.5; cursor: default; }
.table-id { font-size: 12.5px; font-weight: 700; }
.table-who { font-size: 11px; color: #9fb0c2; }
.table-state {
  margin-left: auto;
  font-size: 10px; letter-spacing: 0.4px; text-transform: uppercase;
  color: #8a99ab;
}

/* ------------------------------------------------------------ pile tray
   Reading a pile, with the board still lit above it.

   Docked rather than centred, and only as tall as it needs to be, because the
   question you opened it to answer is nearly always a comparison against the
   board -- and the modal this replaces covered exactly that. No backdrop and
   no blur for the same reason: nothing is waiting on this, so it must not
   behave like something that is. */
.pile-tray {
  position: fixed;
  left: 50%; bottom: calc(1.4 * var(--vh, 1vh));
  transform: translateX(-50%);
  z-index: 90;                     /* under the real overlays, over the board */
  width: min(1180px, calc(94 * var(--vw, 1vw)));
  max-height: calc(46 * var(--vh, 1vh));
  display: flex; flex-direction: column;
  background:
    radial-gradient(120% 90% at 0% 0%, rgba(232, 162, 76, 0.10), transparent 55%),
    linear-gradient(180deg, #182434, #0d1622);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  box-shadow:
    0 18px 50px rgba(0, 0, 0, 0.6),
    inset 0 1px 0 rgba(255, 255, 255, 0.08);
  animation: pile-in var(--t-med) var(--ease);
}
.pile-tray[hidden] { display: none; }
@keyframes pile-in {
  from { opacity: 0; transform: translateX(-50%) translateY(22px); }
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .pile-tray { animation: fade var(--t-fast) var(--ease); }
}
.pile-head {
  display: flex; align-items: baseline; gap: 10px;
  padding: 11px 16px 9px;
  background: linear-gradient(90deg,
      rgba(232, 162, 76, 0.55),
      rgba(146, 205, 232, 0.20) 38%,
      transparent 80%)
    bottom left / 100% 1px no-repeat;
}
.pile-title {
  font-family: var(--display);
  font-size: 17px; letter-spacing: 0.5px;
  color: var(--ember-lit);
}
/* How deep the pile is, as a struck counter rather than a word. */
.pile-n {
  min-width: 20px;
  padding: 1px 7px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.3);
  font-size: 11px; font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--dim);
}
.pile-n:empty { display: none; }
.pile-hint {
  margin-left: auto;
  font-size: 10.5px; color: var(--faint);
}
.pile-close {
  padding: 0 8px;
  background: none; border: 0; box-shadow: none;
  color: var(--faint);
  font-size: 20px; line-height: 1;
  cursor: pointer;
}
.pile-close:hover:not(:disabled) {
  background: none; border: 0;
  color: var(--text);
}
/* The cards, in one row you scroll rather than a grid you scan. A pile HAS an
   order and the row keeps it -- top of the graveyard at the right-hand end,
   where the tray opens scrolled to. */
.pile-cards {
  display: flex; gap: 10px;
  padding: 14px 16px 16px;
  overflow-x: auto; overflow-y: hidden;
  scroll-behavior: smooth;
}
.pile-cards .card {
  flex: 0 0 auto;
  width: 132px;
  transition: transform var(--t-fast) var(--ease),
              box-shadow var(--t-fast) var(--ease);
}
/* Lifts to be read. A pile is the one place you genuinely need to look at a
   card's text, and raising it under the cursor answers that without costing a
   click on a separate inspector. */
.pile-cards .card:hover,
.pile-cards .card:focus-visible {
  transform: translateY(-8px) scale(1.06);
  box-shadow: 0 14px 30px rgba(0, 0, 0, 0.6);
  z-index: 2;
}
.pile-empty { color: var(--faint); font-style: italic; font-size: 12.5px; padding: 8px 2px; }
/* What you can DO with it, written on the card rather than hidden behind a
   right-click. 57 cards activate from a graveyard and every one of them used
   to depend on the player already suspecting the gesture existed. */
.card-action {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 3px 5px;
  background: linear-gradient(180deg, rgba(232, 162, 76, 0.86), rgba(184, 118, 40, 0.94));
  color: #201203;
  font-size: 9.5px; font-weight: 800;
  letter-spacing: 0.4px; text-transform: uppercase;
  text-align: center;
  border-radius: 0 0 4px 4px;
  pointer-events: none;
}
.card.has-action { cursor: pointer; }
/* The chip that opened the tray stays pressed, so it is obvious which pile you
   are looking at and which chip closes it again. */
.zone.open {
  border-color: rgba(232, 162, 76, 0.7);
  box-shadow: 0 0 0 1px rgba(232, 162, 76, 0.45), 0 0 14px rgba(232, 162, 76, 0.35);
}

/* The chairs at a table, before you have committed to one. Drawn as a row of
   places rather than a count, so an empty table reads as an invitation and a
   nearly-full one reads as urgency -- neither of which a number does. */
.seatmap {
  display: flex; flex-direction: column; gap: 6px;
  padding: 9px 10px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--line);
  border-radius: var(--r-sm, 5px);
}
.seatmap-head {
  font-size: 10px; font-weight: 700;
  letter-spacing: 0.8px; text-transform: uppercase;
  color: #8a99ab;
}
.chairs { display: flex; flex-wrap: wrap; gap: 5px; }
.chair {
  display: flex; align-items: center; gap: 5px;
  min-width: 62px;
  padding: 5px 9px;
  border-radius: var(--r-sm, 5px);
  font-size: 11.5px;
}
.chair.taken {
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
  border: 1px solid rgba(217, 164, 65, 0.4);
  color: #e4edf5;
}
.chair.taken.is-gone { opacity: 0.55; }
/* An empty chair is drawn as an absence -- dashed and unfilled -- so the row
   reads at a glance as "two here, four to come". */
.chair.empty {
  border: 1px dashed rgba(255, 255, 255, 0.22);
  min-height: 26px;
}
.chair-who { font-weight: 600; }
.chair-tag {
  font-size: 9px; letter-spacing: 0.4px; text-transform: uppercase;
  color: #94a3b5;
}
.seatmap-note { font-size: 11px; color: #93a2b4; font-style: italic; }
.btn-sit {
  align-self: flex-start;
  padding: 5px 14px;
  background: linear-gradient(180deg, rgba(91, 172, 212, 0.28), rgba(91, 172, 212, 0.12));
  border: 1px solid rgba(91, 172, 212, 0.55);
  border-radius: var(--r-sm, 5px);
  color: #e4edf5; font: inherit; font-size: 12px; font-weight: 600;
  cursor: pointer;
}
.btn-sit:hover { background: rgba(91, 172, 212, 0.4); }
.btn-sit:focus-visible { outline: 2px solid rgba(91, 172, 212, 0.85); outline-offset: 1px; }

/* Who is on the SERVER. Lighter than the roster on purpose: these are people
   you could sit down with, not people you are sitting with, and the two lists
   are stacked in one column where equal weight would make them read as one
   list with an odd heading in the middle. */
.presence {
  display: flex; flex-direction: column; gap: 2px;
  flex: 0 0 auto;
  margin-bottom: 14px;
}
.person {
  display: flex; align-items: baseline; gap: 7px;
  padding: 3px 4px;
  font-size: 12px;
  border-radius: var(--r-sm, 4px);
}
.person.you .person-name { color: var(--gold-lit, #e6c877); }
.person-dot {
  flex: 0 0 auto;
  width: 6px; height: 6px;
  border-radius: 50%;
  background: #46525f;
  transform: translateY(-1px);
}
.person-dot.on {
  background: #57c98a;
  box-shadow: 0 0 5px rgba(87, 201, 138, 0.7);
}
.person-name { font-weight: 600; color: #d6e3ee; }
/* Where they are, pushed to the right so the names form one readable column
   however long the table name is. */
.person-where {
  margin-left: auto;
  font-size: 10.5px; color: #8a99ab;
  white-space: nowrap;
}
.online-count {
  margin-left: 6px;
  font-size: 11px; font-weight: 600;
  color: #8a99ab;
}
.online-count:not(:empty)::before { content: '('; }
.online-count:not(:empty)::after { content: ')'; }

.roster { display: flex; flex-direction: column; gap: 6px; flex: 0 0 auto; }
.roster-empty { color: #d6e3ee; font-size: 13px; font-style: italic; }
.seat {
  display: flex; align-items: center; gap: 11px;
  padding: 8px 11px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.025));
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
  animation: log-in var(--t-med) var(--ease);
}
.seat.you {
  border-color: rgba(91, 172, 212, 0.55);
  background: linear-gradient(180deg, rgba(91, 172, 212, 0.18), rgba(91, 172, 212, 0.05));
}
/* The avatars are cloisonné medallions, so they get a bezel: a gold ring and a
   dark seat, the way a medal sits in its setting. */
.seat-face {
  position: relative;                  /* the presence jewel hangs off this */
  width: 32px; height: 32px; flex: 0 0 auto;
  border-radius: 50%;
  background: #1b2433 center / cover;
  border: 1px solid rgba(217, 164, 65, 0.45);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), inset 0 1px 2px rgba(0, 0, 0, 0.5);
}
/* Presence, on the medallion. A seat you can see somebody in and a seat being
   held for somebody who dropped are completely different situations at a
   table, and they used to be drawn identically -- the server sent `connected`
   from the start and nothing read it. Placed on the bezel like a status jewel
   rather than as another line of text, so it reads without being read. */
.seat-dot {
  position: absolute; right: -1px; bottom: -1px;
  width: 9px; height: 9px;
  border-radius: 50%;
  background: #46525f;                 /* dark: nobody is on the other end */
  border: 1.5px solid #121821;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.seat-dot.on {
  background: #57c98a;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5), 0 0 6px rgba(87, 201, 138, 0.75);
}
/* The whole seat dims, so a dropped player reads from across the room and not
   only from the jewel. Not hidden: the chair is still theirs. */
.seat.is-gone { opacity: 0.55; }
.seat.is-gone .seat-name::after {
  content: ' — offline';
  font-weight: 400; font-size: 10.5px; color: #93a2b4;
}
.seat-name { font-size: 13px; font-weight: 600; letter-spacing: 0.2px; }
.seat-tag { font-size: 10.5px; color: #9aa8ba; }
.seat-skill { display: flex; align-items: center; gap: 2px; margin-top: 2px; }
/* The bot's deck sits under its difficulty and reads the same way. Capped so a
   long archetype name cannot stretch the roster column. */
.seat-deck { display: flex; align-items: center; gap: 2px; margin-top: 1px; }
.seat-deck .skill-select { max-width: 15ch; }
/* Reads as part of the caption line until you go near it, so the roster stays a
   roster rather than a row of form controls. */
.skill-select {
  font: inherit; font-size: 10.5px; color: var(--dim);
  background: transparent; border: 1px solid transparent; border-radius: 4px;
  padding: 0 2px; cursor: pointer;
}
.skill-select:hover { border-color: var(--line); background: rgba(255, 255, 255, 0.04); }
.skill-select:focus-visible { outline: 2px solid var(--gold); outline-offset: 1px; }
.skill-select option { background: var(--panel-solid); color: var(--text); }
.seat-state { margin-left: auto; font-size: 11px; font-weight: 600; }
.seat-state.ready { color: var(--green-lit); }
.seat-state.waiting { color: var(--faint); }
.lobby-stats { font-size: 12px; color: var(--dim); line-height: 1.55; opacity: 1; }

/* Right column: numbered steps. */
.lobby-steps {
  display: flex; flex-direction: column;
  min-height: 0;
}
.steps {
  list-style: none; margin: 0; padding: 22px 24px 8px;
  /* No scrollbar: see fitSteps. A lobby you have to scroll hides the button
     you came for. */
  flex: 0 0 auto;
  transform-origin: top center;
  display: flex; flex-direction: column; gap: 20px;
}
/* 0.82 read as greyed-out rather than as "not your turn yet" -- and every
   step is legible text the whole time, so dimming it only cost contrast. The
   remaining difference is enough to lead the eye without hiding anything. */
.step { opacity: 0.94; transition: opacity var(--t-med) var(--ease); }
.step.active, .step.done { opacity: 1; }
.step-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.step-head h2 {
  margin: 0;
  font-family: var(--display);
  font-size: 17px; font-weight: 600;
  letter-spacing: 1.6px; text-transform: uppercase;
  /* The lightning. Section headings are the system naming a step, which is the
     same voice as every other arcane cue in the interface. */
  color: var(--arcane-lit);
  text-shadow: 0 0 18px rgba(99, 184, 228, 0.28);
}
/* The step number as a struck disc rather than a flat chip: a bright rim, a
   domed face and a dark seat. This is the ornament doing the work an image
   would otherwise be downloaded to do. */
.step-n {
  flex: 0 0 auto;
  width: 26px; height: 26px; border-radius: 50%;
  display: grid; place-items: center;
  font-family: var(--display);
  font-size: 12px; font-weight: 700;
  color: #8e9bab;
  background: radial-gradient(circle at 50% 30%, #2e3a4c 0%, #232e3d 55%, #151e29 100%);
  border: 1px solid rgba(255, 255, 255, 0.10);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10), 0 1px 2px rgba(0, 0, 0, 0.6);
}
.step.active .step-n {
  color: #04202c;
  background: radial-gradient(circle at 50% 28%, var(--flare) 0%, var(--arcane-lit) 48%, #1f5f80 100%);
  border-color: rgba(176, 235, 241, 0.75);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45),
              0 0 16px rgba(91, 172, 212, 0.45), 0 1px 2px rgba(0, 0, 0, 0.6);
}
.step.done .step-n {
  color: #06210f;
  background: radial-gradient(circle at 50% 28%, #9fe8bb 0%, var(--green-lit) 45%, #14713a 100%);
  border-color: rgba(120, 230, 165, 0.6);
  font-size: 0;
}
.step.done .step-n::after { content: "✓"; font-size: 13px; font-weight: 700; }
.step-body { padding-left: 32px; }
.step-hint { margin: 8px 0 0; font-size: 12px; line-height: 1.6; color: #9dabbc; }
.step-hint code { background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; }

/* A button that reads as a link: an aside inside a sentence, not a control
   competing with "Join table" beside it. */
.linky {
  background: none; border: none; padding: 0;
  font: inherit; color: var(--gold-lit, #e6c877);
  text-decoration: underline; text-underline-offset: 2px;
  cursor: pointer;
}
.linky:hover { color: #fff; }

/*
 * The ways in to this table.
 *
 * Each route says WHO it reaches rather than what it is -- "anyone on your
 * tailnet" is the useful fact, "100.x.y.z" is not -- and carries both a link
 * and a code, because those get sent through different things and fail in
 * different ways.
 */
.invite {
  display: flex; flex-direction: column; gap: 10px;
  margin-top: 10px; padding: 12px;
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--line, rgba(255, 255, 255, 0.14));
  border-radius: var(--r-md, 10px);
}
.invite-note { margin: 0; font-size: 12px; line-height: 1.55; color: #9dabbc; }
.invite-warn {
  margin: 0; padding: 7px 9px;
  font-size: 12px; line-height: 1.5;
  color: #ffdcdc;
  background: rgba(70, 16, 16, 0.7);
  border: 1px solid rgba(255, 120, 120, 0.5);
  border-radius: var(--r-sm, 6px);
}
.invite-row { display: flex; flex-direction: column; gap: 4px; }
.invite-who {
  font-size: 11px; font-weight: 700;
  letter-spacing: 0.08em; text-transform: uppercase;
  color: #7f8ea1;
}
/* The one that reaches outside the house is the one being looked for. */
.invite-row.kind-public .invite-who,
.invite-row.kind-tailscale .invite-who { color: var(--gold-lit, #e6c877); }
.invite-line { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.invite-label { font-size: 11px; color: #7f8ea1; min-width: 30px; }
.invite-value {
  flex: 1 1 auto; min-width: 0;
  padding: 4px 7px;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 12px;
  color: var(--ink, #e8eef5);
  background: rgba(255, 255, 255, 0.07);
  border-radius: 3px;
  overflow-x: auto;                       /* a long hostname must not widen the panel */
  white-space: nowrap;
}

.field-row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
.field { flex: 1; min-width: 130px; }
.field-action { flex: 0 0 auto; min-width: 0; }
.field-action button { white-space: nowrap; }

.fold { margin-top: 12px; }
.fold summary {
  cursor: pointer; font-size: 12px; color: var(--dim);
  padding: 6px 0; user-select: none;
}
.fold summary:hover { color: var(--gold-lit); }
.fold textarea { margin-top: 8px; }

.status { margin-top: 10px; font-size: 12px; color: var(--faint); }

/*
 * The decklist, handed over as selectable text.
 *
 * Shown only when the clipboard API is refused, which is every remote player
 * on a plain-http server. Prefixed `export-` -- nothing else in this sheet
 * uses that, checked before naming it.
 */
/*
 * The draw-order badge on a card in the look-at-the-top panel.
 *
 * Ponder puts three cards back in an order, and a number on the card is the
 * only thing that says "this one arrives second" without the player having to
 * work it out from position. `look-` is a free prefix in this sheet.
 */
.look-order {
  position: absolute;
  top: 4px; left: 4px;
  min-width: 20px; height: 20px;
  display: flex; align-items: center; justify-content: center;
  padding: 0 5px;
  font: 700 12px/1 var(--ui, inherit);
  color: #1a1206;
  background: var(--gold-lit, #e6c877);
  border-radius: 10px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
  pointer-events: none;
}

.export-wrap { margin-top: 8px; }
.export-hint { margin-bottom: 4px; font-size: 12px; color: var(--faint); }
.export-fallback {
  width: 100%; min-height: 120px;
  padding: 8px 10px;
  font: 12px/1.5 var(--mono, ui-monospace, monospace);
  color: var(--ink, #e8eef5);
  background: rgba(0, 0, 0, 0.34);
  border: 1px solid var(--gold-lit, #e6c877);
  border-radius: var(--r-sm, 6px);
  resize: vertical;
}
.status.ok { color: var(--green-lit); }
.status.err { color: var(--red-lit); }

.lobby-foot {
  display: flex; gap: 10px; align-items: center;
  /* Five buttons do not fit this column at every interface size, and the modal
     clips its overflow -- so without wrapping the last one (Preferences, the
     way to *change* the interface size) is simply cut off the edge and cannot
     be clicked. Wrapping is the only behaviour here that never hides a control. */
  flex-wrap: wrap;
  padding: 16px 24px;
  border-top: 1px solid var(--line);
  background: rgba(0, 0, 0, 0.28);
}
.lobby-foot button { flex: 0 0 auto; }
/* Two clusters rather than five loose buttons: going-forward on the left,
   giving-up on the right. Grouping them is what makes the wrap safe -- as
   loose children, the row wrapped mid-cluster and dropped "End game" onto its
   own line directly under "Start game", which is the adjacency this was
   separating them to avoid. Whole groups move instead. */
.foot-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
/* Collapses to nothing when the row wraps, so it pushes Preferences right only
   while there is actually room for it to. */
.lobby-foot .spacer { flex: 1 1 0; min-width: 0; }
button.big { padding: 13px 26px; font-size: 14.5px; }
/* Named `subtle`, not `ghost`: `.ghost` is the in-flight card element in
   fx.js and is position:absolute, which yanks any button sharing the class
   straight out of its layout. */
button.subtle { background: transparent; border-color: transparent; color: var(--faint); }
button.subtle:hover:not(:disabled) { background: rgba(255,255,255,0.06); color: var(--text); }

.legal { font-size: 10.5px; line-height: 1.65; color: var(--faint); margin: 0; }
/* Every link in the app, not just the footer: the browser's default blue was
   the one colour on screen that belonged to no palette. */
a { color: var(--gold); text-decoration-color: rgba(217, 164, 65, 0.45); }
a:hover { color: var(--gold-lit); }
.legal a { color: var(--gold); }

@media (max-width: 860px) {
  .lobby { grid-template-columns: 1fr; }
  .lobby-hero { border-right: 0; border-bottom: 1px solid var(--line); min-height: 200px; }
}

.hint { color: var(--faint); font-size: 11.5px; line-height: 1.6; }
.err { color: var(--red-lit); font-size: 12px; }
.ok { color: var(--green-lit); font-size: 12px; }

.coverage-key { display: flex; gap: 16px; font-size: 11.5px; color: var(--dim); margin-top: 10px; flex-wrap: wrap; }
.coverage-key i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }

#toast {
  position: fixed; bottom: 24px; left: 50%;
  z-index: 200; display: none;
  padding: 12px 22px;
  background: linear-gradient(180deg, #b91c1c, #991b1b);
  color: #fff; font-weight: 500;
  border-radius: var(--r-md);
  box-shadow: var(--lift-lg);
  animation: toast-in var(--t-med) var(--ease);
}
@keyframes toast-in {
  from { opacity: 0; transform: translate(-50%, 14px); }
  to { opacity: 1; transform: translate(-50%, 0); }
}

/* Hover preview -- large card read without leaving the board. */
#preview {
  position: fixed; z-index: 150;
  /* Big enough to read the rules text off, which is the entire job -- board
     cards are 62px wide and the printed text on them is decorative. Capped
     against the window height (a card is ~1.4x as tall as it is wide) so it
     never grows past what it can be shown in. */
  width: min(420px, calc(30 * var(--vw, 1vw)), calc(62 * var(--vh, 1vh)));
  min-width: 268px;
  display: none;
  pointer-events: none;
  border-radius: var(--r-md);
  overflow: hidden;
  box-shadow: var(--lift-lg);
  background: var(--panel-solid);
  border: 1px solid var(--line-strong);
  animation: fade var(--t-fast) var(--ease);
}
#preview img { width: 100%; display: block; }
#preview .txt { padding: 12px; font-size: 11.5px; white-space: pre-wrap; line-height: 1.55; }
#preview .nm { font-weight: 700; color: var(--gold-lit); margin-bottom: 4px; }

#ctxmenu {
  position: fixed; z-index: 300;
  background: linear-gradient(180deg, #1a2433, #131b28);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-md);
  padding: 6px;
  min-width: 208px;
  box-shadow: var(--lift-lg);
  animation: modal-in var(--t-fast) var(--ease);
}
#ctxmenu .item {
  padding: 8px 11px; border-radius: var(--r-sm);
  cursor: pointer; font-size: 12px;
  transition: background var(--t-fast) var(--ease);
}
#ctxmenu .item:hover { background: rgba(255, 255, 255, 0.09); }
/* A menu that offers mana shows mana: "Add" then the pip, rather than the word
   Blue. Land colour choices and the payment question both use this. */
#ctxmenu .item .pips { margin-left: 6px; }
#ctxmenu .item.subtle { color: var(--faint); }
#ctxmenu .sep { border-top: 1px solid var(--line); margin: 5px 0; }

.banner-inline {
  /* The mat is a COLUMN flex container, so flex-basis here is height, not
     width. A basis of 100% -- the reflex for "span the full width" in a row --
     makes the banner claim the whole mat, starves .mat-body to zero, and the
     mat's overflow:hidden then clips every permanent out of view. Size to the
     text and never take space from the board. */
  flex: 0 0 auto;
  /* ...and it still took ~40px of a mat with a FIXED height, which is a third
     of a card. `fitBoards` then shrank every permanent to fit the smaller
     body, so declaring an attack made the whole board jump smaller -- reported
     from play as "my lands got tiny". Far enough down and it hits the 34px
     floor, which sets `cramped`, which arms the hover-to-expand meant for a
     four-player ring. One banner, three symptoms.
     Taken out of the flow entirely: it floats over the seam above the mat,
     where there is nothing to cover. */
  position: absolute;
  left: 0; right: 0; bottom: calc(100% + 2px);
  width: max-content;
  max-width: calc(100% - 28px);
  margin-inline: auto;
  pointer-events: none;
  z-index: 8;
  background: rgba(217, 164, 65, 0.10);
  border: 1px solid rgba(217, 164, 65, 0.4);
  color: var(--gold-lit);
  padding: 9px 14px;
  border-radius: var(--r-md);
  margin: 0 14px 8px;
  font-size: 12.5px;
  animation: log-in var(--t-med) var(--ease);
}

/* Loading gate progress. */
#load-bar-wrap {
  background: rgba(0,0,0,0.5); border: 1px solid var(--line);
  border-radius: 999px; height: 9px; overflow: hidden; margin: 18px 0 12px;
}
#load-bar {
  height: 100%; width: 0;
  background: linear-gradient(90deg, var(--gold), var(--gold-lit));
  box-shadow: 0 0 14px rgba(217, 164, 65, 0.65);
  transition: width var(--t-med) var(--ease);
}

/* ------------------------------------------------------------ responsive -- */
@media (max-width: 1100px) {
  #main { grid-template-columns: 1fr 250px; }
  #banner { right: 250px; }
  :root { --card-w: 78px; }
}
@media (max-width: 820px) {
  #main { grid-template-columns: 1fr; }
  #side { display: none; }
  #banner { right: 0; }
  :root { --card-w: 68px; }
  #opponents.cols-2, #opponents.cols-3 { grid-template-columns: 1fr; }
}

/* Motion is decoration here, never information -- strip it all out on request. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  body::after { animation: none; }
  #my-hand .card { margin: 0 2px; transform: none !important; }
  #my-hand .card:hover { transform: translateY(-8px) !important; }
}

/* A checkbox and its words read as one control, so they sit on one line and
   the whole thing is the hit target. */
label.check {
  display: flex; align-items: center; gap: 9px;
  cursor: pointer; color: var(--text); font-weight: 500;
  text-transform: none; letter-spacing: 0;
}
label.check input { width: 16px; height: 16px; accent-color: var(--gold); cursor: pointer; }

/* ------------------------------------------------------------- calm mode -- */
/*
 * The same shutdown, asked for explicitly.
 *
 * `prefers-reduced-motion` is an accessibility setting most people never touch,
 * and the reason to turn this off is usually not vestibular -- it is that a
 * 2560-wide window is being asked to composite a full-screen blur, three
 * parallax plates and a sprite sheet that repaints its background every frame.
 * Those are the expensive things on the page, so this switches off exactly them
 * and nothing that carries meaning.
 */
html.calm *, html.calm *::before, html.calm *::after {
  animation-duration: 0.01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.01ms !important;
}
html.calm body::after { animation: none; }
/* Blur is the costliest thing here: it is a full-viewport read-back that some
   GPUs re-run on every composite, whether or not anything behind it moved. */
html.calm .overlay { backdrop-filter: none; background: rgba(5, 9, 15, 0.9); }
html.calm #side { backdrop-filter: none; }

/* ------------------------------------------------------- drag a card to play */
/* Picking a card up and putting it on the table is what the physical game does.
   The ghost is a real element rather than a native drag image so it can be
   styled, tilted and lit the same way the rest of the board is. */
.drag-ghost {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 200;
  margin: 0;
  pointer-events: none;
  cursor: grabbing;
  box-shadow: var(--lift-lg);
  /* No transition on transform: it follows the pointer every frame, and easing
     it would make the card lag behind the cursor. */
  transition: box-shadow var(--t-fast) var(--ease), outline-color var(--t-fast) var(--ease);
  outline: 2px solid transparent;
  transform-origin: center;
}
.drag-ghost img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Confirms the release will actually play it, so a drop is never a guess. */
.drag-ghost.over-board {
  outline-color: var(--gold);
  box-shadow: var(--lift-lg), var(--glow-gold);
}

/* The card left behind stays in place, dimmed, so the hand does not reflow
   under the cursor mid-drag. */
.card.dragging-source { opacity: 0.28; }

body.dragging-card { cursor: grabbing; }
/* Suppress hover lift and previews on everything while a card is in the air. */
body.dragging-card .card:hover { transform: none; }

/* A quiet wash over the play area while a card is held over it. */
#table.drop-live { background-color: rgba(217, 164, 65, 0.045); }

@media (prefers-reduced-motion: reduce) {
  .drag-ghost { transition: none; }
}

/* ------------------------------------------------------- declaring attackers */
/* A coloured glow is not enough on a board where several things glow. While
   picking attackers the board says, in words, which creatures are going and
   which are not even eligible. */
.combat-flag {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 2px 0;
  font: 700 8.5px/1 Inter, system-ui, sans-serif;
  letter-spacing: 0.8px;
  text-align: center;
  pointer-events: none;
}
.combat-flag.atk { background: rgba(220, 38, 38, 0.92); color: #fff; }
/* Which player this creature was sent at -- only shown when that was a choice. */
.combat-who {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 13px;
  padding: 1px 0;
  font: 600 8px/1 Inter, system-ui, sans-serif;
  text-align: center;
  color: #fff;
  background: rgba(0, 0, 0, 0.62);
  pointer-events: none;
}

/*
 * Declared: the creature steps off its line and into the seam.
 *
 * It used to lift 10px, always upward -- which is the wrong direction for the
 * opponent, whose mat sits ABOVE the middle, so their attackers stepped
 * backwards away from the fight. Each side now moves toward the seam, which is
 * what the battle-line layout was for: the contested strip is somewhere a
 * creature can actually go, so an attack is a move rather than a label.
 *
 * The engine already taps them (CR 508.1f, minus vigilance), so tapped plus
 * forward is the whole story and needs no words.
 */
.card.attacking {
  box-shadow: var(--glow-red), 0 10px 22px rgba(0, 0, 0, 0.6);
  z-index: 30;
}
/* The lift moves a card 46px OUT of .bf-main, which clips -- so a creature
   disappeared the instant you declared it as an attacker, on both sides of the
   table. Reported from play: "my creature became invisible as soon as I
   clicked it", and the same rule mirrored is why an attacking bot creature
   could not be seen or blocked.
   The clip is deliberate (a crowded seat gets denser rather than growing a
   scrollbar -- see fitBoards), so it is lifted only while something is
   actually standing forward, and put straight back afterwards. */
.bf-main:has(.card.attacking) { overflow: visible; }
/* ...but .bf-main was only the innermost clip. The seat itself clips too, so
   opening one and not the other moved the cut rather than removing it: the
   creature kept its top half and lost the rest. Measured on a live table --
   #my-bf takes 15px off a resting card, and the 46px lift puts roughly half
   of a 126px card past the edge. Reported as "the creature is cut in half".
   Gated the same way, so a seat that is not attacking still packs down
   instead of growing a scrollbar. */
#my-bf:has(.card.attacking) { overflow: visible; }
.pod:has(.card.attacking), .pod-bf:has(.card.attacking) { overflow: visible; }

/*
 * MEASURED, not yet solved -- notes for whoever picks the tiny-boards bug up.
 *
 * These three rules do change what fitBoards sees, because `overflow` decides
 * whether an element is a scroll container and therefore what `scrollHeight`
 * even means. Read off a live table, same seat, 11 cards, three attackers:
 *
 *     overflow: visible   .bf-main  scrollHeight 292  clientHeight 292
 *     overflow: hidden    .bf-main  scrollHeight 320  clientHeight 292
 *
 * So an attacking seat reports NO overflow whatever its contents, because a
 * `visible` box is not a scroll container and hands back its own client
 * height. The shrink loop is therefore switched off for exactly as long as a
 * creature is attacking -- which is the opposite of cards going tiny, and is
 * why gating these on `:not(.fitting)` was tried here and reverted: it makes
 * boards shrink MORE during combat, not less.
 *
 * Whatever makes a board go tiny, it is not this. Leaving the measurement
 * written down so the next attempt starts from it rather than from a guess.
 */

/*
 * While fitBoards is measuring, tapping and attacking are taken back off.
 *
 * Both are transforms, and a transformed card still counts toward a clipping
 * ancestor's scroll overflow. Rotating 90deg to tap is the expensive one: a
 * 96x134 card upright claims 96px of width, and the same card turned sideways
 * claims 134 -- 38px more, per attacker, appearing all at once. On a row
 * already near its width that tips scrollWidth past clientWidth, and the fit
 * loop shrinks the whole board a step at a time down to the 34px floor trying
 * to make room that rotation will always take back.
 *
 * Reported from play: the opponent's permanents turn tiny "right when they
 * declare attackers and the creature taps".
 *
 * Tapping is a STATE, not a size. It must not resize anybody's board; a tapped
 * card overhanging its neighbour is what happens on a real table anyway. That
 * argument stands on its own -- but be clear that this is a PRECAUTION and not
 * a proven fix. The reported board has never been reproduced here, and the
 * measurements are identical with this rule live and with it overridden. The
 * console warning in fitEachBoard is what will actually identify the real
 * cause when it next happens.
 */
.fitting .card.tapped,
.fitting .card.attacking { transform: none !important; }
/*
 * And the decorations that hang OFF a card.
 *
 * fitBoards decides card size by shrinking until nothing overflows its row, and
 * it reads overflow from the container's scroll size -- which counts anything
 * sticking out past the edges. The counter tray hangs 6px off the right of a
 * card by design, so the moment a permanent gained a counter the row measured
 * as overflowing and every card on the board shrank. A Saga is the clearest
 * case, because it takes a lore counter the instant it lands: play one and the
 * whole board goes small.
 *
 * They are decorations on a card, not part of how much room the board needs, so
 * they are taken off for the measurement the same way the attack lift is.
 */
.fitting .ctr-tray,
.fitting .card .badge,
.fitting .card .shield { display: none !important; }

/* Your half is below the seam, so forward is up.
   26px was a nudge -- enough to be detectable if you were looking for it, not
   enough to say "these creatures have left the line". Attacking is the most
   consequential thing a creature does in a turn and it should be obvious from
   across the room which ones are in. */
#my-bf .card.attacking { transform: translateY(-46px); }
/* Theirs is above it, so forward is down. */
#ring .card.attacking { transform: translateY(46px); }
/* A tapped attacker keeps its turn as well as its step: the two transforms have
   to compose or declaring an attack would stand the creature back up. */
/* Matching .bf-row .card.tapped exactly -- 90deg and 0.8 -- so declaring an
   attack does not quietly restyle every tapped creature. */
#my-bf .bf-row .card.attacking.tapped { transform: translateY(-46px) rotate(90deg) scale(0.8); }
#ring .bf-row .card.attacking.tapped { transform: translateY(46px) rotate(90deg) scale(0.8); }
@media (prefers-reduced-motion: reduce) {
  #my-bf .card.attacking, #ring .card.attacking { transition: none; }
}
/* Available but not yet chosen: quietly inviting, never competing with the
   declared ones. */
.card.can-attack { outline: 1px dashed rgba(217, 164, 65, 0.75); outline-offset: 2px; cursor: pointer; }
.card.can-attack:hover { outline-color: var(--gold-lit); transform: translateY(-5px); }
/* Ineligible this turn: visibly out of the running, so a card that ignores your
   click looks deliberate instead of broken. */
.card.cannot-attack {
  opacity: 0.4;
  filter: saturate(0.4);
  /* Inert rather than clickable-but-refusing: the click is swallowed in JS, and
     the cursor says so. Not `pointer-events: none` -- that would also kill the
     hover preview, and reading a card you cannot attack with is still useful. */
  cursor: default;
}
.card.cannot-attack:hover { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .card.attacking, .card.can-attack:hover { transform: none; }
}

/* A seat that has turned its hand round for the players who are out. */
.shown-chip {
  align-self: center;
  padding: 1px 6px;
  margin-right: 4px;
  border-radius: 999px;
  background: rgba(217, 164, 65, 0.18);
  border: 1px solid rgba(217, 164, 65, 0.55);
  color: var(--gold-lit);
  font: 600 9px/1.4 Inter, system-ui, sans-serif;
  letter-spacing: 0.3px;
  white-space: nowrap;
}

/* What format the table is playing, above the roster. */
.roster-note {
  font: 600 10.5px/1.4 Inter, system-ui, sans-serif;
  letter-spacing: 0.3px;
  color: var(--gold-lit);
  background: rgba(217, 164, 65, 0.10);
  border: 1px solid rgba(217, 164, 65, 0.35);
  border-radius: 999px;
  padding: 3px 10px;
  margin-bottom: 8px;
  display: inline-block;
}

/* Stop animating what nobody can see.
   The table's drifting gradient runs continuously on body::after. While a
   full-screen overlay covers it -- the lobby, the mulligan, a prompt -- it is
   invisible, but it still repaints every frame and forces anything with a
   backdrop-filter above it to re-blur. Pausing it costs nothing visually. */
body:has(.overlay.show)::after { animation: none; }

/* Same courtesy for reduced-motion users, who should never have had it. */
@media (prefers-reduced-motion: reduce) {
  body::after { animation: none; }
}

/* The match step's hint changes length when you switch between a single game
   and a real match. Without a floor the step shrinks by exactly one line and
   everything under it jumps, which looks like a layout bug rather than a
   sentence getting shorter. Two lines are always reserved. */
#match-hint { min-height: 3.2em; }

/* A deck is named after its signature cards now, which is far more useful than
   "UBR" and far longer -- long enough that sharing a row with a button cut the
   name off mid-word. The name gets the full width and the button drops below
   it: you read the list far more often than you press the button. */
.field-row-stacked { flex-wrap: wrap; }
.field-row-stacked > .field:first-child { flex: 1 1 100%; min-width: 0; }
.field-row-stacked > .field-action { flex: 0 0 auto; }

/*
 * Choosing how to cast a card.
 *
 * A card with two prices is two different plays, and a text menu makes you
 * hold both in your head at once. Showing the card once per option, with the
 * price on it, is how every player already expects to be asked -- you are
 * picking a card to play, not a line from a list.
 */
.cm-options {
  display: flex; flex-wrap: wrap; gap: 18px;
  justify-content: center;
  margin-top: 18px;
}
.cm-option {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  padding: 14px 14px 16px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: linear-gradient(180deg, rgba(33, 80, 107, 0.35), rgba(20, 50, 70, 0.25));
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease),
              box-shadow var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
}
.cm-option:hover, .cm-option:focus-visible {
  border-color: var(--arcane-lit);
  box-shadow: var(--glow-arcane);
  transform: translateY(-3px);
  outline: none;
}
/* The card itself, at a size you can actually read the art on. */
/* `contain` and the printed ratio, like every other card on the table. A fixed
   150x210 box is 0.7143 against the card's 0.7159, so `cover` was cropping
   about a third of a pixel -- invisible, but it is the one place the "never
   cropped" rule held by coincidence rather than by construction, and a change
   to either number would have started cropping in earnest with nothing to
   catch it. */
.cm-option .card { width: 150px; aspect-ratio: 63 / 88; position: relative; }
.cm-option .card img { width: 100%; height: 100%; object-fit: contain; border-radius: var(--r-sm); }

/* The price is the thing being chosen between, so it is the loudest element
   here -- larger than the label above it and in the palette's warm light,
   because paying is an action. */
.cm-cost {
  font: 600 15px/1.2 Inter, system-ui, sans-serif;
  color: var(--ember-lit);
  text-align: center;
}
.cm-what {
  font: 500 11px/1.3 Inter, system-ui, sans-serif;
  letter-spacing: 0.6px; text-transform: uppercase;
  color: var(--arcane-lit);
  text-align: center;
}

/* Spree: "choose one or more additional costs". The only chooser on the table
   where the cells are a multiple selection rather than a menu, so a chosen one
   has to LOOK chosen while the overlay stays open -- every other option here
   closes the moment it is clicked.

   Ember, because selecting a leg is committing to pay for it, and the reading
   of the palette is that ARCANE is the system telling you something while
   EMBER is you spending something. The Cast cell is the same commitment made
   final, so it wears the same colour rather than a second one. */
.cm-option.cm-on {
  border-color: var(--ember-lit);
  box-shadow: var(--glow-gold);
}
.cm-option.cm-on .cm-what { color: var(--ember-lit); }
/* The leg text, which is a whole sentence rather than a label -- so it wraps,
   and it is capped at the width of the card above it to stay a column. */
.cm-leg {
  font: 400 12px/1.35 Inter, system-ui, sans-serif;
  color: var(--ink-2, #cfe3ee);
  max-width: 150px; text-align: center;
}
.cm-go {
  align-self: center;
  font: 600 13px/1 Inter, system-ui, sans-serif;
  letter-spacing: 0.4px;
  color: var(--ember-lit);
}

/*
 * Mana pips. The interface printed costs as "{4}{R}", which is how the data
 * stores them and not how anyone reads them -- it is the single most
 * bookkeeping-looking thing on a screen that is meant to be a game.
 * Reusable anywhere a cost is shown.
 */
.pips { display: inline-flex; gap: 3px; align-items: center; vertical-align: middle; }
.pip {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 50%;
  font: 700 12px/1 Inter, system-ui, sans-serif;
  color: #1a1206;
  box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.5);
}
.pip-W { background: #fffbd5; }
.pip-U { background: #aae0fa; }
.pip-B { background: #ccc2c0; }
.pip-R { background: #f9aa8f; }
.pip-G { background: #9bd3ae; }
.pip-C, .pip-N { background: #cdc6c0; }
/* ------------------------------------------------------------- mana pool */
/*
 * What you have floating, beside the buttons rather than buried in the identity
 * line. It was "mana 2U" in six-point grey next to "5 in hand", which is close
 * enough to invisible that nobody knew tapping a land by hand did anything.
 *
 * Hidden entirely when the pool is empty. It is the state of a decision in
 * progress, not a readout, and a permanently-present empty box would be one
 * more thing on a screen that already reads as an accounting app.
 */
#mana-pool {
  display: flex; flex-direction: column; gap: 6px;
  padding: 8px 10px; margin-bottom: 8px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.09);
}
#mana-pool[hidden] { display: none; }
.mp-head { display: flex; align-items: baseline; gap: 6px; }
.mp-title {
  font: 700 10px/1 Inter, system-ui, sans-serif;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim);
}
.mp-note { font: 400 10px/1 Inter, system-ui, sans-serif; color: var(--dim); }
.mp-pips { display: flex; flex-wrap: wrap; gap: 4px; }
/* A pip whose land can still be untapped. The affordance is the point: the
   undo is the thing that makes tapping by hand safe to try. */
.mp-undoable { cursor: pointer; transition: transform 90ms ease, box-shadow 90ms ease; }
.mp-undoable:hover {
  transform: translateY(-2px);
  box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.22), 0 0 0 2px var(--ember-lit);
}
/* Restricted mana -- "spend this only to cast a creature spell". Dimmed
   because it will not pay for most of what is in your hand, and shown at all
   because it is otherwise absent from the pool entirely: tap a village, see
   nothing appear, conclude the land is broken. */
.mp-only { opacity: 0.55; border: 1px dashed rgba(0, 0, 0, 0.45); }

/* Somewhere to tap, while a spell waits for you to pay for it. Arcane rather
   than ember: this is the system pointing, not an action of yours. */
.card.can-tap-mana { outline: 2px solid var(--arcane); cursor: pointer; }

/*
 * A card you cannot play right now.
 *
 * Reported as the biggest quality-of-life gap in the client: nothing said
 * whether a card was castable, so every hand looked identical whether you
 * could afford it or not.
 *
 * TWO CHANNELS, NEITHER OF THEM HUE. Luminance (opacity) reads instantly
 * across a whole hand, and desaturation reinforces it without inventing a
 * colour meaning -- both survive any form of colour blindness. The third
 * channel is the `title`, which says WHY, on demand rather than as a badge:
 * seven cards each wearing a label is noise on a board that is already dense.
 *
 * NOT `cursor: not-allowed`, and no `pointer-events: none`. These cards stay
 * clickable on purpose. The dim is advice from a client that prices cards
 * pessimistically -- affinity, convoke and every alternative cost make the
 * printed number an over-estimate -- and a client that refused a play it had
 * merely mispriced would be worse than one that said nothing at all.
 *
 * Transitioned, because a hand that snaps between states as mana comes and
 * goes reads as flicker rather than as an answer.
 */
.card.not-now {
  opacity: 0.42;
  filter: saturate(0.55);
  transition: opacity var(--t-med, 200ms) var(--ease), filter var(--t-med, 200ms) var(--ease);
}
/* Hover restores it: you are looking at this one deliberately, and a card you
   are inspecting should be legible. Also the escape hatch when the client has
   priced something wrongly and you want to read it properly before trying. */
.card.not-now:hover,
.card.not-now:focus-visible { opacity: 0.92; filter: none; }

@media (prefers-reduced-motion: reduce) {
  .card.not-now { transition: none; }
}

/* A second line inside a context-menu item, for what the choice actually
   means. Used by the payment question, where the difference between the two
   answers is worth one short sentence. */
.item-sub {
  display: block; margin-top: 2px;
  font: 400 10px/1.3 Inter, system-ui, sans-serif; color: var(--dim);
}

/* A cost paid with something other than mana -- lands, life -- shown as the
   thing itself rather than described. */
.cost-things { display: flex; gap: 6px; align-items: center; justify-content: center; }
.cost-thing { width: 34px; height: 48px; border-radius: 3px; object-fit: cover;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.55); }
.cost-x { font: 700 13px Inter, system-ui, sans-serif; color: var(--dim); }


/* ---------------------------------------------------------- deck builder */
/* Dense by nature -- the most information-heavy screen in the product. The
   chrome stays quiet and the card art carries the warmth, rather than fighting
   the density and losing. */
#ov-builder .db-shell {
  width: min(1600px, 97vw); height: min(940px, 94vh);
  display: flex; flex-direction: column;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-md); overflow: hidden;
}
.db-bar {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 14px; border-bottom: 1px solid var(--line);
}
.db-name { font-family: var(--display); letter-spacing: 0.6px; color: var(--arcane-lit); }
.db-counts { font-size: 12px; color: var(--dim); }
.db-spacer { flex: 1; }

.db-panes { flex: 1; min-height: 0; display: grid; grid-template-columns: 1.35fr 1fr 0.75fr; }
.db-pane { min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.db-pane:last-child { border-right: 0; }

.db-search { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
#db-q { width: 100%; }
.db-toggle { font-size: 11.5px; color: var(--dim); display: flex; align-items: center; gap: 6px; }
.db-results {
  flex: 1; min-height: 0; overflow-y: auto;
  display: flex; flex-wrap: wrap; align-content: flex-start; gap: 8px; padding: 0 12px 12px;
}
.db-results .card { width: 92px; cursor: pointer; }
/* Visibly not fully playable, without being hidden: the gap is information. */
.db-partial { opacity: 0.55; filter: saturate(0.5); }
.db-more { padding: 8px 12px; font-size: 11.5px; color: var(--faint); border-top: 1px solid var(--line); }

.db-zonetabs { display: flex; gap: 6px; padding: 10px 12px; }
.db-tab {
  font: inherit; font-size: 12px; padding: 5px 10px; cursor: pointer;
  background: transparent; color: var(--dim);
  border: 1px solid var(--line); border-radius: var(--r-sm);
}
.db-tab.on { color: var(--arcane-lit); border-color: rgba(99, 184, 228, 0.45); }
.db-piles { flex: 1; min-height: 0; overflow-y: auto; padding: 0 12px 12px; }
.db-pile { margin-bottom: 14px; }
.db-pile-name {
  font-family: var(--display); font-size: 11.5px; letter-spacing: 1px;
  text-transform: uppercase; color: var(--faint); padding: 6px 0;
  border-bottom: 1px solid var(--line); margin-bottom: 4px;
}
.db-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12.5px; }
.db-qty { color: var(--faint); min-width: 1.6em; font-variant-numeric: tabular-nums; }
.db-cardname { flex: 1; }
.db-minus {
  font: inherit; cursor: pointer; background: transparent; color: var(--faint);
  border: 1px solid var(--line); border-radius: var(--r-sm); padding: 0 7px;
}
.db-minus:hover { color: var(--text); }

.db-analysis { padding: 10px 14px; overflow-y: auto; }
.db-analysis h3 {
  margin: 0 0 10px; font-family: var(--display); font-size: 12px;
  letter-spacing: 1.2px; text-transform: uppercase; color: var(--arcane-lit);
}
.db-auto { font-size: 22px; font-family: var(--display); color: var(--gold-lit); }
.db-issue { font-size: 12px; color: var(--dim); padding: 3px 0; }
.db-ok { font-size: 12px; color: var(--faint); }

/* Filters are clicked, not typed. */
.db-filters { display: flex; flex-direction: column; gap: 6px; }
.db-frow { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.db-fmeta { margin-top: 2px; }

.db-pipbtn {
  background: transparent; border: 1px solid var(--line);
  border-radius: 999px; padding: 3px; cursor: pointer; line-height: 0;
  opacity: 0.45; transition: opacity var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.db-pipbtn:hover { opacity: 0.8; }
/* On is unmistakable: full colour and a ring. A pip at 45% next to one at 100%
   reads at a glance, which a checkbox never does. */
.db-pipbtn.on { opacity: 1; border-color: var(--arcane-lit); box-shadow: 0 0 0 1px rgba(99,184,228,.35); }

.db-chip {
  font: inherit; font-size: 11.5px; padding: 3px 9px; cursor: pointer;
  background: transparent; color: var(--dim);
  border: 1px solid var(--line); border-radius: 999px;
}
.db-chip:hover { color: var(--text); }
.db-chip.on { color: var(--arcane-lit); border-color: rgba(99,184,228,.45); background: rgba(99,184,228,.10); }
.db-cost { min-width: 2.1em; text-align: center; font-variant-numeric: tabular-nums; }
.db-smart { color: var(--gold-lit); border-color: rgba(217,164,65,.4); }

/* The card name is the way to its printings, so it has to look pressable. */
.db-cardname {
  flex: 1; text-align: left; font: inherit; font-size: 12.5px;
  background: transparent; border: 0; color: var(--text); cursor: pointer; padding: 0;
}
.db-cardname:hover { color: var(--arcane-lit); text-decoration: underline dotted; }


/* ------------------------------------------------------ whose turn it is */
/*
 * Told by light, not by a word.
 *
 * The trick to it not looking corny is the inversion: the active half is not
 * lit, the inactive half is DIMMED. Adding light reads as an effect applied on
 * top of the picture; taking it away reads as depth that was always there. The
 * active side is therefore untouched -- a screenshot of it alone should look
 * like nothing was done to it, while the two states side by side are
 * unmistakable.
 *
 * No pulse, no bloom, no coloured wash. Fifteen percent of luminance, a little
 * saturation, and a fractional shift toward the cold end of the palette, which
 * is where the storm already lives. Only the handoff moves.
 */
/*
 * A scrim, not a filter.
 *
 * brightness(0.84) is multiplicative: it takes sixteen percent off whatever is
 * already there. With one playmat a bright starfield and the other a dark
 * forest, the same filter moved one visibly and the other hardly at all -- the
 * cue was competing with the artwork instead of sitting above it.
 *
 * A translucent black layer darkens in ABSOLUTE terms, so both halves recede by
 * the same amount whatever is painted underneath. It is also just shadow, which
 * is the least corny thing available: no glow, no wash, nothing that reads as an
 * effect rather than as the light in the room.
 */
#ring, #my-bf { position: relative; }
#ring::after, #my-bf::after {
  content: "";
  position: absolute; inset: 0;
  pointer-events: none;
  border-radius: inherit;
  background: #05080c;
  opacity: 0;
  transition: opacity var(--t-slow, 320ms) var(--ease);
  z-index: 5;
}
#table.turn-mine #ring::after,
#table.turn-theirs #my-bf::after { opacity: 0.34; }
/* A trace of desaturation on top, so the sleeping half also goes quiet in
   colour rather than only in brightness. */
#ring, #my-bf { transition: filter var(--t-slow, 320ms) var(--ease); }
#table.turn-mine #ring,
#table.turn-theirs #my-bf { filter: saturate(0.82); }
/* The seam's hairline belongs to whoever is acting: it is the only thing that
   crosses the line, so it leans toward the half that owns the turn. */
#table.turn-mine #table-center::before {
  background: linear-gradient(90deg, transparent, rgba(217, 164, 65, 0.30), transparent);
}
#table.turn-theirs #table-center::before {
  background: linear-gradient(90deg, transparent, rgba(99, 184, 228, 0.24), transparent);
}


/*
 * Your own life is the number you check constantly, so it is still the largest
 * thing in the seat furniture -- but only a little, now that the strip is back
 * in the flow.
 *
 * At 68px it was floating over the mat and cost nothing. In the flow it sets
 * the height of a band that comes straight out of .mat-body, which is what
 * fitBoards sizes your cards from -- so 68 against the opponents' 42 would make
 * your own board render smaller than theirs on a mat that is nominally the same
 * size. That is the exact complaint that got this floated out in the first
 * place, and 50 keeps the emphasis without paying for it.
 */
#my-bf .life-barrel { font-size: 13px; }
#my-bf .life-ring { width: 42px; height: 42px; }

/* ------------------------------------------------------ counters as dice */
/*
 * A die sitting on the card, the way counters are tracked in person.
 *
 * Quieter than the power/toughness badge on purpose: the badge says what the
 * creature IS and is read in combat, the die says why. Small, low-contrast,
 * bottom-left so it never crowds the badge in the opposite corner.
 */
/*
 * Off the right edge, under the mana cost.
 *
 * Every position tried inside the card was wrong in the same way: a card has no
 * spare room. The bottom ~5% is the artist and copyright line, the bottom-right
 * box is P/T or loyalty, the middle band is type line and rules text, and the
 * rest is the illustration -- so anything laid on the card covers something
 * somebody drew or wrote.
 *
 * So they do not sit on the card. They hang off its right edge, below the mana
 * cost, half on and half off. That band is the frame's own border rather than
 * art, the overhang lands in the 12px gap `.row` already leaves between cards,
 * and the counters read as something clipped TO the card rather than printed on
 * it -- which is what they are.
 *
 * A column, not a row: the edge is the long side, and a creature with three
 * kinds of counter should grow downward into empty frame rather than sideways
 * across its own artwork.
 */
.ctr-tray {
  position: absolute;
  top: 15%; right: -7px;
  left: auto; bottom: auto; transform: none;
  z-index: 3;
  display: flex; flex-direction: column; align-items: flex-end; gap: 3px;
  pointer-events: none;
}

/* Letting them out. `.card` clips to its rounded corners so the image's square
   corners do not poke through, so the clip moves onto the image itself and the
   card opens up -- only when there is a tray to let out. Same `:has()` escape
   the attacking-card transform already uses. */
.card:has(.ctr-tray) { overflow: visible; }
.card img, .card .face { border-radius: inherit; }
.pod:has(.ctr-tray), .pod-bf:has(.ctr-tray), .bf-row:has(.ctr-tray) { overflow: visible; }
/*
 * A counter, as a small badge.
 *
 * This was a rendered die sliced out of a generated sheet, which was a lovely
 * idea and did not survive its own content: `background-size` was a percentage
 * of the element's WIDTH, so a die had to stay square to look like one, and the
 * number inside it does not. Counters routinely reach two digits and can reach
 * three, and at "120" the sheet scaled up with the element until the die face
 * was a zoomed band of colour with a numeral on it.
 *
 * So the object is gone and the number is the whole badge. It reads at any
 * value, and the colour still says which KIND of counter it is -- which is the
 * only thing the die art was really carrying.
 *
 * The other half of the same question is answered elsewhere: whether a creature
 * is the size it is printed is told by the P/T badge changing colour, not by
 * counting these. See ptDrift() and .badge.pt-up / .pt-down.
 */
.die {
  min-width: 1.05em; height: 1.05em;
  padding: 0 3px;
  display: grid; place-items: center;
  font-size: 10px; font-weight: 700; line-height: 1;
  font-variant-numeric: tabular-nums;
  border-radius: 3px;
  background: rgba(6, 10, 17, 0.92);
  border: 1px solid rgba(255, 255, 255, 0.22);
  color: #dbe4ee;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
/* +1/+1 and -1/-1 are the two a player tracks by reflex, so they keep a colour.
   Anything else is a counter you read rather than glance at. */
.die-plus  { border-color: rgba(126, 231, 168, 0.8); color: #7ee7a8; }
.die-minus { border-color: rgba(255, 143, 143, 0.8); color: #ff8f8f; }

/* Scaled to the card, not pinned to a pixel size.
 *
 * This was a flat 11px on the assumption that board cards are 86px wide. They
 * are not: fitBoards sizes them to the mat, which measured 75px on an empty
 * board and shrinks further as the board fills. An 11px die on a 60px card is
 * a coloured speck with an unreadable digit on it -- present, correct, and
 * invisible, which from across the table is the same as broken.
 *
 * 16% of the card keeps three of them comfortably in the corner at any size,
 * with a floor that stops them dissolving and a ceiling that stops them
 * competing with the power/toughness badge. */
.bf-row .card .die {
  font-size: clamp(11px, calc(var(--bf-card-w, 86px) * 0.16), 19px);
}

/* ------------------------------------------------------- the opening roll */
/*
 * Who plays first, decided by an actual roll and shown as one. This is the only
 * place in the product where an animation is allowed to be showy: everything
 * else animates to explain a state change, but a die IS the state change.
 */
#ov-roll .roll-box {
  display: grid; place-items: center; gap: 16px;
  padding: 34px 46px;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
/*
 * A die, with pips.
 *
 * This drew the rolled number as a numeral on a dark rounded rectangle, and a
 * rounded rectangle with a digit in it is a BUTTON everywhere else in this
 * app -- reported, exactly, as "it doesn't look like a die, it looks like a
 * web form button." Pips are what makes a die legible as a die, and they are
 * read at a glance instead of parsed.
 *
 * Drawn rather than rendered: the client scales the whole page with
 * documentElement.style.zoom, so a bitmap die would soften at every size the
 * table is played at, while this stays sharp and takes its colours from the
 * palette.
 */
.roll-die {
  width: 74px; height: 74px;
  display: grid;
  grid-template: repeat(3, 1fr) / repeat(3, 1fr);
  padding: 9px;
  border-radius: 14px;
  background: linear-gradient(155deg, #fefcf6 0%, #efe8d8 52%, #d4cab2 100%);
  box-shadow:
    inset 0 2px 1px rgba(255, 255, 255, 0.95),
    inset 0 -5px 8px rgba(120, 100, 70, 0.28),
    0 12px 26px rgba(0, 0, 0, 0.65);
}
/* Each pip sits in its own cell of the 3x3; the face picks the cells. */
.roll-die .pip {
  place-self: center;
  width: 13px; height: 13px;
  border-radius: 50%;
  background: radial-gradient(circle at 34% 30%, #55606f 0%, #232c39 55%, #121821 100%);
  box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.3), 0 1px 0 rgba(255, 255, 255, 0.5);
  pointer-events: none;
}
/* Not yet thrown: a blank face reads as a die waiting, not as a rolled zero. */
.roll-die.unthrown {
  background: linear-gradient(155deg, #39434f 0%, #232c38 55%, #161d27 100%);
  box-shadow:
    inset 0 2px 1px rgba(255, 255, 255, 0.16),
    inset 0 -5px 8px rgba(0, 0, 0, 0.45),
    0 12px 26px rgba(0, 0, 0, 0.65);
}
/* A die per seat, side by side, the way two people roll off across a table. */
.roll-seats { display: flex; gap: 40px; align-items: end; }
.roll-seat { display: grid; place-items: center; gap: 9px; }
.roll-name { font-size: 12px; color: var(--dim); letter-spacing: 0.04em; }
/* Yours, before you have thrown it: the one thing on screen asking to be
   clicked, so nobody has to be told which die is theirs. */
.roll-die.ready {
  cursor: pointer;
  box-shadow:
    inset 0 2px 1px rgba(255, 255, 255, 0.16),
    inset 0 -5px 8px rgba(0, 0, 0, 0.45),
    0 12px 26px rgba(0, 0, 0, 0.65),
    0 0 0 2px var(--gold-lit), 0 0 22px rgba(217, 164, 65, 0.5);
  animation: die-waiting 1.9s var(--ease) infinite;
}
.roll-die.ready:hover { transform: translateY(-3px) scale(1.05); }
@keyframes die-waiting {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-5px); }
}
/* A die nobody has thrown yet reads as waiting, not as a rolled blank. */
.roll-die span { pointer-events: none; }
.roll-choice { display: flex; gap: 10px; }
.roll-die.tumbling { animation: die-tumble 900ms cubic-bezier(0.3, 0.7, 0.3, 1); }
.roll-die.settled { animation: die-land 340ms var(--ease); }
@keyframes die-tumble {
  0%   { transform: rotate(0deg)    scale(0.7); }
  25%  { transform: rotate(220deg)  scale(1.06); }
  50%  { transform: rotate(430deg)  scale(0.94); }
  75%  { transform: rotate(680deg)  scale(1.04); }
  100% { transform: rotate(900deg)  scale(1); }
}
@keyframes die-land {
  0%   { transform: scale(1.14); }
  60%  { transform: scale(0.97); }
  100% { transform: scale(1); }
}
.roll-who {
  font-family: var(--display); font-size: 15px; letter-spacing: 1.4px;
  text-transform: uppercase; color: var(--arcane-lit);
}
@media (prefers-reduced-motion: reduce) {
  .roll-die.tumbling, .roll-die.settled { animation: none; }
}

/* --------------------------------------------------------- the deck picker */
/*
 * Decks as objects, not rows. This was a <select> of 351 strings -- the most
 * evocative choice in the flow, presented like picking a timezone. Each tile
 * wears the art of the deck's most distinctive card, which is how anyone
 * recognises a deck across a real table.
 */
.deck-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  gap: 8px;
  /* The grid scrolls, the page does not. Two rows of tiles is enough to browse
     by -- nobody expects 120 decks on screen at once -- and at 260px this
     pushed the last step 93px past the panel, which clips rather than scrolls
     because .lobby is overflow:hidden. */
  max-height: 196px;
  overflow-y: auto;
  padding: 2px 2px 6px;
}
/*
 * What a deck IS, on hover.
 *
 * The tiles carry a name, two colour pips and a record, and none of that
 * answers the only question a player has in front of 120 of them: what does
 * this deck DO. Four cards answer it -- two name a colour pair, four name a
 * plan -- and the art is what people actually recognise a deck by.
 *
 * Fixed rather than absolute so it is never clipped by the picker's own
 * overflow, which is the reason the grid scrolls at all.
 */
.deck-peek {
  position: fixed;
  z-index: 120;
  width: 288px;
  padding: 10px 11px 9px;
  border: 1px solid var(--gold-lit);
  border-radius: var(--r-md);
  background: rgba(9, 14, 21, 0.97);
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.7);
  pointer-events: none;                 /* it must never eat the tile's click */
}
.deck-peek-name {
  font-family: var(--display);
  font-size: 13px;
  color: var(--gold-lit);
  letter-spacing: 0.4px;
  margin-bottom: 7px;
}
.deck-peek-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.deck-peek-card {
  position: relative;
  height: 58px;
  border-radius: 5px;
  /* Whole cards, uncropped -- the credit is printed on them. */
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  background-color: #131b26;
  overflow: hidden;
  display: flex;
  align-items: flex-end;
}
/* The name sits on the art, so a card with no art still reads. */
.deck-peek-card span {
  width: 100%;
  padding: 2px 5px 3px;
  font-size: 10.5px;
  line-height: 1.2;
  color: #eef4fb;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.92), rgba(0, 0, 0, 0));
}
.deck-peek-foot {
  margin-top: 8px;
  font-size: 10.5px;
  color: var(--faint);
  line-height: 1.45;
}

/*
 * Paging between the versions of one deck.
 *
 * An archetype is many tournament lists and they differ -- a couple of slots,
 * a different bench -- so choosing between them is a real decision. It reads
 * as one control rather than three: the arrows are part of the strip, not
 * buttons parked beside a label.
 */
.dl-versions {
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 12px 0 4px;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.03);
  width: fit-content;
}
.dl-step {
  width: 26px; height: 26px;
  padding: 0;
  display: grid; place-items: center;
  font-size: 16px; line-height: 1;
  border-radius: 50%;
  border: 1px solid transparent;
  background: none;
  color: var(--dim);
  cursor: pointer;
  transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.dl-step:hover:not(:disabled) { color: var(--flare); background: rgba(255, 255, 255, 0.06); }
/* At the end of the run, not broken -- so it fades rather than disappearing. */
.dl-step:disabled { opacity: 0.28; cursor: default; }
.dl-step:focus-visible { outline: 2px solid var(--arcane-lit); outline-offset: 1px; }
.dl-where {
  min-width: 15em;
  text-align: center;
  font-size: 11.5px;
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------- your decks */
/*
 * Slides out of the builder's bar rather than taking a fourth column: it is
 * opened to switch decks and closed again, not read while building.
 */
.db-mine {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 240px;
  overflow-y: auto;
  padding: 8px 10px;
  border-bottom: 1px solid var(--line);
  background: rgba(3, 10, 17, 0.5);
}
.db-mine-empty { font-size: 11.5px; color: var(--faint); line-height: 1.6; padding: 4px 2px; }
.db-mine-row {
  display: flex; align-items: center; gap: 8px;
  padding: 3px 6px;
  border-radius: var(--r-sm);
}
.db-mine-row:hover { background: rgba(255, 255, 255, 0.05); }
/* The one you have open. Ember, because it is a commitment rather than a
   hover state -- the same rule the deck browser's tiles follow. */
.db-mine-row.on { background: rgba(232, 162, 76, 0.12); }
.db-mine-open {
  flex: 1;
  text-align: left;
  padding: 2px 0;
  border: none; background: none;
  font: inherit; font-size: 12.5px;
  color: var(--text);
  cursor: pointer;
}
.db-mine-open:hover { color: var(--ember-lit); }
.db-mine-n {
  font-size: 11px; color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.db-mine-del {
  width: 20px; height: 20px;
  padding: 0;
  display: grid; place-items: center;
  border: none; background: none;
  color: var(--faint);
  font-size: 15px; line-height: 1;
  cursor: pointer;
  opacity: 0;
}
.db-mine-row:hover .db-mine-del { opacity: 1; }
.db-mine-del:hover { color: var(--red-lit); }

/* The full list. Written the way a decklist is written: counted, grouped,
   creatures first -- because that is the order a player reads one in. */
.dl-body { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 6px; }
.dl-section h3 {
  margin: 0 0 8px;
  font-family: var(--display);
  font-size: 13px;
  color: var(--gold-lit);
  letter-spacing: 0.6px;
  text-transform: uppercase;
}
.dl-group { margin-bottom: 12px; }
.dl-group h4 {
  margin: 0 0 4px;
  font-size: 10.5px;
  letter-spacing: 0.8px;
  text-transform: uppercase;
  color: var(--faint);
  font-weight: 600;
}
.dl-line { display: flex; gap: 8px; font-size: 12.5px; padding: 1px 0; }
.dl-n { color: var(--dim); font-variant-numeric: tabular-nums; min-width: 1.4em; }
.dl-card { color: #dbe6f2; }
.dl-line:hover .dl-card { color: var(--gold-lit); }
@media (max-width: 760px) { .dl-body { grid-template-columns: 1fr; } }

/* The way into that list, sitting on the tile it belongs to. Only on hover or
   focus: 120 of these visible at once would be louder than the decks. */
.deck-more {
  position: absolute;
  top: 4px; left: 4px;
  padding: 1px 6px;
  font-size: 10px;
  border-radius: 4px;
  border: 1px solid rgba(217, 164, 65, 0.6);
  background: rgba(9, 14, 21, 0.9);
  color: var(--gold-lit);
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--t-fast) var(--ease);
}
.deck-tile:hover .deck-more,
.deck-tile:focus-within .deck-more { opacity: 1; }
.deck-more:hover { background: rgba(30, 42, 58, 0.95); }

.deck-tile {
  position: relative;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  overflow: hidden;
  cursor: pointer;
  background: rgba(255, 255, 255, 0.03);
  transition: transform var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.deck-tile:hover { transform: translateY(-2px); border-color: rgba(99, 184, 228, 0.45); }
.deck-tile:focus-visible { outline: 2px solid var(--arcane-lit); outline-offset: 2px; }
/* Chosen is unmistakable, because it is the one piece of state on this screen
   you must not have to guess at. */
.deck-tile.chosen {
  border-color: var(--gold-lit);
  box-shadow: 0 0 0 1px var(--gold-lit), 0 6px 18px rgba(0, 0, 0, 0.5);
}
/* `contain`, not `cover`. These show the whole card now rather than its art
   crop, and the card's own credit strip is along its bottom edge -- which is
   exactly what `cover` would slice off in a band this shallow. */
.deck-face {
  height: 48px;
  background: #10161f center / contain no-repeat;
}
.deck-body { padding: 6px 8px 8px; }
.deck-name {
  font-family: var(--display);
  font-size: 12px; line-height: 1.25;
  color: var(--text);
  /* Two lines, then clipped: an archetype name is short and a runaway one must
     not change the tile's height and reflow the whole grid. */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}
.deck-meta { display: flex; align-items: center; gap: 6px; margin-top: 5px; }
.deck-record { font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
.deck-auto {
  position: absolute; top: 5px; right: 5px;
  font-size: 10px; font-weight: 700;
  padding: 1px 5px; border-radius: 999px;
  background: rgba(6, 10, 17, 0.85);
  border: 1px solid rgba(217, 164, 65, 0.5);
  color: var(--gold-lit);
}
.deck-empty { grid-column: 1 / -1; padding: 14px; color: var(--faint); font-size: 12px; }

/* ==========================================================================
 * THE DECK BROWSER
 *
 * The strip above is a quick pick, and it is 196px tall because the lobby step
 * it sits in clips rather than scrolls. This is the room you go into when you
 * have not decided yet: the decks at a size where the ART is what you read,
 * and a panel beside them that says what one DOES.
 *
 * The two lights do the work here, exactly as the language sets them out.
 * ARCANE is the system speaking, so it marks the deck you are LOOKING at --
 * a state, and one that changes as you move. EMBER is a commitment, so it
 * marks the deck you have CHOSEN. A tile never wears both, which is what
 * makes "this is the one I'm reading" and "this is the one I'm playing"
 * legible at a glance instead of being two shades of the same highlight.
 * ========================================================================== */

.ov-decks { padding: calc(3 * var(--vh, 1vh)) calc(2 * var(--vw, 1vw)); }

.deckbrowser {
  display: grid;
  grid-template-rows: auto auto 1fr;
  width: min(1280px, calc(96 * var(--vw, 1vw)));
  height: min(860px, calc(92 * var(--vh, 1vh)));
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  background: linear-gradient(180deg, #16202f, #0d1521 62%, #0b1219);
  box-shadow: var(--lift-lg);
  overflow: hidden;
  animation: modal-in var(--t-med) var(--ease);
  /* The top hairline of light that says "plate". */
  position: relative;
}
.deckbrowser::before {
  content: '';
  position: absolute; inset: 0 0 auto;
  height: 1px;
  background: linear-gradient(90deg,
    transparent, rgba(255, 201, 120, 0.55) 22%, rgba(255, 201, 120, 0.55) 78%, transparent);
}

.dkb-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 18px 22px 12px;
}
.dkb-head h2 {
  margin: 0;
  font-family: var(--display);
  font-size: 20px;
  font-weight: 600;
  letter-spacing: 1.2px;
  color: var(--ember-lit);
}
.dkb-sub { margin-top: 3px; font-size: 12px; color: var(--faint); }
.dkb-close {
  flex: none;
  width: 30px; height: 30px;
  display: grid; place-items: center;
  font-size: 20px; line-height: 1;
  border-radius: 50%;
  border: 1px solid var(--line);
  background: rgba(255, 255, 255, 0.04);
  color: var(--dim);
  cursor: pointer;
  transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.dkb-close:hover { color: var(--flare); border-color: rgba(138, 214, 247, 0.5); }

/* ---------------------------------------------------------------- filters */
.dkb-tools {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 0 22px 14px;
  border-bottom: 1px solid var(--line);
}
.dkb-search {
  flex: 1 1 240px;
  min-width: 180px;
  padding: 8px 12px;
  font: inherit;
  font-size: 13px;
  color: var(--text);
  border-radius: var(--r-sm);
  /* A well: sunk into the plate, lit along its lower lip. */
  border: 1px solid rgba(0, 0, 0, 0.5);
  background: rgba(3, 8, 14, 0.6);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.55),
              inset 0 -1px 0 rgba(146, 205, 232, 0.1);
}
.dkb-search:focus {
  outline: none;
  border-color: rgba(138, 214, 247, 0.55);
  box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.55), var(--glow-arcane);
}
.dkb-search::placeholder { color: var(--faint); }

.dkb-colors { display: flex; gap: 5px; }
/* The pips keep their card colours; being OFF is what is styled, not being on.
   A row of six greyed circles reads as disabled, so they stay legible and
   simply sit back until chosen. */
.dkb-pip {
  /* A pip is round. The sheet's `button` rule pads it to 29x22, which is an
     ellipse wearing a mana symbol -- so the box is stated outright rather
     than left to whatever padding wins. */
  width: 22px; height: 22px;
  padding: 0;
  display: grid; place-items: center;
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
  border: 1px solid transparent;
  opacity: 0.42;
  filter: saturate(0.5);
  transition: opacity var(--t-fast) var(--ease), filter var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}
.dkb-pip:hover { opacity: 0.8; filter: none; transform: translateY(-1px); }
.dkb-pip.on {
  opacity: 1;
  filter: none;
  box-shadow: 0 0 0 2px rgba(138, 214, 247, 0.75), 0 0 12px rgba(99, 184, 228, 0.45);
}
.dkb-pip:focus-visible { outline: 2px solid var(--arcane-lit); outline-offset: 2px; }

/*
 * A switch, not a checkbox.
 *
 * This sheet styles bare `label` and `input` for the lobby's forms -- uppercase,
 * letter-spaced, `width: 100%` -- which turned "Playtested only" into a shouted
 * two-line heading beside a full-width white square. Every property that rule
 * sets is reset here rather than nudged, because inheriting half of a form
 * style is how it ends up looking almost right.
 */
/*
 * "Let the bot choose", in the browser's filter bar.
 *
 * Only shown while browsing FOR a bot. It is the one answer the grid cannot
 * offer as a tile -- there is no card to put on it -- so it sits with the
 * filters rather than pretending to be a deck.
 */
.dkb-auto {
  padding: 5px 11px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line-strong);
  background: rgba(255, 255, 255, 0.04);
  color: var(--dim);
  font: inherit; font-size: 11.5px;
  cursor: pointer;
  white-space: nowrap;
}
.dkb-auto:hover { color: var(--flare); border-color: rgba(138, 214, 247, 0.5); }

/*
 * A bot's deck, as a way IN rather than a list of names.
 *
 * Reads as the current choice with an affordance, not as a form control: the
 * thing it opens is a room full of card art, and a `<select>` in front of that
 * promises the wrong thing.
 */
.seat-deck-btn {
  padding: 3px 9px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
  background: rgba(255, 255, 255, 0.05);
  color: var(--dim);
  font: inherit; font-size: 11.5px;
  cursor: pointer;
  text-align: left;
  max-width: 15em;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.seat-deck-btn:hover { color: var(--text); border-color: rgba(138, 214, 247, 0.45); }
.seat-deck-btn.wide { width: 100%; max-width: none; padding: 7px 10px; }
/* Ember once a deck has actually been chosen -- a commitment, the same rule
   the browser's own tiles follow. "Bot chooses" is an absence, not a choice. */
.seat-deck-btn.picked { color: var(--ember-lit); border-color: rgba(232, 162, 76, 0.45); }

.dkb-toggle {
  display: flex; align-items: center; gap: 8px;
  margin: 0;
  font-family: var(--body, inherit);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  color: var(--dim);
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}
.dkb-toggle input {
  appearance: none; -webkit-appearance: none;
  flex: none;
  width: 30px; height: 17px;
  margin: 0; padding: 0;
  border-radius: 999px;
  border: 1px solid var(--line-strong);
  background: rgba(3, 8, 14, 0.7);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.6);
  cursor: pointer;
  position: relative;
  transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.dkb-toggle input::after {
  content: '';
  position: absolute;
  top: 2px; left: 2px;
  width: 11px; height: 11px;
  border-radius: 50%;
  background: var(--faint);
  transition: transform var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.dkb-toggle input:checked {
  background: rgba(99, 184, 228, 0.28);
  border-color: rgba(138, 214, 247, 0.6);
}
.dkb-toggle input:checked::after {
  transform: translateX(13px);
  background: var(--arcane-lit);
}
.dkb-toggle input:focus-visible { outline: 2px solid var(--arcane-lit); outline-offset: 2px; }
.dkb-toggle:hover { color: var(--text); }
.dkb-count {
  margin-left: auto;
  font-size: 11.5px;
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------- grid + the panel */
.dkb-main {
  display: grid;
  grid-template-columns: 1fr 336px;
  min-height: 0;                 /* or the children refuse to scroll */
}
.dkb-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(166px, 1fr));
  grid-auto-rows: min-content;
  gap: 12px;
  padding: 16px 18px 20px;
  overflow-y: auto;
  min-height: 0;
  align-content: start;
}

/* A tile in here is a CARD on a table, not a row in a list. Tall enough that
   the whole card reads at a glance -- which is the entire reason to have gone
   into a bigger room. */
.dkb-tile { background: rgba(255, 255, 255, 0.025); }
/* Only the height and the ground change. The art itself is set inline by the
   client, and `background-image` here would be a rule that looks like it does
   something and never wins. */
.dkb-tile .deck-face {
  height: 176px;
  background-color: transparent;
  position: relative;
}
.dkb-tile .deck-face::after {
  content: '';
  position: absolute; inset: 0;
  background: radial-gradient(ellipse at 50% 62%,
    rgba(99, 184, 228, 0.16), rgba(5, 16, 26, 0) 68%);
  pointer-events: none;
}
.dkb-tile .deck-name { font-size: 13px; }
.dkb-tile .deck-body { padding: 8px 10px 10px; }

/* Looking at it: arcane, because that is the system telling you where you are.
   It must not be mistaken for having chosen it. */
.dkb-tile.focused {
  border-color: rgba(138, 214, 247, 0.7);
  box-shadow: var(--glow-arcane);
  transform: translateY(-2px);
}
/* Having chosen it: ember, the commitment. Beats the arcane ring when a tile
   is both, because what you have committed to is the more important fact. */
.dkb-tile.chosen,
.dkb-tile.focused.chosen {
  border-color: var(--ember-lit);
  box-shadow: 0 0 0 1px var(--ember-lit), 0 0 22px rgba(232, 162, 76, 0.34);
}

.dkb-detail {
  border-left: 1px solid var(--line);
  background: linear-gradient(180deg, rgba(3, 10, 17, 0.55), rgba(3, 10, 17, 0.85));
  padding: 18px 18px 20px;
  overflow-y: auto;
  min-height: 0;
}
.dkb-empty { color: var(--faint); font-size: 12.5px; line-height: 1.7; padding-top: 8px; }

/* The face card at the size it was painted for. */
/* The glow is a shadow, not a layer underneath.
   The art is an inline `background-image`, so a gradient behind it would have
   to be a pseudo-element at a negative z-index -- which paints behind the
   PANEL's own background and is simply never seen. */
/* Big enough to read the card, small enough that the curve and the button
   below it are on screen without scrolling. 268px was neither. */
.dkb-art {
  height: 180px;
  border-radius: var(--r-md);
  background: center / contain no-repeat;
  box-shadow: 0 0 54px rgba(99, 184, 228, 0.16);
}
.dkb-name {
  margin: 14px 0 0;
  font-family: var(--display);
  font-size: 17px;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 0.5px;
  color: var(--ember-lit);
  text-wrap: balance;
}
.dkb-line {
  display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
  margin-top: 7px;
}
.dkb-record, .dkb-place {
  font-size: 11.5px; color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.dkb-place::before { content: '· '; }

.dkb-lists {
  margin-top: 8px;
  padding: 6px 9px;
  border-left: 2px solid rgba(138, 214, 247, 0.4);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  background: rgba(99, 184, 228, 0.07);
  font-size: 11px; line-height: 1.5;
  color: var(--dim);
}

.dkb-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; }
.dkb-badge {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 9px 3px 7px;
  border-radius: 999px;
  font-size: 10.5px; font-weight: 600;
  letter-spacing: 0.3px;
  border: 1px solid var(--line);
  background: rgba(255, 255, 255, 0.035);
  color: var(--dim);
  cursor: default;
}
/* Green is "ready" in this language, and a deck somebody has actually played
   end to end is exactly that. */
.dkb-badge.is-proven {
  border-color: rgba(63, 190, 134, 0.45);
  color: var(--green-lit);
  padding-left: 9px;
}
/* The number as a dial, so a partly automated deck reads as partly filled
   before the digits are read at all. */
.dkb-ring {
  width: 12px; height: 12px;
  border-radius: 50%;
  background: conic-gradient(var(--arcane-lit) calc(var(--pct, 100) * 1%),
                             rgba(146, 205, 232, 0.18) 0);
  box-shadow: inset 0 0 0 2px rgba(6, 12, 20, 0.9);
  flex: none;
}

.dkb-sec { margin-top: 16px; }
.dkb-sec h4 {
  margin: 0 0 8px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1.1px;
  text-transform: uppercase;
  color: var(--faint);
}
.dkb-sigs { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.dkb-sig {
  position: relative;
  height: 62px;
  border-radius: 5px;
  background: #101823 center / contain no-repeat;
  overflow: hidden;
  display: flex; align-items: flex-end;
  cursor: default;
  transition: transform var(--t-fast) var(--ease);
}
.dkb-sig:hover { transform: translateY(-2px); }
.dkb-sig span {
  width: 100%;
  padding: 2px 5px 3px;
  font-size: 10px; line-height: 1.2;
  color: #eef4fb;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.94), rgba(0, 0, 0, 0));
}

.dkb-loading { font-size: 11.5px; color: var(--faint); }
/* Seven columns, one per bucket, sharing the width. The bars grow from the
   floor because that is the direction a curve is read in. */
.dkb-curve {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 4px;
  height: 92px;
  align-items: end;
}
.dkb-bar { display: flex; flex-direction: column; justify-content: flex-end; height: 100%; }
.dkb-bar-fill {
  display: block;
  height: calc(var(--h, 0) * 1%);
  min-height: 2px;
  border-radius: 3px 3px 0 0;
  background: linear-gradient(180deg, var(--arcane-lit), rgba(46, 91, 122, 0.75));
  position: relative;
  transition: height var(--t-med) var(--ease);
}
.dkb-bar-fill em {
  position: absolute; left: 0; right: 0; top: -13px;
  font-style: normal;
  font-size: 9.5px;
  text-align: center;
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}
.dkb-bar-n {
  margin-top: 4px;
  text-align: center;
  font-size: 9.5px;
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}

.dkb-mix { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 14px; }
.dkb-mix-cell {
  text-align: center;
  padding: 7px 4px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
  background: rgba(255, 255, 255, 0.03);
}
.dkb-mix-cell strong {
  display: block;
  font-family: var(--display);
  font-size: 16px;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.dkb-mix-cell span {
  font-size: 9.5px; letter-spacing: 0.6px; text-transform: uppercase;
  color: var(--faint);
}

/*
 * The commitment stays on screen.
 *
 * The panel scrolls -- a deck with four signature cards and a curve is taller
 * than the space beside the grid -- and the button that actually plays the
 * deck was at the bottom of that scroll. The one control the whole room exists
 * to lead to was the one thing you had to go looking for.
 */
.dkb-actions {
  position: sticky;
  bottom: 0;
  z-index: 1;
  display: flex;
  gap: 8px;
  margin: 18px -18px -20px;
  /* Tall top padding carrying a fade rather than a rule.
     A hard edge over content that is still scrolling reads as CUT -- the mix
     row sits right under this bar and looked truncated. A gradient says the
     same thing a fade always says: there is more, keep going. */
  padding: 26px 18px 14px;
  background: linear-gradient(180deg,
    rgba(6, 14, 22, 0) 0%,
    rgba(6, 14, 22, 0.86) 42%,
    var(--panel-solid) 76%);
}
/* Two rows in the one sticky bar: the commitment on top, the two ways of
   taking the deck somewhere else underneath. */
.dkb-actions { flex-direction: column; }
.dkb-actions button { flex: 1; }
.dkb-row { display: flex; gap: 8px; }
.dkb-row button { flex: 1; }

/* Narrow: the panel goes under the grid rather than squeezing both. */
@media (max-width: 900px) {
  .dkb-main { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
  .dkb-detail { border-left: none; border-top: 1px solid var(--line); max-height: 46%; }
  .dkb-art { height: 190px; }
}

/* ------------------------------------------------- lobby steps, one at a time */
/*
 * Four expanded steps need 1094px in a 728px column, and the old answer was to
 * scale the whole thing down until it fit -- which is why this screen read small
 * and cramped. Nothing here happens simultaneously, so only the step you are on
 * is open. A collapsed one keeps its heading and a summary of what you chose.
 */
.step-head { cursor: pointer; display: flex; align-items: baseline; gap: 10px; }
.step-summary { margin-left: auto; font-size: 11.5px; color: var(--faint); }
.step:not(.open) > .step-body { display: none; }

/*
 * The head is the control that opens the step, so it has to look like one.
 *
 * It was a bare `cursor: pointer` -- no hover, no focus ring, and nothing on
 * screen saying a collapsed step could be opened at all. Three of the four are
 * collapsed at any moment, and going back to change the table you typed means
 * finding a target you have no reason to believe is there.
 *
 * The hit area is widened past the text with negative margins so the whole
 * width of the column responds, not just the words.
 */
.step-head {
  margin-inline: -8px;
  padding: 4px 8px;
  border-radius: var(--r-md);
  transition: background var(--t-fast) var(--ease);
}
.step-head:hover { background: rgba(255, 255, 255, 0.05); }
.step-head:focus-visible {
  outline: 2px solid var(--arcane-lit);
  outline-offset: 1px;
  background: rgba(255, 255, 255, 0.05);
}
/*
 * The caret: two borders on an empty box, rotated. Drawn rather than set in a
 * glyph because the display face has no lowercase and the interface face's
 * chevron sits off the optical centre -- and a rotated border is the same
 * shape at every interface scale.
 *
 * `align-self: center` because the row is baseline-aligned and a box with no
 * text in it baselines on its bottom edge, which would hang it below the
 * heading.
 */
.step-head::after {
  content: "";
  align-self: center;
  flex: 0 0 auto;
  width: 6px; height: 6px;
  margin-left: 10px;
  border-right: 1.5px solid var(--faint);
  border-bottom: 1.5px solid var(--faint);
  /* Pointing down when closed: "there is more of this below". The -2px lifts
     the drawn shape back onto the centre line, since rotating a square about
     its middle leaves the visible corner low. */
  transform: translateY(-2px) rotate(45deg);
  transition: transform var(--t-med) var(--ease), border-color var(--t-fast) var(--ease);
}
/* Open: the same caret turned up, so the animation reads as the panel folding
   rather than as a different icon being swapped in. */
.step.open > .step-head::after {
  transform: translateY(1px) rotate(-135deg);
  border-color: var(--arcane-lit);
}
/* A settled step folds away gold rather than grey, so the caret carries the
   same "this one is answered" colour the step number already has. */
.step:not(.open).done > .step-head::after { border-color: rgba(217, 164, 65, 0.55); }
/* Last, so it wins over both of the state colours above -- a caret that stops
   responding on the steps you have already done is the wrong half to drop. */
.step-head:hover::after { border-color: var(--text); }

/* The summary is the collapsed step's whole content, so it stops being an
   afterthought colour: it is the only thing telling you what you chose. */
.step:not(.open) > .step-head .step-summary { color: var(--dim); }
.step.done .step-n {
  background: rgba(217, 164, 65, 0.18);
  border-color: rgba(217, 164, 65, 0.55);
  color: var(--gold-lit);
}
.step:not(.open) { padding-bottom: 4px; }


/* ------------------------------------------- your seat: name at your edge */
/*
 * Flipping your half so the creatures face the seam put the battle line
 * directly under the pod header -- your life counter and name sit at the top of
 * your mat, so an attacking creature stepping 26px forward went straight behind
 * them.
 *
 * Your identity belongs at YOUR edge, which also mirrors the opponent, whose
 * header is already at their far edge with their creatures nearest the middle.
 * So the mat reads, top to bottom: battle line, support, then you.
 */
#my-bf .banner-inline { order: 1; }
#my-bf .mat-body { order: 2; }
/*
 * Your identity is a strip at the top of your mat, like everyone else's.
 *
 * It was floated out of the flow instead, pinned to a corner, and that went
 * wrong twice. First `left: 12px`, on a written assumption that "lands are
 * centred, so the bottom-left corner is free" -- but the zone rail had since
 * become the first child of .mat-body (see zoneRail's insertBefore in app.js)
 * and runs the full height of the left edge, so the life counter sat on top of
 * the exile pile. Then, clear of the rail, it simply floated in the middle of
 * an empty mat with nothing anchoring it.
 *
 * A floating element in a corner has to keep being right about what else is in
 * that corner, and it was not. In the flow it cannot collide with anything, and
 * your seat now reads the way the opponents' already do.
 *
 * The original objection was cost: fitBoards derives card size from .mat-body's
 * height, so a band at the top comes out of the board. That is real, and the
 * answer is to keep the strip the same height as the one every other seat
 * carries rather than to take it out of the flow -- see the ring size below.
 */
#my-bf { position: relative; }
#my-bf .pod-head {
  order: 0;                              /* above .mat-body, which is order 2 */
  margin: 0 0 4px;
  padding-left: 2px;
  pointer-events: auto;
}

/* ----------------------------------------------- the play area has a width */
/*
 * The mats spanned the full window -- 1599px on a 2000px screen -- while a real
 * board holds a handful of permanents at 86px each. Centring the rows stopped
 * them jamming against the left edge, but the mat itself was still a vast empty
 * field with a small cluster somewhere in it.
 *
 * Capping the seats and centring them gives the board a size that matches what
 * is on it, and hands the margins back to the table -- which is where the oak
 * finally becomes visible, rather than being covered by mats edge to edge.
 */
#ring, #me {
  width: 100%;
  max-width: 1180px;
  margin-inline: auto;
}
/* The seam belongs to the play area, not the window, or it runs out past the
   boards it separates. */
#table-center { width: min(100%, 1180px); margin-inline: auto; }

/* A spell on the stack you are allowed to counter. Same arcane glow as every
   other target, because "you may click this" should look identical wherever it
   appears. */
.stack-item.targetable {
  cursor: pointer;
  border-left-color: var(--arcane-lit);
  box-shadow: var(--glow-blue);
  animation: pulse-target 1.6s var(--ease) infinite;
}
.stack-item { cursor: pointer; }

/* Scry / surveil chooser. Each card says where it is going, because "click to
   toggle" with no label is a decision made blind -- which is the exact
   complaint that produced this screen. */
.scry-pick { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.scry-pick .card { transition: transform var(--t-med) var(--ease), opacity var(--t-med) var(--ease); }
.scry-pick.away .card { opacity: 0.45; transform: translateY(8px) scale(0.94); }
.scry-where {
  font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
  color: #8fa0b8; font-weight: 700;
}
.scry-pick.away .scry-where { color: #d98b6a; }
/*
 * Reordering the cards that stay on top. "...and the rest on top of your
 * library in any order" (CR 701.18b) is half of what scry does, and there was
 * no control for it at all -- whatever you kept went back the way it came off.
 * Arrows rather than drag: this is a two-to-three card decision made under a
 * clock, and a drop target you can miss is worse than a button you cannot.
 */
.scry-move { display: flex; gap: 4px; }
.scry-arrow {
  width: 26px; height: 22px;
  padding: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 12px; line-height: 1;
  border-radius: var(--r-sm);
  border: 1px solid rgba(150, 180, 220, 0.3);
  background: rgba(24, 34, 48, 0.9);
  color: #cfe0f5;
  cursor: pointer;
}
.scry-arrow:hover:not(:disabled) { border-color: var(--accent, #7fd7ff); color: #fff; }
.scry-arrow:disabled { opacity: 0.28; cursor: default; }
.scry-pick.keeps .scry-where { color: #8fd9a8; }
#scry-order {
  margin-top: 10px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------ brandmark -- */
/*
 * The title, on the scene rather than in the panel, and cut into it.
 *
 * It used to sit inside .lobby-hero-body above the roster, competing with the
 * one thing that column exists to show. Out here it can be the size a title
 * should be -- and it needed to stop looking like every other "premium"
 * wordmark: gold gradient, even weight, drop shadow, letters resting on top of
 * the picture. See the notes on each part below.
 */
.brandmark {
  position: absolute;
  /* var(--vw)/var(--vh), never raw vw/vh. The interface scales itself with
     documentElement.style.zoom, and raw viewport units resolve against the
     unzoomed viewport, so they drift out of step with everything around them. */
  top: clamp(18px, calc(4 * var(--vh, 1vh)), 46px);
  left: clamp(20px, calc(4 * var(--vw, 1vw)), 60px);
  z-index: 1;
  pointer-events: none;
  animation: brand-in 900ms var(--ease) both;
  animation-delay: 120ms;
}

/* "Kitchen" as an eyebrow: small, widely tracked, quiet.
   Setting both words at the same size and weight was most of why the mark read
   as a report header -- two equal lines make no statement about which word is
   the name. */
.brandmark-kitchen {
  display: block;
  font-family: var(--display);
  font-weight: 600;
  text-transform: uppercase;
  font-size: clamp(10px, calc(1.05 * var(--vw, 1vw)), 15px);
  letter-spacing: 0.62em;
  text-indent: 0.62em;          /* tracking adds a trailing gap; balance it */
  color: #c8a961;
  opacity: 0.86;
  margin-bottom: 0.35em;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.9);
}

/*
 * "TABLE", incised rather than raised.
 *
 * The old mark was a gold gradient with a drop shadow -- letters sitting on
 * top of the scene, which is the same treatment every "premium" wordmark uses
 * and reads as a spa menu. This is cut INTO the plate instead: the fill is
 * darker than the ground, a hairline highlight sits along the bottom edge
 * where light would catch a cut, and a soft warm bloom sits behind it so the
 * letterforms read against a dark backdrop without glowing like neon.
 */
.brandmark-table {
  display: block;
  font-family: var(--display);
  font-weight: 700;
  text-transform: uppercase;
  line-height: 0.86;
  font-size: clamp(38px, calc(5.6 * var(--vw, 1vw)), 84px);
  letter-spacing: 0.02em;
  /* The body of the cut: deep, warm, and darker at the top where the shadow
     of the edge falls. */
  background: linear-gradient(184deg, #4a3312 0%, #9a7433 46%, #e8cd8f 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  position: relative;
}
/* The lit edge. A second copy offset one pixel down, clipped to the glyphs,
   painting only the sliver that shows past the body above it. */
.brandmark-table::after {
  content: attr(data-word);
  position: absolute;
  left: 0; top: 1px;
  background: linear-gradient(180deg, transparent 58%, rgba(255, 236, 190, 0.85) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  z-index: -1;
}
/* The bloom behind, so an incised mark still separates from a dark plate.
   A blurred, scaled copy rather than a box-shadow, which would trace the
   element rather than the letters. */
.brandmark-table::before {
  content: attr(data-word);
  position: absolute;
  left: 0; top: 0;
  color: rgba(190, 140, 52, 0.5);
  filter: blur(18px);
  z-index: -2;
}

/* A hairline under the mark, stopping short -- an underscore for the word
   rather than a border for a box. */
.brandmark-rule {
  margin-top: 0.5em;
  height: 1px;
  width: 62%;
  background: linear-gradient(90deg,
    rgba(217, 164, 65, 0.75), rgba(217, 164, 65, 0.28) 62%, transparent);
}

@keyframes brand-in {
  from { opacity: 0; transform: translateY(-8px); }
  to   { opacity: 1; transform: none; }
}

/* Out of the way where there is no room for it. Below this the panel fills the
   window and the mark would sit on top of the controls. */
@media (max-width: 900px), (max-height: 620px) {
  .brandmark { display: none; }
}

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

/*
 * What a permanent is holding in exile -- Oblivion Ring and its family.
 *
 * Shown FACE UP underneath the card that took it, the way it sits on a real
 * table: the enchantment on top, its prisoner tucked beneath and still
 * readable. A badge would have said "something is under here"; the point is
 * being able to see WHAT, without hunting through an exile pile that also
 * holds everything else exiled this game.
 *
 * .card clips its own art, so a child hanging below would be cut off -- the
 * holder turns clipping off and the image keeps the corner radius itself.
 */
.card.holds { overflow: visible; }
.card.holds > img { border-radius: inherit; }
.held {
  position: absolute;
  left: 8%; right: -8%;
  top: 62%;
  z-index: -1;                 /* behind the holder, in front of the mat */
  display: flex; gap: 2px;
  pointer-events: none;
}
.held-card {
  flex: 1 1 0; min-width: 0;
  aspect-ratio: 63 / 88;
  border-radius: var(--r-sm);
  overflow: hidden;
  border: 1px solid rgba(190, 150, 230, 0.55);
  background: #141a24;
  box-shadow: 0 6px 14px rgba(0, 0, 0, 0.65);
  pointer-events: auto;
  cursor: help;
}
.held-card img { width: 100%; height: 100%; object-fit: cover; object-position: center 22%; display: block; }
.held-name {
  padding: 3px 4px;
  font-size: 9px; line-height: 1.15; font-weight: 700;
  color: #d7b6f0;
}

/* ------------------------------------------------- attached permanents -- */
/*
 * Auras and Equipment hang below the permanent they are attached to.
 *
 * CR 400.5 lets players arrange the battlefield however they like with one
 * exception: "what other objects are attached to them must remain clear to all
 * players". Sorting by type broke exactly that -- the Aura sat in the
 * enchantments region and its creature in the creatures region, with nothing
 * on screen connecting them.
 *
 * Shingled rather than fanned sideways, because a creature can carry several
 * and the name has to stay readable on every one. Each strip shows the art
 * band and the title; the full card is a hover away, same as everywhere else.
 */
.card.hosts { overflow: visible; }
.card.hosts > img { border-radius: inherit; }
.attached {
  position: absolute;
  left: 5%; right: 5%;
  top: 84%;
  z-index: -1;                 /* behind the host, in front of the mat */
  display: flex; flex-direction: column;
  gap: 2px;
  pointer-events: none;
}
.attached-card {
  position: relative;
  height: 17px;
  border-radius: var(--r-sm);
  overflow: hidden;
  border: 1px solid rgba(120, 190, 255, 0.5);
  background: #101722;
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.6);
  pointer-events: auto;
  cursor: pointer;
}
/* Your own attachments read warm, an opponent's cool -- a Pacifism on your
   creature is controlled by THEM and sits on YOUR mat, so the mat it is drawn
   on cannot be what tells you whose it is. */
.attached-card.mine { border-color: rgba(120, 230, 170, 0.55); }
.attached-card.tapped { filter: saturate(0.5) brightness(0.72); }
.attached-card img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; object-position: center 30%;
  display: block;
}
.attached-name {
  position: absolute; inset: 0;
  display: flex; align-items: center;
  padding: 0 4px;
  font-size: 9px; line-height: 1; font-weight: 700;
  color: #eaf2ff;
  text-shadow: 0 1px 2px #000, 0 0 6px #000;
  background: linear-gradient(90deg, rgba(6, 10, 16, 0.86) 55%, rgba(6, 10, 16, 0.15));
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.attached-card:hover { border-color: var(--accent, #7fd7ff); }

/* ------------------------------------------------------- combat keywords -- */
/*
 * Badges for the keywords that decide a combat, along the top edge of a card.
 *
 * They answer the question actually being asked while blockers are declared --
 * can this be blocked, and who dies -- which previously required reading the
 * rules text on six cards at once. Along the TOP because the bottom corners
 * already hold counter dice and the power/toughness badge, and a badge that
 * moves depending on what else is present is worse than no badge.
 *
 * Sized against the card so they survive fitBoards shrinking the board, the
 * same way the counter dice do.
 */
.kw-strip {
  position: absolute;
  top: 2px; left: 2px; right: 2px;
  z-index: 2;
  display: flex; flex-wrap: wrap; gap: 2px;
  pointer-events: none;
}
.kw {
  width: clamp(13px, calc(var(--bf-card-w, 86px) * 0.19), 22px);
  aspect-ratio: 1;
  display: grid; place-items: center;
  border-radius: 3px;
  background: rgba(8, 12, 20, 0.86);
  color: #cfe0f5;
  box-shadow: inset 0 0 0 1px rgba(190, 214, 240, 0.35);
  cursor: help; pointer-events: auto;
}

/* Colour carries the category, so the strip reads at a glance without
   deciphering every glyph: blue is evasion, amber is damage timing, red is
   lethality, green is a gain. */
.kw-flying, .kw-reach, .kw-menace { color: #9fd0ff; box-shadow: inset 0 0 0 1px rgba(120, 180, 240, 0.5); }
.kw-first-strike, .kw-double-strike { color: #ffd88a; box-shadow: inset 0 0 0 1px rgba(230, 190, 110, 0.5); }
.kw-deathtouch { color: #ff9d9d; box-shadow: inset 0 0 0 1px rgba(230, 120, 120, 0.55); }
.kw-trample { color: #ffc48a; box-shadow: inset 0 0 0 1px rgba(230, 170, 110, 0.5); }
.kw-lifelink { color: #a8e6b0; box-shadow: inset 0 0 0 1px rgba(130, 210, 150, 0.5); }
.kw-vigilance { color: #cfe0f5; }

/* Reach is the one a player most needs to spot on the DEFENDING side, so it
   gets a little more presence than the rest. */
.kw-reach { background: rgba(14, 26, 40, 0.94); }

/* The icon fills its badge. Stroke width scales down as the badge does, or a
   1.7 stroke on a 13px icon fills in solid and every keyword becomes the same
   dark blob. */
.kw svg { width: 82%; height: 82%; stroke-width: 2.1; }
