@font-face {
  font-family: "Cinzel";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../assets/fonts/cinzel-600.woff2") format("woff2");
}

@font-face {
  font-family: "Cinzel";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../assets/fonts/cinzel-700.woff2") format("woff2");
}

@font-face {
  font-family: "Spectral";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../assets/fonts/spectral-400.woff2") format("woff2");
}

@font-face {
  font-family: "Spectral";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../assets/fonts/spectral-500.woff2") format("woff2");
}

/* Card styles for Moonfall game */

/* Define card dimensions as CSS variables for consistency */
:root {
  /* One scale unit drives ALL card anatomy. Desktop: 1vmin exactly (the
     historic math); phones raise the floor inside the battle media query so
     cards stay readable/tappable when vmin collapses. */
  --card-unit: 1vmin;
  --card-width: calc(12.29 * var(--card-unit));
  --card-height: calc(22.2 * var(--card-unit));
  --catalog-card-scale: 1.05;
  --catalog-card-gap: 14px;
}

.card {
  --card-frame-scale: 1;
  /* The frame hairline is DRAWN, never laid out. As a real `border` it ate 1px
     of the padding box under the global `box-sizing: border-box`, and every
     interior of a card — mana disc, title plate, art well, description, status
     — is positioned as a FRACTION of --card-width/--card-height, so all of them
     resolved against a box inset by that border. The inset is a constant 1px at
     every card size, which is 0.4% of a 265px card and 2.5% of a 40px phone
     card: measured, the mana digit's centre sat at 0.1124 of card height when
     large and 0.12497 when small. That is the "mana cost is offset downwards
     until you enlarge it" report — one absolute error seen at two scales.
     An inset shadow paints the identical line at zero box-model cost. */
  --card-frame-ring: inset 0 0 0 calc(1px * var(--card-frame-scale)) rgba(238, 219, 241, 0.3);
  width: var(--card-width);
  /* Use CSS variable for consistency */
  height: var(--card-height);
  /* Use CSS variable for consistency */
  margin: 0 calc(2.64 * var(--card-unit));
  /* Increased horizontal margin to 20px for much wider spacing */
  border-radius: 0;
  /* Removed corner rounding */
  display: flex;
  flex-direction: column;
  position: relative;
  overflow: hidden;
  cursor: pointer;
  /* Update transition to include z-index */
  /* Apply transform/shadow instantly, delay z-index drop on hover-out */
  transition:
    transform 0.2s ease,
    box-shadow 0.2s ease,
    z-index 0s ease 0.2s;
  box-shadow:
    0 0 calc(8px * var(--card-frame-scale)) rgba(238, 219, 241, 0.5),
    var(--card-frame-ring);
  -webkit-user-select: none;
  user-select: none;
}

/* Removed generic hover style as it conflicts with specialized hover behavior in player hand */
/* Only apply hover shadow effect without transformation */
.card:hover {
  /* box-shadow REPLACES; the ring has to be re-stated or the outline vanishes
     for as long as the pointer is on the card. */
  box-shadow:
    0 0 calc(12px * var(--card-frame-scale)) rgba(238, 219, 241, 0.8),
    var(--card-frame-ring);
}

.card.draggable {
  cursor: grab;
  transform-origin: center center;
  /* Prevent native browser drag ghost — CardDrag handles clone via pointer events */
  -webkit-user-drag: none;
  -webkit-user-select: none;
  user-select: none;
}

.card.draggable:hover {
  --card-hover-transform: translateY(-5px) scale(1.05);
  --card-base-transform: scale(1);
  /* Override the large scale effect when hovering over draggable cards */
  /* This ensures cards don't get too large when preparing to drag */
  z-index: 10;
  transform-origin: center center;
}

.card.draggable:active {
  cursor: grabbing;
  /* Return to normal size when actively grabbing */
  --card-hover-transform: scale(1);
  --card-base-transform: scale(1);
  transform-origin: center center;
}

/* Force normal size during dragging operations */
.card.dragging {
  transform: scale(1) !important;
  transform-origin: center center !important;
  z-index: 200 !important;
  /* Keep on top during drag */
  /* Blue glow for card under drag */
  box-shadow:
    0 0 14px rgba(0, 128, 255, 0.9),
    inset 0 0 0 1px rgba(0, 128, 255, 0.6) !important;
  /* Do not intercept pointer events so targets underneath can receive dragover/drop */
  pointer-events: none !important;
}

/* --- Unified pointer drag clone --- */
.card-drag-clone {
  border-radius: inherit;
  cursor: grabbing;
  backface-visibility: hidden;
  /* Kill inherited .card transition — position is set via left/top per-frame,
     tilt/scale via transform. No CSS interpolation allowed. */
  transition: none !important;
}

/* Suppress hover during pointer drag */
body.card-dragging #player-hand .card {
  transform: none !important;
  transition: none !important;
  z-index: 1 !important;
}

body.card-dragging .card.hover-active,
body.card-dragging .card:not(.dragging):hover {
  transform: none !important;
  transition: none !important;
}

body.card-dragging #hover-overlay {
  pointer-events: none !important;
  visibility: hidden !important;
  z-index: 0 !important;
}

body.card-dragging .card.dragging {
  z-index: 200 !important;
}

body.card-dragging #opponent-hand .card {
  z-index: 50 !important;
}

/* Suppress ALL visual changes while a drop is settling.
   renderBoard() recreates the entire board DOM under the cursor, which
   can fire mouseenter → zoom/tilt and CSS transitions on fresh cards. */
body.drop-settling .card {
  transition: none !important;
  animation: none !important;
  zoom: 1 !important;
  --card-hover-transform: scale(1) !important;
}

/* --- Mulligan phase: grey out hand cards, disable interaction --- */
.mulligan-phase #player-hand .card {
  filter: grayscale(0.7) brightness(0.6);
  pointer-events: none;
}

/* HIGHLIGHT RINGS ARE DRAWN, NEVER LAID OUT.
   These four states each added a real `border: 1px`, and under the global
   `box-sizing: border-box` that eats 1px off the PADDING box — the box every
   interior of a card resolves its percentages against. The mana disc, title
   plate, art well, description and status are all positioned as fractions of
   --card-width/--card-height, so a playable card drew its numeral 1px away
   from where an unplayable one drew it. Measured in game: the numeral sat at
   20.52px on a card whose gem centre is 19.5px, a full pixel of drift that
   appears and disappears as a card becomes playable.
   That is the same trap `--card-frame-ring` was created to solve for the base
   frame; an inset shadow paints an identical line at zero box-model cost. */
/* --- Highlight states (priority: red > orange > blue > white > green) --- */
.card.legal-play {
  /* Green glow for playable cards */
  box-shadow:
    0 0 8px rgba(0, 255, 0, 0.7),
    inset 0 0 0 1px rgba(0, 255, 0, 0.4);
}

/* Orange glow for all potential targets during a drag operation */
.potential-target {
  box-shadow:
    0 0 12px rgba(255, 165, 0, 0.8),
    inset 0 0 0 1px rgba(255, 165, 0, 0.5) !important;
}

/* Red glow for the currently hovered/active target */
.valid-target {
  box-shadow:
    0 0 14px rgba(255, 0, 0, 0.9),
    inset 0 0 0 1px rgba(255, 0, 0, 0.6) !important;
}

/* Status text overlay (always visible; repositions on enlarge) */
.card-status {
  opacity: 1;
  transition: opacity 0.2s ease-in-out;
  position: absolute;
  /* THE CARD'S OWN BOTTOM EDGE. Do not move this without being asked.

     It was briefly changed to `27.5% + 0.25 * --card-unit`, anchoring the plate
     to the top of the rules panel so it grew upward over the art instead of
     over the description. That was reasoning from a WRONG DIAGNOSIS of the
     owner's summon-flicker report, it was never requested, and it regressed
     something that had already been signed off: the plate has no upper bound,
     so with enough statuses it climbed into the art and covered 62% of the MANA
     COST and 14% of the name. The owner had confirmed mana-cost placement fixed
     before that change and reported the regression on sight.

     The real cause of the flicker was never this plate — it is the summon
     readiness gate in game-board.css, measured on the owner's iPhone: the whole
     card sits at `opacity: 0` while its art settles, so the description goes
     with it. Fixing the actual mechanism removes any reason to move the plate.


     If the plate ever does need to cover less of the description, bound its
     HEIGHT — it must never reach the mana disc or the name. */
  bottom: calc(0.25 * var(--card-unit));
  /* CEILING, from the merged remediation work. The plate's contents are a
     storyteller string split on `;` with no cap, so its height is model output.
     At the card's own foot it grows UPWARD, and unbounded it climbed into the
     description, then the mana cost, then the name — measured at eight statuses
     standing above the card's top edge. The anchor stays where the owner signed
     it off; this is the bound the note above asked for. */
  max-height: calc(1.64 * var(--card-unit));

  left: calc(0.25 * var(--card-unit));
  right: calc(0.25 * var(--card-unit));
  /* BIGGER TYPE, REAL PADDING, A REAL PLATE.
     Reported: "status on cards is too small, and lacks padding, can use some
     help from slightly better styling." It was 0.79 units of type squeezed
     into 0.13 units of vertical padding on a flat 60% black bar — thin enough
     to read as a debug overlay rather than part of the card.
     Type up ~14%, vertical padding more than doubled, and the plate now has
     the same vocabulary as every other chip in the game: a slightly deeper,
     gradient-lit ground, a hairline top highlight and a drop shadow that lifts
     it off the art. Every value stays in `--card-unit`, so it scales with the
     card exactly as before and cannot desync between surfaces. */
  background:
    linear-gradient(180deg, rgba(24, 16, 38, 0.82), rgba(10, 7, 18, 0.88));
  color: #f6efff;
  font-size: calc(0.9 * var(--card-unit));
  font-weight: 600;
  letter-spacing: 0.01em;
  line-height: 1.15;
  padding: calc(0.3 * var(--card-unit)) calc(0.42 * var(--card-unit));
  text-align: center;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
  border-radius: calc(0.42 * var(--card-unit));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.09),
    0 calc(0.15 * var(--card-unit)) calc(0.4 * var(--card-unit)) rgba(0, 0, 0, 0.5);
  z-index: 4;
  pointer-events: none;
  /* Allow clicks to pass through */
  display: flex;
  flex-direction: row;
  /* CENTRED, AND IT HAS TO BE SAID IN ONE WORD HERE.
     Reported: "caRD status fields on desktop uncentered."

     The plate is full-bleed — `left`/`right` inset it 0.25 units from the
     card's own edges — while its chips shrink-wrap their text. It used to be
     `flex-direction: column`, whose cross axis is horizontal and whose default
     `align-items: stretch` made every chip span the plate, so `text-align:
     center` below centred each line. Holding the resting plate to ONE line
     (8fa5abfd) turned it into a `row`, which moved the horizontal axis to
     `justify-content` — and its default `flex-start` packs every chip against
     the plate's left edge inside a visibly wider box.

     `text-align: center` cannot reach this: it centres inline content INSIDE a
     chip, and each chip has already shrink-wrapped its own width. Exactly the
     defect, and exactly the fix, the summoner rail landed one file over — see
     "THE REPORTED DEFECT" in status-indicator.css. */
  justify-content: center;
  gap: calc(0.304 * var(--card-unit));
  white-space: normal;
  overflow: hidden;
}

/* `.card >` KEEPS THE PLATE'S PADDING ABOVE SCOPED SINGLE-CLASS RESETS.
   Foundry ships `.foundry-surface-root * { padding: 0 }` and foundry.css loads
   AFTER cards.css, so at equal (0,1,0) specificity the reset won and the same
   card's status plate rendered with no padding inside the Foundry: measured
   1.92px/2.688px against 0, which made the plate 3.8px shorter and every
   status span 5.4px wider at 1400x640, in Chromium, Firefox and WebKit alike.

   This predates the plate's bound — it arrived with "BIGGER TYPE, REAL
   PADDING, A REAL PLATE" — and no test could see it, because the scoped-reset
   parity probe rendered `status: ''` and the renderer only builds this element
   when a status exists. The probe now carries a real status, so the plate is
   inside that sweep.

   Same idiom, same reason, as `.card > .card-image` further down.

   THE FONT IS PINNED HERE FOR THE SAME REASON. `.card-name`, `.card-cost` and
   `.card-desc` each declare `font-family: "Spectral", serif`; the status plate
   is the one card text element that declares none, so it inherits Arial from
   `body` — except inside the Foundry, whose reset carries
   `font-family: inherit` and hands it the surface's Spectral. WebKit reported
   the residue after the padding was fixed: 6.609375px against 6.593125px of
   span height, one 64th of a pixel, from a different typeface.

   Arial, not Spectral, because that is what the plate renders as on every
   surface today and matching the siblings is a VISUAL decision, not a bug fix.
   Measured, Spectral at this `line-height: 1.15` is safe if it is ever wanted:
   identical plate height (27.47-27.56px against Arial's 27.52-27.56px at
   1400x640) with ink fully contained in Chromium, Firefox and WebKit — so it
   does not repeat the `.card-cost` line-box trap. */
.card > .card-status {
  padding: calc(0.3 * var(--card-unit)) calc(0.42 * var(--card-unit));
  margin: 0;
  font-family: Arial, sans-serif;
}

/* `.status-icon` WAS HERE AND IS DELETED, NOT FIXED.
   It was an opaque `rgba(0,0,0,0.7)` plate at `z-index: 4`, anchored
   `bottom: 0.39 * --card-unit` — 0.39 units above the card's bottom edge,
   which is inside `.card-desc`'s 72.5%-93.5% band at `z-index: 3`. That is
   byte for byte the defect the status plate above was just fixed for, sitting
   in the same file, waiting for someone to wire it up.

   It had zero references: no `public/scripts`, no `public/dist`, no HTML, no
   test, no other stylesheet. So there was nothing to fix — the choice was
   between keeping a dead trap and removing it, and a rule that cannot be
   observed cannot be regression-tested either. Anything that needs a status
   chip on a card uses `.card-status`, which is bounded on both sides and has
   a contract test. tests/unit/styles/cardOverlayBands.test.js fails if a
   bottom-anchored overlay is reintroduced into the description band. */

/* Optional subtle change on hover */
.card:hover .card-status {
  opacity: 1;
}

/* The side panel lives outside the card box, which normally clips it. */
#card-hover-preview,
#card-hover-preview .card {
  overflow: visible;
}

/* When enlarged, let inner elements protrude outside the original box */
.card.hover-active {
  overflow: visible;
  --card-base-transform: scale(1);
}

/* Reveal & reposition when enlarged (hover-active) */
.card.hover-active .card-status {
  opacity: 1;

  /* Move outside card to the right */
  position: absolute;
  /* Reset default bottom/right so they do not conflict */
  bottom: auto;
  right: auto;
  left: calc(100% + calc(1.248 * var(--card-unit)));
  /* gap from card edge */
  top: 0;

  /* Flexible sizing */
  width: auto;
  max-width: calc(31.12 * var(--card-unit));
  min-width: calc(13.216 * var(--card-unit));
  white-space: normal;
  /* allow wrapping */
  overflow-wrap: break-word;
  overflow: visible;
  text-align: left;

  /* Typography & style */
  font-size: calc(0.768 * var(--card-unit));
  line-height: 1;
  padding: calc(0.624 * var(--card-unit)) calc(1.248 * var(--card-unit));
  background: rgba(0, 0, 0, 0.75);
  border-left: calc(0.464 * var(--card-unit)) solid rgba(255, 255, 255, 0.35);
  border-radius: calc(0.304 * var(--card-unit));

  /* Elevation */
  z-index: 110;
  box-shadow: 0 calc(0.304 * var(--card-unit)) calc(1.248 * var(--card-unit)) rgba(0, 0, 0, 0.55);
}

/* Reposition status banner when a board card is hovered (enlarged by scale) */
#player-board .card:not(.dragging):not(:active):hover .card-status,
#opponent-board .card:not(.dragging):not(:active):hover .card-status {
  opacity: 1;
  position: absolute;
  bottom: auto;
  right: auto;
  left: calc(100% + calc(1.248 * var(--card-unit)));
  top: 0;
  transform: none;

  width: auto;
  max-width: calc(31.12 * var(--card-unit));
  min-width: calc(13.216 * var(--card-unit));
  white-space: normal;
  overflow-wrap: break-word;
  overflow: visible;
  text-align: left;
  font-size: calc(0.768 * var(--card-unit));
  line-height: 1;
  padding: calc(0.624 * var(--card-unit)) calc(1.248 * var(--card-unit));
  background: rgba(0, 0, 0, 0.75);
  border-left: calc(0.464 * var(--card-unit)) solid rgba(255, 255, 255, 0.35);
  border-radius: calc(0.304 * var(--card-unit));
  z-index: 110;
  box-shadow: 0 calc(0.304 * var(--card-unit)) calc(1.248 * var(--card-unit)) rgba(0, 0, 0, 0.55);
}

#player-board .card:not(.dragging):not(:active):hover .card:hover::after,
#opponent-board .card:not(.dragging):not(:active):hover .card:hover::after {
  content: attr(data-tooltip);
  position: absolute;
  top: 50%;
  left: calc(100% + calc(1.6 * var(--card-unit)));
  transform: translateY(-50%);
  background: rgba(0, 0, 0, 0.9);
  color: #fff;
  padding: calc(1.19 * var(--card-unit)) calc(1.98 * var(--card-unit));
  border-radius: calc(0.8 * var(--card-unit));
  font-size: calc(2.38 * var(--card-unit));
  white-space: normal;
  z-index: 1000;
  width: max-content;
  max-width: calc(46.672 * var(--card-unit));
  min-width: calc(19.84 * var(--card-unit));
  pointer-events: none;
  text-align: left;
  border-left: calc(0.704 * var(--card-unit)) solid rgba(255, 255, 255, 0.35);
  border-radius: calc(0.448 * var(--card-unit));
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  box-shadow: 0 calc(0.448 * var(--card-unit)) calc(1.872 * var(--card-unit)) rgba(0, 0, 0, 0.55);
}

/* Hover Hint overlay (hidden by default, separate from status) */
.card-hint {
  opacity: 0;
  transition: opacity 0.15s ease-in-out;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  text-align: center;
  background: none !important;
  box-shadow: none !important;
  /* Modern cinematic shadow: soft depth + subtle glow */
  text-shadow:
    0 calc(0.2 * var(--card-unit)) calc(0.4 * var(--card-unit)) rgba(0, 0, 0, 0.9),
    0 0 calc(1 * var(--card-unit)) rgba(0, 0, 0, 0.6),
    0 0 calc(2 * var(--card-unit)) rgba(138, 43, 226, 0.3);
  color: #fff;
  font-size: calc(1.28 * var(--card-unit));
  line-height: 1.1;
  letter-spacing: 0.05em;
  /* Slightly wider spacing for elegance */
  border-radius: 2px;
  z-index: 5;
  /* raised on visible */
  pointer-events: none;
  white-space: normal;
  overflow: hidden;
}

/* The in-card .card-hint element is now a silent state holder; the visible
   hint is rendered to the side of the card via #card-side-hint. Keep the
   element in the DOM (callers and tests look it up) but never display it. */
.card-hint.visible-hint {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

/* Floating off-card hint. Lives at <body> level so it escapes the card's
   overflow:hidden and never gets clipped by adjacent cards in the hand.
   Big and attention-grabbing on purpose — new players struggle to discover
   how to use cards, so the call-to-action has to be impossible to miss. */
#card-side-hint {
  position: fixed;
  /* Parked off-screen until JS positions it beside a card. Defense-in-depth:
     if anything makes the hint visible before positionSideHint() runs (or
     clears its inline left/top), the default must NOT be the visible top-left
     corner — otherwise it flashes at (0,0). Off-screen-left is invisible. */
  left: -9999px;
  top: 0;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  /* Must beat the enlarged-card z-index (9500 in game-board.css) so the hint
     can never get covered by a zoomed-up board card. */
  z-index: 10000;
  max-width: calc(34 * var(--card-unit));
  padding: calc(2.2 * var(--card-unit)) calc(2.8 * var(--card-unit));
  text-align: center;
  white-space: normal;
  overflow-wrap: break-word;
  font-family: "Cinzel", serif;
  text-transform: uppercase;
  font-weight: 900;
  letter-spacing: 0.14em;
  font-size: calc(3.6 * var(--card-unit));
  line-height: 1.05;
  color: #fff8e0;
  background:
    radial-gradient(
      120% 160% at 50% 0%,
      rgba(82, 38, 140, 0.92) 0%,
      rgba(28, 10, 58, 0.96) 60%,
      rgba(14, 4, 36, 0.98) 100%
    );
  border: calc(0.32 * var(--card-unit)) solid rgba(255, 215, 130, 0.85);
  border-radius: calc(1 * var(--card-unit));
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  text-shadow:
    0 0 calc(0.5 * var(--card-unit)) rgba(0, 0, 0, 1),
    0 calc(0.35 * var(--card-unit)) calc(0.7 * var(--card-unit)) rgba(0, 0, 0, 0.95),
    0 0 calc(1.8 * var(--card-unit)) rgba(255, 215, 130, 0.95),
    0 0 calc(3.6 * var(--card-unit)) rgba(168, 85, 247, 0.75);
  box-shadow:
    0 calc(1.2 * var(--card-unit)) calc(3.6 * var(--card-unit)) rgba(0, 0, 0, 0.75),
    0 0 calc(2.4 * var(--card-unit)) rgba(255, 215, 130, 0.55),
    0 0 calc(5 * var(--card-unit)) rgba(138, 43, 226, 0.6),
    inset 0 0 calc(1.4 * var(--card-unit)) rgba(255, 215, 130, 0.25);
  /* Off-state: nudged downward for the entrance lift */
  transform: translateY(calc(0.8 * var(--card-unit))) scale(0.92);
  transition:
    opacity 0.18s ease-out,
    transform 0.22s cubic-bezier(0.16, 1.1, 0.3, 1.4),
    visibility 0s linear 0.18s;
}

#card-side-hint.visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0) scale(1);
  transition:
    opacity 0.18s ease-out,
    transform 0.22s cubic-bezier(0.16, 1.1, 0.3, 1.4),
    visibility 0s linear 0s;
  animation: side-hint-pulse 1.6s ease-in-out 0.22s infinite;
}

/* Continuous attention pulse — kicks in after the entrance settles so the
   bounce-in doesn't fight the breathing scale. */
@keyframes side-hint-pulse {
  0%, 100% {
    box-shadow:
      0 calc(1.2 * var(--card-unit)) calc(3.6 * var(--card-unit)) rgba(0, 0, 0, 0.75),
      0 0 calc(2.4 * var(--card-unit)) rgba(255, 215, 130, 0.55),
      0 0 calc(5 * var(--card-unit)) rgba(138, 43, 226, 0.6),
      inset 0 0 calc(1.4 * var(--card-unit)) rgba(255, 215, 130, 0.25);
    filter: brightness(1);
  }
  50% {
    box-shadow:
      0 calc(1.2 * var(--card-unit)) calc(3.6 * var(--card-unit)) rgba(0, 0, 0, 0.8),
      0 0 calc(4 * var(--card-unit)) rgba(255, 215, 130, 0.95),
      0 0 calc(7.5 * var(--card-unit)) rgba(168, 85, 247, 0.9),
      inset 0 0 calc(1.8 * var(--card-unit)) rgba(255, 215, 130, 0.4);
    filter: brightness(1.12);
  }
}

/* Directional arrow pointing at the card. The arrow sits on the side of the
   hint that faces the card. */
#card-side-hint::before {
  content: '';
  position: absolute;
  top: 50%;
  width: 0;
  height: 0;
  border-top: calc(1.8 * var(--card-unit)) solid transparent;
  border-bottom: calc(1.8 * var(--card-unit)) solid transparent;
  margin-top: -1.8vmin;
  filter: drop-shadow(0 0 calc(0.8 * var(--card-unit)) rgba(255, 215, 130, 0.85));
}

#card-side-hint.side-hint-right::before {
  /* Hint on right of card → arrow on hint's left edge pointing left. */
  left: -1.6vmin;
  border-right: calc(1.8 * var(--card-unit)) solid rgba(255, 215, 130, 0.92);
}

#card-side-hint.side-hint-left::before {
  /* Hint on left of card → arrow on hint's right edge pointing right. */
  right: -1.6vmin;
  border-left: calc(1.8 * var(--card-unit)) solid rgba(255, 215, 130, 0.92);
}

/* Touch inspection is an explicit first-tap latch; acting is a second tap or a
   labelled action control. Keep the screen-reader instruction and discovery
   cue separate from the card's visual layout. */
.touch-preview-instructions {
  position: fixed;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

#touch-preview-hint {
  position: fixed;
  left: 50%;
  top: max(8px, env(safe-area-inset-top));
  z-index: 2147483646;
  min-height: 36px;
  max-width: min(240px, calc(100vw - 16px));
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 8px 12px;
  border: 1px solid rgba(255, 220, 143, 0.9);
  border-radius: 999px;
  color: #fff8e0;
  background: rgba(28, 10, 58, 0.96);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55), 0 0 12px rgba(168, 85, 247, 0.45);
  font: 700 clamp(12px, 3.5vmin, 16px)/1.15 "Cinzel", serif;
  letter-spacing: 0.04em;
  text-align: center;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transform: translateY(4px) scale(0.96);
  transition: opacity 120ms ease-out, transform 120ms ease-out, visibility 0s linear 120ms;
}

#touch-preview-hint.visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0) scale(1);
  transition-delay: 0s;
}

@media (prefers-reduced-motion: reduce) {
  #touch-preview-hint,
  #touch-preview-hint.visible {
    transform: none;
    transition: opacity 80ms linear, visibility 0s;
  }
}

/* The ACT control on an enlarged card. It looks like the hint on purpose — same
   family, same placement logic — but it is a real button: it takes pointer
   events, carries a full tap target, and says what it will do before the player
   commits. Inspecting a card and acting on it are different gestures, so they
   get different affordances. */
#touch-preview-action {
  position: fixed;
  left: 50%;
  top: max(8px, env(safe-area-inset-top));
  z-index: 2147483647;
  min-height: var(--mf-touch, 44px);
  min-width: 128px;
  max-width: min(280px, calc(100vw - 16px));
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 8px 18px;
  border: 1px solid rgba(255, 220, 143, 0.95);
  border-radius: 999px;
  color: #1a0b2e;
  background: linear-gradient(180deg, #ffe9a8, #f6c96a);
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.6), 0 0 16px rgba(246, 201, 106, 0.5);
  font: 700 clamp(13px, 3.6vmin, 17px)/1.15 "Cinzel", serif;
  letter-spacing: 0.04em;
  text-align: center;
  cursor: pointer;
  /* `manipulation` here was a DEAD declaration. touch-action composes by
     RESTRICTION down the ancestor chain, and touch-policy.css puts a panning
     value on html/body (`pan-x pan-y` then, `manipulation` now) — so the
     composed value still panned, and Android Chrome treated every press on
     this control as a candidate pan. A few pixels of thumb drift then
     reclassified the press as a scroll and no `click` was generated at all:
     "that add to deck button is not working reliably". `none` is more
     restrictive than the root's value, so it is the one value a descendant
     CAN still apply. This is a 44px fixed button that nothing scrolls, so
     removing panning (and the card pinch) from it costs nothing. */
  touch-action: none;
  -webkit-appearance: none;
  appearance: none;
}

/* Hidden, but still MEASURABLE. The placement solver reads the control's own
   offsetWidth/offsetHeight to choose a seat that clears the enlarged card and
   stays inside the safe area; `display: none` made both zero, so every
   placement was computed against the 150x36 fallback guess for a box that is
   really ~131-178 x 44. Visibility keeps the box real while keeping the
   control invisible, out of the accessibility tree, and untappable. */
/* Deliberately `display: none`, NOT `visibility: hidden`.
   Keeping a measurable box while hidden would let the placement solver read
   the control's real size instead of the 150x36 fallback it currently guesses
   for a box that is really ~131-178 x 44 — but it regressed Firefox: with the
   control retaining a box, the SECOND press of the control in one latch cycle
   stopped acting (A/B-proven on real-page-box-firefox, green with
   `display: none` and red with `visibility: hidden`, everything else held).
   The imprecision is not worth an unexplained regression; it is recorded in
   docs/mobile-page-box.md instead. */
#touch-preview-action[hidden] {
  display: none;
}

/* THE HIT REGION IS A RECTANGLE, even though the control is a pill.
   A rounded box hit-tests along its radius, so the four 22px corner squares of
   this control's bounding box fell through to whatever was painted beneath —
   measured on the real deck builder at a 915x336 page box: 92 of 1452 sampled
   points, 6.3% of the control's own box. Beneath it is a `.deck-card-item`,
   and every one of those is a TouchPreview latch target, so a corner landing
   enlarged the card under the button instead of pressing it. A mouse pointer
   is one pixel and effectively never lands there; a fingertip is a 10-25px
   contact patch, which is why this read as a phone-only defect.
   The transparent rectangle also buys 8px of miss margin, which a thumb needs
   and a mouse does not. It inherits `pointer-events` from the control, so it
   is inert whenever the control is hidden.
   Contract: the "every pixel of the control belongs to the control" test in
   tests/e2e-playwright/real/mobile-catalog-touch.real.spec.js. */
#touch-preview-action::after {
  content: '';
  position: absolute;
  inset: -8px;
  border-radius: 0;
}

#touch-preview-action:active {
  transform: translateY(1px);
}

@media (max-width: 720px) {
  #card-side-hint {
    font-size: calc(4.4 * var(--card-unit));
    max-width: calc(44 * var(--card-unit));
    padding: calc(2.6 * var(--card-unit)) calc(3 * var(--card-unit));
  }
}

@media (prefers-reduced-motion: reduce) {
  #card-side-hint {
    transition: opacity 0.16s linear, visibility 0s linear 0.16s;
    transform: none !important;
    animation: none !important;
  }
  #card-side-hint.visible {
    transition: opacity 0.16s linear, visibility 0s linear 0s;
    animation: none !important;
  }
}

/* Additional status line styling */
.card-status span {
  font-weight: bold;
}

/* Beyond the first few, statuses are held back on the resting card and stated
   as a count. See `.card.hover-active .card-status` for the full ledger. */
.card-status .card-status-overflow {
  display: none;
}

.card-status .card-status-more {
  opacity: 0.82;
}

/* Card status indicators */
.card.summoned-this-turn {
  opacity: 0.7;
}

/* SCALED BY THE CARD, AND BOUNDED OFF THE RULES BAND.

   `font-size: 0.6rem` was ABSOLUTE on an element whose every other dimension
   derives from `--card-unit`. On desktop the card is wide enough that this
   sentence sits on one line and the bar is a thin cap. Measured on the owner's
   iPhone at the real page box, the board card is 34px wide, so the same string
   at a fixed 9.6px wrapped to ~52px on a 61.3px card: a 70%-black bar anchored
   at `top: 0` ran all the way down and covered 64% of the rules band.

   That is one of the five mechanisms behind "the card description vanishes"
   — the only one that is PERSISTENT rather than momentary, which is why it also
   made the band look wrong between summons.

   Same defect class as the description's own 2.07px type: an absolute unit
   inside a component sized in `--card-unit`. The cap is the second guard, so a
   longer translation cannot reintroduce it in a language nobody tested;
   `.card-desc` starts at 72.5% of card height and the veil must stay well clear.
   Contract: tests/unit/styles/cardVeilScales.test.js. */
.card.summoned-this-turn::after {
  content: "Can't use this turn";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  background-color: rgba(0, 0, 0, 0.7);
  color: #fff;
  font-size: calc(0.82 * var(--card-unit));
  line-height: 1.08;
  max-height: calc(2.6 * var(--card-unit));
  overflow: hidden;
  padding: calc(0.16 * var(--card-unit));
  text-align: center;
}

.card.used-this-turn {
  opacity: 0.7;
}

/* Same scaling and the same bound as the summon veil above — see its note. */
.card.used-this-turn::after {
  content: "Already used";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  background-color: rgba(0, 0, 0, 0.7);
  color: #fff;
  font-size: calc(0.82 * var(--card-unit));
  line-height: 1.08;
  max-height: calc(2.6 * var(--card-unit));
  overflow: hidden;
  padding: calc(0.16 * var(--card-unit));
  text-align: center;
}

/* Expanded card status banner for enlarged cards.
   `#card-hover-preview .card` is the POPUP enlargement — the only way a phone
   enlarges a card (tap-to-inspect builds a popup rather than growing the card
   in place). It never carried `hover-active`, so on mobile the status stayed a
   strip across the card's own bottom edge and covered the description the
   player had opened the card to read. Desktop had the side panel all along. */
.card.hover-active .card-status,
#card-hover-preview .card .card-status,
#player-board .card:not(.dragging):not(:active):hover .card-status,
#opponent-board .card:not(.dragging):not(:active):hover .card-status {
  top: 0;
  bottom: auto;
  left: calc(100% + 6px);
  transform: none;

  width: auto;
  max-width: 200px;
  min-width: 85px;
  height: auto;
  /* The resting plate's ceiling is a fraction of the CARD, because that is the
     box it shares with the cost and the name. This panel sits beside the card
     and overlaps nothing, so the same fraction would only truncate it. */
  max-height: none;

  overflow: visible;

  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 4px;
}

/* The enlarged panel shows the WHOLE ledger. The resting card shows the first
   few and a `+N` chip (cards.js) because it is 37px wide on a phone; enlarging
   is how a player reads the rest, so hiding anything here would make the
   overflow unreachable. */
.card.hover-active .card-status .card-status-overflow,
#card-hover-preview .card .card-status .card-status-overflow,
#player-board .card:not(.dragging):not(:active):hover .card-status .card-status-overflow,
#opponent-board .card:not(.dragging):not(:active):hover .card-status .card-status-overflow {
  display: block;
}

.card.hover-active .card-status .card-status-more,
#card-hover-preview .card .card-status .card-status-more,
#player-board .card:not(.dragging):not(:active):hover .card-status .card-status-more,
#opponent-board .card:not(.dragging):not(:active):hover .card-status .card-status-more {
  display: none;
}

/* Card front and back */
.card-back {
  background-image: var(--cardback-image, url("../assets/images/cardback.webp"));
  background-size: cover;
  background-position: center;
  background-color: rgba(0, 0, 0, 0.5);
}

.card-front {
  background-size: cover;
  background-position: center;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  color: #060f19;
  background-color: rgba(0, 0, 0, 0.5);
  position: relative;
  box-sizing: border-box;
  isolation: isolate;
}

.card-front::before {
  /* default fallback frame */
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  /* ?v= is a CACHE KEY, not decoration. These frames are served with
     max-age=604800 and Cloudflare caches them at the edge, so re-encoding one
     reaches nobody for a week: after ca0cc7cc both frames were correct in the
     container and STALE at the edge — players were still being served the old
     lossy creature frame and the wrong-hued spell gem. Bump v whenever a frame
     file changes. Verified: the bare URL returned cf-cache-status HIT with the
     old byte count, the ?v= URL returned MISS with the new one. */
  background-image: url("../assets/images/cardfront_creature.webp?v=2");
  background-size: cover;
  background-position: center;
  z-index: 2;
}

/* Specific frames per card type */
.card-front.spell-card::before {
  background-image: url("../assets/images/cardfront_spell.webp?v=2");
}

.card-front.character-card::before {
  background-image: url("../assets/images/cardfront_creature.webp?v=2");
}

/* Stellar frame variants by card type */
.card-front.stellar-card.character-card::before {
  background-image: url("../assets/images/cardfront_creature_stellar.webp");
}

.card-front.stellar-card.spell-card::before {
  background-image: url("../assets/images/cardfront_spell_stellar.webp");
}

/* Card-specific font overrides */
.card-name {
  font-family: "Spectral", serif;
  font-weight: bold;
  text-transform: uppercase;
  /* Same reason as `.card-desc` below: an absolute px tracking cannot scale
     with a card whose every other dimension does. 0.047em is 0.15px at the
     measured base (3.1956px fitted title on a 79x142 card). This one also
     stops the tracking varying with TITLE LENGTH — `fitCardTitle` picks a
     smaller font for a longer name, and a fixed px then gave that name
     relatively wider letter-spacing than a short one on the identical card. */
  letter-spacing: 0.047em;
}

.card-cost {
  font-family: "Spectral", serif;
  font-weight: bold;
}

.card-desc {
  font-family: "Spectral", serif;
  font-weight: 500;
  /* SCALES WITH THE CARD, like every other card dimension.
     `fitCardDescription` expresses its whole size ladder in `--card-unit`, so a
     card enlarged by multiplying that unit reproduces its own text exactly —
     except for tracking, which was pinned at an absolute 0.1px. The larger the
     card, the tighter the tracking became RELATIVE to the glyphs, so wrap
     points moved: the mulligan's inspected card reflowed from 4 lines to 3,
     which `mulligan-hover-composition.real.spec.js` reads as "inspection is not
     one continuously visible physical card". Whether it tipped was per-engine
     luck — Chromium flipped a line where Firefox did not.
     0.0208em is 0.1px at the measured base (4.8px fitted description on a
     79x142 card at `--card-unit: 1vmin`, vmin 640), so the resting card is
     unchanged and only the enlarged one stops drifting. */
  letter-spacing: 0.0208em;
}

/* Card elements */
/* REVERTED TO THE VERSION THAT WORKED, on the owner's instruction: "figure out
   how the code used to be because you are the one who broke the centering".

   `febdf3ff` (2026-08-06) replaced a TOP-anchored box with a CENTRE-anchored
   one and added `line-height: 1`, to fix a downward bias reported on mobile. It
   fixed that and introduced two: a desktop offset on hovered and unhovered
   cards, and a leftward bias on enlarged mobile cards. Every subsequent attempt
   to re-derive the anchor made it worse, because the numbers were derived from
   the frame ASSET (736x1312) while the numeral is positioned against the card
   BOX, which the frame is painted into at a different aspect — the two spaces
   are not interchangeable and no amount of measuring one fixes the other.

   The `line-height: 1` was the more expensive half. Spectral's own text box is
   ascent 1.06 + descent 0.46 = 1.52em, so a line-height of 1 guarantees the
   numeral's ink overflows its element. `cardTextHasPaintGeometry` rejects any
   card whose ink escapes its box, `settleCardFaceForPresentation` then reports
   `textReady: false`, and `startMatchedSummon` refuses to fly an unsettled
   card — so EVERY enemy summon silently lost its glide and the card popped onto
   the board fully formed. Restoring the font's own `normal` line height (~1.52)
   clears that gate as a side effect of being correct.

   Locked by tests/e2e-playwright/mana-gem-in-game.spec.js, which measures the
   real board rather than the marketing renderer the old spec used. */
.card-cost {
  position: absolute;
  /* ANCHORED BY ITS CENTRE, vertically as well as horizontally, and that is
     the whole fix for "on mobile the small cards have the mana cost biased
     down".

     Anchored by its TOP, as this was, the digit's resting place is
     `top + (lineBox - (ascent + descent)) / 2 + ascent`: every term after
     `top` is a FONT metric resolved in whole device pixels by the engine. So
     the digit's position carried an absolute pixel error, and an absolute
     error is a proportional error divided by the card's size — invisible on a
     233px hover preview, and the same error on a 72px phone hand card, where
     the numeral itself is only about 7px tall. Measured across the six real
     card sizes, the centre wandered between 0.1106 and 0.1151 of card height.

     Anchored by its centre the font metrics cancel: half-leading is split
     equally above and below, so the text box centre IS the element centre
     whatever the line height, whatever the engine, at any size. Measured the
     same way afterwards, WebKit — which is every browser on iOS, and so the
     engine this was reported from — returns the requested fraction flat to
     within 0.0003 at all six sizes.

     0.10952 is DERIVED, and it is the number that puts the numeral's INK on the
     lens centre rather than merely somewhere consistent.

     Two measurements, each reproducible:
       1. THE LENS. Flood-fill the disc in cardfront_creature.webp (736x1312):
          centre (120.5, 139.5), i.e. x 0.16372, y 0.10633 of the asset —
          identical in all four frame variants. `cover` HEIGHT-fits here (card
          aspect 0.5536 is narrower than the asset's 0.5610), so it crops
          horizontally only and there is NO vertical crop: the asset's y
          fraction IS the on-card fraction, 0.10633. The same projection on x
          returns 0.15924 against the 0.1592 shipped below — five decimals, so
          the method is sound before it is trusted here.
       2. THE INK. `translate(-50%,-50%)` centres the element, and the flex box
          centres the LINE box inside it — but the player sees the ink. Measured
          with canvas TextMetrics at 2000px (sub-pixel, no rasterisation), in
          Chromium AND WebKit: Spectral's ascent 1.059em / descent 0.463em puts
          the baseline (1.059-0.463)/2 = 0.298em below the element centre, and a
          digit's ink centre sits 0.330em above its baseline. Net: the ink rides
          0.032em ABOVE the element centre — the two engines agree exactly.
          font-size is 0.18*card-width = 0.09965*card-height, so that is
          0.00319 of card height.

     Anchor = lens + ink offset = 0.10633 + 0.00319 = 0.10952. The previous
     0.1123 put the INK at 0.10909, i.e. 0.00276 of card height BELOW the lens —
     a proportional error, so it grew with the card and was worst exactly where
     it was reported: "it used to be absolutely perfect in all states except
     unhovered small cards on mobile ... it is still off there just as much as
     everywhere else". `left` and `translateX` are untouched on purpose —
     `febdf3ff` moved both axes at once and introduced a leftward bias on
     enlarged mobile cards. */
  top: calc(var(--card-height) * 0.10952);
  /* MEASURED, not eyeballed — "is the mana cost centered on the gem" is a
     geometry question with one derivable answer. The lens disc's centre sits
     at x = 0.1637 of the frame ASSET (widest-run circle fit on
     cardfront_creature.webp, 736x1312; identical geometry in all four frame
     variants). The frame paints with background-size:cover + center: the
     card box's aspect (12.29/22.2 = 0.5536) is narrower than the asset's
     (736/1312 = 0.5610), so cover height-fits and crops (0.5610-0.5536)/2 of
     a card-height from EACH side. On-card fraction therefore:
       (0.1637*0.5610 - (0.5610-0.5536)/2) / 0.5536 = 0.1592.
     Every surface derives --card-width/height from one --card-unit at the
     same 12.29/22.2 multipliers, so the crop — and this constant — is the
     same at every size; the old 0.148 (and the 4K-only 0.151 nudge, deleted
     below) were successive eyeballings toward this number, and their
     remainder is the reported "slightly biased to the left". */
  left: calc(var(--card-width) * 0.1592);
  transform: translate(-50%, -50%);
  color: #060f19;
  display: flex;
  justify-content: center;
  align-items: center;
  font-weight: bold;
  font-size: calc(var(--card-width) * 0.18);
  /* DECLARED, and with room. The comment above reasoned about `line-height: 1`
     but no line-height was ever set here, so the box inherited whatever the
     surrounding rule gave it and a numeral's ink could exceed it. The
     real-backend WebKit summon check caught the consequence: `card-cost`
     appeared in `clippedGlyphs` while the card travelled — the same trap that
     clipped the Japanese "/月" in the store. Centring is unaffected, because
     the box is anchored with translate(-50%, -50%) and extra line height grows
     symmetrically about that point.

     1.18 was still not enough. Spectral's natural line box is about 1.6em, so
     EVERY card measured ink ~23px inside a ~17px box, and the consequence was
     invisible until it was traced: `cardTextHasPaintGeometry` rejected every
     card in the game, `settleCardFaceForPresentation` therefore returned
     `textReady:false` for every card, and `enemy-replay.js` cancelled every
     prepared summon — enemy Characters appeared on the board with no glide at
     all. `normal` is the font's own line box, so containment holds on every
     engine, and because half-leading is split equally above and below the
     baseline the numeral does not move by a pixel. */
  line-height: normal;
  z-index: 3;
}

/* The 4K-only `left` nudge that lived here is gone: it was an empirical
   half-step toward the measured lens centre (0.1592), which the base rule now
   carries at every size. A resolution-scoped override of a scale-invariant
   fraction was the smell that something was eyeballed. */


.card-name {
  position: absolute !important;
  top: calc(var(--card-height) * 0.036) !important;
  left: calc(var(--card-width) * 0.32) !important;
  margin-top: 0 !important;
  /* Override media query margin-top values */
  width: calc(var(--card-width) * 0.61) !important;
  /* Increased width by 2.5x */
  height: calc(var(--card-height) * 0.065) !important;
  /* Fixed height for the title box */
  display: flex !important;
  /* Use flexbox for centering */
  justify-content: center !important;
  /* Center horizontally */
  align-items: center !important;
  /* Center vertically */
  text-align: center !important;
  /* Center text alignment */
  box-sizing: border-box !important;
  /* Include padding in width/height calculation */
  font-weight: bold;
  font-weight: bold;
  font-size: calc(var(--card-height) * 0.03);
  /* 5% larger than previous size */
  padding: 0 calc(var(--card-width) * 0.032) !important;
  /* Horizontal padding retained */
  white-space: nowrap !important;
  overflow: hidden !important;
  text-overflow: ellipsis !important;
  line-height: 1.05;
  z-index: 3;
  color: #060f19;
  text-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}

/* Second line for a title that cannot hold one legibly. The "20 character"
   limit is guidance we give the content models, not a guarantee — and
   translation cannot honour it at all (a faithful Spanish "Quetzalcoatl" is
   "Quetzalcóatl, la Serpiente Emplumada"). Two lines fit this bar even at the
   full title size, since 2 * 1.05 * 0.03 < its 0.065 height, so the long tail
   costs a line instead of half its type size. fitCardTitle() in cards.js owns
   when this class goes on; titles that already fit one line never get it. */
.card-name.card-name--wrap {
  white-space: normal !important;
  overflow-wrap: anywhere;
  text-overflow: clip !important;
}

/* `.card >` keeps the art-window geometry above scoped single-class resets
   (Foundry's `.foundry-surface-root *` loads later and would zero these
   margins, sliding the art up under the name bar with a dark bar below). */
.card > .card-image {
  position: relative;
  height: calc(var(--card-height) * 0.555);
  /* THE APERTURE MAY NEVER GIVE. `.card` is a column flex container and this is
     its in-flow child, so the default `flex-shrink: 1` puts the ARTWORK first in
     line whenever anything squeezes the card's height. The window would then
     change shape, `object-fit: cover` would show a different part of the
     painting, and the frame drawn over it would still look correct — which is
     exactly what "the card art zoomed" looks like. Let the layout that caused
     the pressure overflow instead; the well keeps the one ratio every screen
     shares. Contract: tests/unit/ui/cardArtWellGeometry.test.js. */
  flex: none;
  margin: calc(var(--card-height) * 0.15) calc(0.98 * var(--card-unit)) 0 calc(0.98 * var(--card-unit));
  overflow: visible;
  border-radius: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1;
  isolation: isolate;
}

.card-image.card-image-loading-placeholder {
  overflow: hidden !important;
  background:
    radial-gradient(circle at 50% 38%, rgba(246, 226, 122, 0.22), transparent 36%),
    linear-gradient(145deg, rgba(20, 20, 42, 0.86), rgba(47, 34, 72, 0.92));
}

.card-image.card-image-loading-placeholder::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(110deg,
      transparent 0%,
      rgba(255, 255, 255, 0.04) 32%,
      rgba(246, 226, 122, 0.24) 50%,
      rgba(255, 255, 255, 0.04) 68%,
      transparent 100%);
  transform: translateX(-115%);
  animation: moon-fuse-placeholder-shimmer 1.8s ease-in-out infinite;
  pointer-events: none;
  z-index: 2;
}

.card-image.card-image-loading-placeholder::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(var(--card-height) * 0.075);
  height: calc(var(--card-height) * 0.075);
  border: 2px solid rgba(246, 226, 122, 0.28);
  border-top-color: rgba(246, 226, 122, 0.95);
  border-radius: 50%;
  transform: translate(-50%, -50%);
  animation: moon-fuse-placeholder-spin 0.9s linear infinite;
  pointer-events: none;
  z-index: 3;
}

.card-image img.moon-fuse-placeholder-art {
  filter: saturate(0.75) brightness(0.72);
}

@keyframes moon-fuse-placeholder-shimmer {
  0% { transform: translateX(-115%); }
  100% { transform: translateX(115%); }
}

@keyframes moon-fuse-placeholder-spin {
  0% { transform: translate(-50%, -50%) rotate(0deg); }
  100% { transform: translate(-50%, -50%) rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .card-image.card-image-loading-placeholder::before,
  .card-image.card-image-loading-placeholder::after {
    animation: none;
  }

  .card-image.card-image-loading-placeholder::before {
    transform: none;
  }
}

.stellar-card .card-image .stellar-video {
  position: absolute;
  left: -7.5%;
  top: -7.5%;
  width: 115% !important;
  height: 115% !important;
  object-fit: cover;
  object-position: 50% 50%;
  transform: none;
  contain: paint;
  opacity: 0;
  pointer-events: none;
  z-index: 2;
}

/* A Stellar card animates AT REST, not only under the pointer. Its passive loop
   is visible exactly when it is actually running, which is what `data-playing`
   reports (set from the element's own `playing`/`pause` events, so the layer can
   never show a frozen frame it is not decoding).

   This used to key on `.stellar-hover`, which conflated two unrelated facts —
   "the pointer is over this card" and "the resting animation is on". Clearing
   hover therefore switched the card off for good, and the summon clip's `ended`
   handler only restarted the loop for a card that was still hovered, which after
   the drag that summoned it never is: "stellar anims stop playing after
   summoning". */
.stellar-card .card-image .stellar-loop-video[data-media-ready="true"][data-playing="true"] {
  opacity: 1;
}

/* A one-shot cinematic owns the art well by sitting a layer ABOVE the passive
   loop, never by switching the loop off.

   Hiding the loop under the cinematic was measured to STALL it in both Gecko
   and WebKit: an `opacity: 0` <video> stops being driven, and the wrap a
   `loop` clip performs at its end never completes, so the card came back from
   the summon reporting `paused: false` while frozen on a single frame
   (Firefox stuck at 3.71/4.00, WebKit at 0.00/4.00; Chromium was unaffected,
   which is exactly how this would have shipped). Layering costs one decode
   that was already running and cannot stall.

   A stuck `stellar-summon-playing`/`stellar-cast-playing` class no longer
   freezes a dead cinematic frame over the card either — those layers need
   `data-playing` too, so a paused clip hides itself. It does still block the
   loop from being resumed, because `playLoopVideo` guards on the same class
   (cards.js); that path is tracked in docs/open-follow-ups.md. */
.stellar-card .card-image .stellar-summon-video,
.stellar-card .card-image .stellar-cast-video {
  z-index: 3;
}

/* Same rule for all three layers: a Stellar video is visible exactly when it is
   ready AND actually rendering frames. `data-playing` is what makes a stuck
   `stellar-summon-playing` harmless — a staged clip that a board rebuild
   detaches is paused by the UA and never fires `ended`, and without this it
   would freeze its last frame on top of the card for the rest of the match. */
.stellar-card.stellar-summon-playing .card-image .stellar-summon-video[data-media-ready="true"][data-playing="true"] {
  opacity: 1;
}

.stellar-card.stellar-cast-playing .card-image .stellar-cast-video[data-media-ready="true"][data-playing="true"] {
  opacity: 1;
}


.card-image > .card-art-paint {
  position: absolute;
  left: -7.5%;
  top: -7.5%;
  width: 115% !important;
  height: 115% !important;
  max-height: none !important;
  object-fit: cover !important;
  object-position: 50% 50%;
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  display: block !important;
  opacity: 1 !important;
  z-index: 1;
  transform: none;
  contain: paint;
  image-rendering: auto;
  border: none !important;
  outline: none !important;
  box-shadow: none !important;
}

/* When using character portraits inside card slots, crop equally left/right to fit */
.card-image img.character-portrait {
  object-fit: cover !important;
  object-position: center center !important;
}

/* Text-first progressive render — docs/asset-loading.md law 2. The owning
   card (or StoryFeed tile) stays fully visible: frame, name, cost and
   description paint immediately. Only the not-yet-settled paint node is
   withheld (opacity 0, layout preserved for measurement) behind an animated
   art-well placeholder, until the shared loader reaches a truthful terminal
   paint. This still prevents empty art wells and direct-to-sampled
   blur/sharpen frames — the unsettled node is never visible — without ever
   hiding the element that owns it. */

.card-image:has(> .card-art-loading) {
  overflow: hidden;
  background:
    radial-gradient(circle at 50% 38%, rgba(246, 226, 122, 0.16), transparent 36%),
    linear-gradient(145deg, rgba(20, 20, 42, 0.85), rgba(47, 34, 72, 0.9));
}

.card-image:has(> .card-art-loading)::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(110deg,
      transparent 0%,
      rgba(255, 255, 255, 0.04) 32%,
      rgba(246, 226, 122, 0.18) 50%,
      rgba(255, 255, 255, 0.04) 68%,
      transparent 100%);
  transform: translateX(-115%);
  animation: card-art-well-shimmer 1.8s ease-in-out infinite;
  pointer-events: none;
}

.card-image > .card-art-loading {
  /* The settled paint rule above is `!important` because catalogue overrides
     used to leak into card wells. Loading is the stronger state: keep direct
     or provisional pixels behind the well until the loader removes this
     class, even while the owning card itself is intentionally visible. */
  opacity: 0 !important;
}

/* Chromium: a composited, infinitely-animating ::before inside a CSS-zoomed
   ancestor ESCAPES its overflow clip. The enlarged hover popup zooms, so the
   loading sweep above painted as a displaced semi-transparent golden
   rectangle over the Moon Fuse canvas, its edge cutting through chip names.
   The popup gates its own reveal on the settled raster, so the sweep adds
   nothing there — the static loading well remains, only the escaping
   animation goes. */
.card-hover-preview .card-image:has(> .card-art-loading)::before {
  animation: none;
  transform: none;
  opacity: 0;
}

.story-tile:has(> img.card-art-loading) {
  background:
    linear-gradient(145deg, rgba(20, 20, 42, 0.85), rgba(47, 34, 72, 0.9));
}

.story-tile > img.card-art-loading {
  opacity: 0;
}

/* GRACE (law 2 amendment): for the first --card-art-grace of a load the
   owning card/tile withholds itself exactly like the pre-doctrine behavior,
   so fast (warm-cache) art appears together with the card and the placeholder
   never flashes for a couple of frames. The hide is released by a bounded
   forwards animation — a bare visibility:hidden with no timed release is the
   old indefinite gate and is forbidden. When art settles inside the grace the
   selector stops matching and the complete card appears at once. */
:root {
  /* Tuned so typical warm loads (dev/CDN ~300-600ms) still appear atomically
     complete — the pre-doctrine "super smooth" feel — while genuinely slow
     connections get the text-first placeholder instead of a blank. */
  --card-art-grace: 450ms;
}

.card:has(.card-art-loading),
.story-tile:has(> img.card-art-loading) {
  visibility: hidden;
  animation: card-art-grace-reveal 0s linear var(--card-art-grace) forwards;
}

/* The hand stays responsive while drawing and dragging. `inherit`, never a
   literal `visible`, preserves the mulligan's deliberate ancestor hold.
   A board summon has a stricter contract below: its first visible frame must
   already contain settled art, so it is withheld by an explicit per-card state
   rather than broadly opting the whole lane out of the grace. */
#player-hand .card:has(.card-art-loading) {
  visibility: inherit;
}

/* A card with a successful settled transfer never re-enters the generic
   loading grace. New summons and the exceptional established replacement
   whose old paint could not transfer carry the explicit pending class below;
   every other board object stays present. */
:is(#player-board, #opponent-board) .card:not(.card-summon-art-pending):has(.card-art-loading) {
  visibility: inherit !important;
  animation: none !important;
}

/* GATE THE ART WELL, NEVER THE CARD.

   This rule used to add `opacity: 0 !important` to the CARD — frame, name, cost
   and rules text included — until board.js marked the raster ready. Its twin in
   `game-board.css` was corrected to gate the paint node in an earlier round;
   this copy survived, and because it is `!important` on the card element itself
   it kept winning. The defect outlived its own fix.

   Measured on the owner's own iPhone 13 landscape page box (844x280, DPR 3)
   against the live AI opponent: `opponent-board#dustyDevil_0` sat at computed
   opacity 0 for 1080ms while its description held 72 characters at full size
   the entire time. That is the report — "if i have summoned cards, and i summon
   another, their card description flicker ... it dissapears and then comes back
   in a second", and "when i summon card X even its own description vanishes".

   CLAUDE.md: "A readiness gate may never delete or withhold UI. Raster/decode/
   settle probes are warm-ups, not permissions... Gate the ART SWAP, never the
   element." docs/asset-loading.md law 2 says the same — render text first, gate
   only the art well, behind a placeholder. The well already owns that
   placeholder on `.card-image::before` (`.card-summon-art-error` switching it
   off explicitly is the proof it is there), so a cold summon now paints
   immediately over a shimmering well.

   Established cards are never enrolled in this state, so one slow image still
   cannot blank a lane. `visibility: inherit`, never a literal `visible`, keeps
   the live hit target and accessibility node on a visible board without
   punching through a board/view transition that deliberately hides an ancestor.
   Contract: tests/unit/styles/summonArtGateScope.test.js sweeps BOTH sheets. */
.card.card-summon-art-pending {
  visibility: inherit !important;
  animation: none !important;
  transition: none !important;
}

.card.card-summon-art-pending .card-image > .card-art-paint {
  opacity: 0 !important;
}

/* A failed terminal settlement may not strand an invisible gameplay object or
   pretend art loaded. The unresolved loading node still matches the generic
   warm-cache grace selector, so the terminal state must cancel that grace
   immediately while continuing to honour any hidden ancestor. */
.card.card-summon-art-error {
  visibility: inherit !important;
  animation: none !important;
}

/* Reveal the card with a still, unmistakable error well. */
.card.card-summon-art-error .card-image {
  background:
    radial-gradient(circle at 50% 42%, rgba(255, 136, 136, 0.18), transparent 42%),
    linear-gradient(145deg, rgba(44, 18, 28, 0.96), rgba(21, 18, 38, 0.98));
}

.card.card-summon-art-error .card-image::before {
  animation: none;
  opacity: 0;
}

.card.card-summon-art-error .card-image::after {
  content: "!";
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: rgba(255, 225, 225, 0.92);
  font-size: calc(5.2 * var(--card-unit));
  font-weight: 800;
  line-height: 1;
  opacity: 1;
  z-index: 3;
  text-shadow: 0 0 calc(1.4 * var(--card-unit)) rgba(255, 80, 105, 0.62);
  pointer-events: none;
}

/* Same rule, one step further out. card-drag.js clones the card and appends
   the clone to document.body (card-drag.js:133, :187), so it lands OUTSIDE the
   ids above and the bare grace rule would drag it around invisibly. The clone
   is literally under the player's finger — it is the least withholdable thing
   on screen. It keeps its loading well rather than being stripped like an FX
   clone (fx.js:850), so it stays pixel-identical to the card it came from.
   Specificity (0,3,0) beats the grace rule's (0,2,0) regardless of order. */
.card.card-drag-clone:has(.card-art-loading) {
  visibility: inherit;
}

/* Releases to `inherit`, NOT to a literal `visible`. `visibility` is an
   inherited property and an animation-origin declaration out-ranks a normal
   one, so a literal here beat every ancestor hold: a staging mulligan overlay
   and a held `#player-hand` both leaked bare cards over the live battlefield
   at 450ms, then lost them again the instant the art settled and `:has()`
   stopped matching. `inherit` keeps the bounded release while leaving a
   deliberate ancestor hold authoritative.
   Contract: tests/unit/styles/cardArtGraceAncestorHold.test.js. */
@keyframes card-art-grace-reveal {
  to {
    visibility: inherit;
  }
}

/* Cohort hold (grace rule, group form): a rebuilt group's still-loading
   members stay withheld until the whole cohort settles or its bounded cap
   releases them, so deals/decks appear together instead of one by one.
   !important so the per-card grace release animation cannot preempt the
   cohort's own (equally bounded) release. */
.card.card-cohort-hold {
  visibility: hidden !important;
}

@keyframes card-art-well-shimmer {
  to {
    transform: translateX(115%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .card-image:has(> .card-art-loading)::before {
    animation: none;
  }
}

.card-image img.card-art-loading {
  transition: none;
}

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

.card-desc {
  /* Pinned to the description panel drawn on the frame (inset for breathing room),
     card-relative so the margin is identical at every card size / preview scale /
     screen. fitCardDescription() shrinks long text to fit this box, so descriptions
     sit inside the panel instead of riding the bottom edge. */
  position: absolute;
  top: 72.5%;
  bottom: 6.5%; /* 100% - 93.5% */
  left: 9%;
  right: 9%; /* 100% - 91% */
  z-index: 3;

  font-size: calc(var(--card-height) * 0.0035); /* fallback; JS sets inline px-fit */
  line-height: 1.2;
  text-align: center;

  /* Center the text within the box; clip as a hard safety net if JS ever fails. */
  display: flex;
  flex-direction: column;
  justify-content: center;
  overflow: hidden;
}

/* Drawn, never laid out — the fourth copy of the trap the block at the top of
   the highlight states describes, and the worst of them: at 2px it took TWO
   pixels off the padding box every card interior resolves its fractions
   against, so a card carrying it drew its mana numeral twice as far from the
   gem as a bordered battle card did. */
.card.drag-over {
  box-shadow:
    0 0 10px 5px rgba(0, 255, 0, 0.6),
    inset 0 0 0 2px lime;
  --card-hover-transform: scale(1.05);
  --card-base-transform: scale(1);
}

/* Responsive card sizes */
/* Responsive card sizes - REMOVED as vmin handles scaling */

/* Draw-in: newly drawn hand cards fade in with a short stagger. The live card
   keeps its resting transform for every frame so its art/text raster is never
   resampled by an automatic entrance animation. */
.card.card-draw-in {
    animation: card-draw-in 340ms cubic-bezier(0.2, 0.9, 0.3, 1) var(--draw-delay, 0ms) backwards;
}

@keyframes card-draw-in {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}

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

/* Phone-landscape battle floor: the ONLY place the unit deviates from 1vmin.
   Viewport-driven like the surrounding battle layout so fine-pointer DevTools
   emulation cannot silently render smaller, less readable cards than a phone.
   max() keeps larger short landscapes on pure vmin; small ones get cards of
   roughly 56.5x102px. */
@media (orientation: landscape) and (max-height: 560px) {
  :root {
    --card-unit: max(1vmin, 4.6px);
  }
}

/* Under ~320pt of page box (an iPhone in Brave, where a URL bar AND a tab strip
   both stay on screen) the 4.6px floor no longer fits: the board slot row and
   the hand fan want 106px each out of a 288px box and collide by ~24px. A 288
   and a 344 box compute the SAME unit, so the deficit is height, not card size
   — but height is the one thing that box does not have. Lower the floor just
   for those boxes so both rows keep their full anatomy without printing the
   hand across the slots. */
@media (orientation: landscape) and (max-height: 320px) {
  :root {
    --card-unit: max(1vmin, 3.9px);
  }
}
