/* Oathfell — hand-written CSS (no Tailwind available offline) */

/* Vendored offline type (no CDN / font service, per the offline-assets rule).
   Space Mono for display/titles, Source Code Pro for body — both SIL OFL 1.1.
   Files + license texts live in priv/static/fonts/; see fonts/CREDITS.md. */
@font-face {
  font-family: "Space Mono";
  font-weight: 400; font-style: normal; font-display: swap;
  src: url("/fonts/space-mono-400-049dfc5919c21896a13296775d80b8c0.woff2?vsn=d") format("woff2");
}
@font-face {
  font-family: "Space Mono";
  font-weight: 700; font-style: normal; font-display: swap;
  src: url("/fonts/space-mono-700-e5e908997a453e21f2fe41cb72e186d2.woff2?vsn=d") format("woff2");
}
@font-face {
  font-family: "Source Code Pro";
  font-weight: 400; font-style: normal; font-display: swap;
  src: url("/fonts/source-code-pro-400-45a9e965b61ba3b6b4697c14ddae442c.woff2?vsn=d") format("woff2");
}
@font-face {
  font-family: "Source Code Pro";
  font-weight: 400; font-style: italic; font-display: swap;
  src: url("/fonts/source-code-pro-400-italic-89256ec72cd4033af1015295029357f8.woff2?vsn=d") format("woff2");
}
@font-face {
  font-family: "Source Code Pro";
  font-weight: 600; font-style: normal; font-display: swap;
  src: url("/fonts/source-code-pro-600-df97cab73b83a4355ad1d758592d4990.woff2?vsn=d") format("woff2");
}
@font-face {
  font-family: "Source Code Pro";
  font-weight: 700; font-style: normal; font-display: swap;
  src: url("/fonts/source-code-pro-700-eb7fda576edeb865a1a57a8593cf809a.woff2?vsn=d") format("woff2");
}

:root {
  --bg: #16141f;
  --panel: #221f2e;
  --panel-2: #2c2839;
  --line: #3a3450;
  --text: #e8e4f0;
  --muted: #9a92b0;
  --red: #e05252;
  --red-dark: #8c2f2f;
  --blue: #4f8fe0;
  --blue-dark: #2f4f8c;
  --gold: #e0b452;
  --gold-muted: rgba(224, 180, 82, 0.4);
  --gold-dim: rgba(224, 180, 82, 0.20);   /* spent AP wedge fill */
  --green: #5cb85c;
  --silver: #c9d2de;
  --cyan: #4fd0d8;
  --arcane: #4aa3ff;
  --tile: #4a4258;
  --tile-alt: #443d52;
  /* On-board special-tile icon colors: each capability's base color pre-mixed
     toward the board tile so the glyphs read muted, not candy-bright. */
  --icon-spawn: color-mix(in srgb, var(--green) 32%, var(--tile));
  --icon-defense: color-mix(in srgb, var(--silver) 60%, var(--tile));
  --icon-attack: color-mix(in srgb, var(--red) 60%, var(--tile));
  --icon-power: color-mix(in srgb, var(--gold) 60%, var(--tile));
  --icon-teleport: color-mix(in srgb, var(--arcane) 60%, var(--tile));
  /* Type: a display face for titles/brand/headings, a readable one for body.
     Curated title selectors below opt into --font-title; everything else
     inherits --font-body from <body>. */
  --font-title: "Space Mono", ui-monospace, Menlo, monospace;
  --font-body: "Source Code Pro", ui-monospace, Menlo, monospace;
}

* { box-sizing: border-box; }

/* Stop iOS Safari from auto-inflating text when rotated to landscape (and
   from leaving it inflated after rotating back to portrait). Applied to the
   root element so it covers every non-game text block. */
html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 15px;
  line-height: 1.45;
}

/* Titles, brand, and section labels use the display face. Keep this list
   curated — anything not named here stays in the body face. */
.brand,
h1, h2, h3,
.dash-section h2,
.turn-log-title,
.char-name,
.army-option-name {
  font-family: var(--font-title);
}

a { color: var(--gold); }

header.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: 960px;
  margin: 0 auto;
  padding: 0.3rem 1rem;
  background: var(--panel);
  border-bottom: 2px solid var(--line);
  position: sticky;
  top: 0;
  z-index: 45;
}

header.topbar .brand {
  font-weight: bold;
  letter-spacing: 2px;
  color: var(--gold);
  text-decoration: none;
  text-transform: uppercase;
}

header.topbar nav { display: flex; align-items: center; margin-left: auto; }
header.topbar nav ul {
  display: flex;
  gap: 1rem;
  align-items: center;
  list-style: none;
  margin: 0;
  padding: 0;
}
header.topbar nav li { margin: 0; }
header.topbar nav a { color: var(--muted); text-decoration: none; }
header.topbar nav a:hover { color: var(--text); }
header.topbar nav a.active { color: var(--gold); }

main.container { max-width: 960px; margin: 0 auto; }

.alert { padding: 0.6rem 1rem; margin: 0.5rem 0; border: 1px solid var(--line); border-radius: 4px; }
.alert-info { background: #23324a; border-color: var(--blue-dark); }
.alert-danger { background: #4a2323; border-color: var(--red-dark); }
.alert:empty { display: none; }

button, .button {
  font-family: inherit;
  font-size: 14px;
  background: var(--panel-2);
  color: var(--text);
  border: 2px solid var(--line);
  border-radius: 4px;
  padding: 0.45rem 1rem;
  cursor: pointer;
  text-decoration: none;
  display: inline-block;
  /* Kill the iOS double-tap-to-zoom gesture on controls: rapidly tapping a
     button (e.g. undoing several AP off the cap) otherwise reads as a
     double-tap and zooms the PWA. `manipulation` keeps panning + pinch-zoom. */
  touch-action: manipulation;
}

button:hover:not(:disabled), .button:hover { border-color: var(--gold); }
button:disabled { opacity: 0.45; cursor: default; }
button.primary, .button.primary { background: var(--gold); color: #221a06; border-color: var(--gold); font-weight: bold; }

input[type="text"], input[type="email"], input[type="password"], select {
  font-family: inherit;
  background: var(--panel-2);
  border: 2px solid var(--line);
  color: var(--text);
  border-radius: 4px;
  padding: 0.45rem 0.6rem;
  width: 100%;
  max-width: 24rem;
}

/* A select only honours background/color once it stops being a native control
   (Safari ignores them otherwise), so `appearance: none` is what makes it match
   the inputs above — and it takes the platform arrow with it, hence the drawn
   chevron. Inline SVG in a data URI: hand-written, no icon font or CDN.
   The fill is --muted (#9a92b0) spelled out, since a var() can't reach inside
   a data URI. */
select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 2rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M0 0h10L5 6z' fill='%239a92b0'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.7rem center;
  background-size: 0.6rem 0.4rem;
  cursor: pointer;
}
select:focus { border-color: var(--gold); outline: none; }
/* The native dropdown list is drawn by the OS, so its options need their own
   colours or they inherit a white-on-white default in some browsers. */
select option { background: var(--panel-2); color: var(--text); }
select option:disabled { color: var(--muted); }

label { display: block; margin: 0.6rem 0 0.2rem; color: var(--muted); }

h1, h2, h3 { font-weight: bold; letter-spacing: 1px; }
h1 { font-size: 1.4rem; }

.invalid-feedback { color: var(--red); display: block; }

/* ---------- Forms: fieldsets, actions, checkboxes ---------- */

fieldset {
  border: 2px solid var(--line);
  border-radius: 6px;
  padding: 0.4rem 1rem 1rem;
  margin: 1.5rem 0 0;
  /* let the fieldset shrink inside flex/narrow parents instead of forcing
     its min-content width */
  min-width: 0;
}

legend {
  padding: 0 0.4rem;
  font-weight: bold;
  letter-spacing: 1px;
  text-transform: uppercase;
  font-size: 0.9rem;
  color: var(--gold);
}

.field-help { color: var(--muted); margin: 0.2rem 0 0.6rem; }

/* Submit rows get the same top gap as the space between two fields (a label's
   top margin), so the button never crowds the last input. */
.form-actions { margin-top: 0.6rem; }

/* ---------- Auth pages (log in / register / reset) ---------- */

.auth { max-width: 22rem; margin: 2rem auto; }
.auth h1 { text-align: center; }
.auth .auth-links { text-align: center; color: var(--muted); margin-top: 1.2rem; }
.auth input[type="text"],
.auth input[type="email"],
.auth input[type="password"] { max-width: 100%; }
.auth .form-actions button,
.auth .form-actions .button { width: 100%; }

/* ---------- Settings page ---------- */

.settings { max-width: 34rem; }
.about-list { margin: 0.2rem 0 0; display: flex; flex-direction: column; gap: 0.4rem; }
.about-list > div { display: flex; gap: 0.75rem; }
.about-list dt { color: var(--muted); flex: 0 0 6.5rem; margin: 0; }
.about-list dd { margin: 0; }

/* ---------- Game board ---------- */

.game {
  width: 100%;
  /* Sized to the open cluster (board + gap + panel) and capped there, so the
     header spans that same width whether the games panel is open or closed —
     the top-nav icons never drift outward when you toggle the menu, and the
     title keeps its breathing room. On a phone this caps at the viewport (the
     panel's own width is vw-based), so the header simply fills the screen. The
     board and hand stay centred within it via .game-stage's auto margins. */
  max-width: calc(var(--board-width) + var(--games-menu-w) + 1rem);
  margin: 0 auto;
  position: relative;
}

/* Card chrome and bottom-center slide-up match the connecting overlay's
   `.connecting-card` (below) — one visual language for both toasts. */
.toast {
  position: fixed;
  left: 50%;
  bottom: 1rem;
  z-index: 1000;
  width: min(calc(100% - 2rem), 26rem);
  padding: 0.75rem 1rem;
  padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
  background: var(--panel);
  border: 2px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  font-size: 14px;
  transform: translate(-50%, 120%);
  animation: toast-in 0.25s ease-out forwards;
}

.toast--error {
  background: #4a2323;
  border-color: var(--red-dark);
  color: #f0b3b3;
}

.toast--info {
  border-color: var(--gold);
}

@keyframes toast-in {
  to { transform: translate(-50%, 0); }
}

@media (prefers-reduced-motion: reduce) {
  .toast {
    transform: translate(-50%, 0);
    animation: none;
  }
}

/* Auto-update banner: flashes bottom-center right before the page reloads onto a
   freshly deployed version. Non-interactive — the reload dismisses it. */
.update-toast {
  position: fixed;
  bottom: 1.25rem;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.55rem 1rem;
  background: var(--panel);
  border: 1px solid var(--gold);
  border-radius: 6px;
  color: var(--text);
  font-size: 14px;
  white-space: nowrap;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
  animation: update-toast-in 160ms ease-out;
}

.update-toast::before {
  content: "";
  width: 14px;
  height: 14px;
  border: 2px solid var(--gold-muted);
  border-top-color: var(--gold);
  border-radius: 50%;
  animation: update-toast-spin 700ms linear infinite;
}

@keyframes update-toast-in {
  from {
    opacity: 0;
    transform: translate(-50%, 8px);
  }
  to {
    opacity: 1;
    transform: translate(-50%, 0);
  }
}

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

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

/* Two solid-color banners meet flush at center; the turn shield straddles the
   seam. `position: relative` anchors both the absolutely-centered shield and the
   endcaps that overhang each banner's outer edge. */
.topbar-players {
  position: relative;
  display: flex;
  align-items: stretch;
  margin: 0 auto 0.5rem;
  width: var(--board-width);
  /* fixed so state changes never shift the board */
  height: 1.32rem;
}

/* --banner is the player color; --banner-eff is that color muted-but-tinted
   when it's not this player's turn, full-strength when it is. Everything else
   (background, endcap, text glow) derives from --banner-eff so the whole banner
   flips with one variable. --mute is how much hue survives when idle — tunable. */
.player-plate {
  --mute: 34%;
  --banner-eff: color-mix(in srgb, var(--banner) var(--mute), #2b2e34);
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  align-items: center;
  font-weight: bold;
  /* full player color at the outer (endcap) edge, fading to the page bg at the
     center seam. --fade flips direction for the opponent banner. */
  --fade: to right;
  background: linear-gradient(var(--fade), var(--banner-eff), var(--bg));
  color: color-mix(in srgb, #fff 72%, transparent);
}
.player-plate.active {
  --banner-eff: var(--banner);
  color: #fff;
}

.player-plate.side-blue { --banner: var(--blue); }
.player-plate.side-red { --banner: var(--red); }

/* Outer side clears the overhanging endcap; inner side clears the center
   shield. you = left banner, opponent = right banner (mirror). */
.player-plate.you {
  justify-content: flex-start;
  /* No left padding — the name overflows onto the scroll endcap. */
  padding: 0 0.7rem 0 0;
}
.player-plate.opponent {
  --fade: to left;
  justify-content: flex-end;
  /* No right padding — the name overflows onto the scroll endcap. */
  padding: 0 0 0 0.7rem;
}

/* The name fills the banner's inner space (flex: 1) so long text runs toward the
   center, where a mask gradient fades it to transparent before it collides with
   the turn shield — softer than a hard clip, and no "…" (which ate too much
   width). Short names sit at the outer edge, well clear of the fade. */
.plate-name {
  display: block;
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: clip;
  /* Lift the name above the absolutely-positioned scroll endcaps. */
  position: relative;
  z-index: 1;
  /* Black drop shadow so the name reads against the banner gradient. */
  text-shadow: 0 1px 2px #000;
}
/* Fade toward the center seam: transparent on the inner edge, opaque on the
   outer edge where the name is anchored. Mirrored per side. */
.player-plate.you .plate-name {
  text-align: left;
  /* Space the name off the (now margin-less) pip on its outer/left edge. */
  padding-left: 0.4rem;
  -webkit-mask-image: linear-gradient(to right, #000 70%, transparent);
  mask-image: linear-gradient(to right, #000 70%, transparent);
}
.player-plate.opponent .plate-name {
  text-align: right;
  /* Space the name off the (now margin-less) pip on its outer/right edge. */
  padding-right: 0.4rem;
  -webkit-mask-image: linear-gradient(to left, #000 70%, transparent);
  mask-image: linear-gradient(to left, #000 70%, transparent);
}
.player-plate.active .plate-name {
  text-shadow:
    0 1px 2px #000,
    0 0 8px color-mix(in srgb, var(--banner) 75%, #fff);
}
.plate-name-button {
  background: none;
  border: none;
  padding: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

/* Presence pip: a small status dot on the banner's OUTER edge, matching each
   name's alignment — leftmost on the left (you) banner, rightmost on the right
   (opponent) banner. It is a fixed-width (flex: none) sibling of the flexible
   .plate-name, so when a name is too long the name shrinks and clips
   (overflow: hidden) while the pip keeps its width — the pip is never clipped.
   z-index lifts it above the overhanging scroll endcap it rides over, like the
   name does. */
.plate-pip {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  /* No margin: the pip sits flush at the outer edge, overlaying the scroll
     endcap. Spacing from the name is provided by the name's padding instead
     (see .plate-name padding above), so the pip can ride all the way out. */
  position: relative;
  z-index: 1;
  background: color-mix(in srgb, #fff 20%, transparent);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.45);
}
.plate-pip.online {
  --online: #4ade80;
  background: var(--online);
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.45),
    0 0 6px color-mix(in srgb, var(--online) 80%, transparent);
}

/* Scroll endcap: darker shade of the (possibly muted) banner color, sized larger
   than the bar and pulled off the outer edge so it hides the banner's end and
   overhangs past the board's alignment (intended). Right side mirrors. */
/* Two-class selector (.player-plate .plate-endcap) so these beat the base
   `.icon` rule, which is defined later in the file and would otherwise win on
   source order at equal specificity — clobbering the size and color. */
.player-plate .plate-endcap {
  position: absolute;
  top: 50%;
  width: 1.98rem; /* 50% taller than the 1.32rem banner */
  height: 1.98rem;
  color: color-mix(in srgb, var(--banner-eff) 62%, #000);
  transform: translateY(-50%);
  pointer-events: none;
}
.player-plate.you .plate-endcap {
  left: -0.9rem;
}
.player-plate.opponent .plate-endcap {
  right: -0.9rem;
  transform: translateY(-50%) scaleX(-1);
}

/* The turn number rides on top of an oversized shield (~3× a nav icon) used
   purely as decorative backing, centered over the seam to mask the join. */
.turn-counter {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.6rem;
  height: 2.6rem;
}

.turn-counter .turn-shield {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  /* Deeper, muted gold — the flat --gold read as washed out. */
  color: color-mix(in srgb, var(--gold) 80%, #5a3d0e);
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));
}

/* Curvature: a top-lit / bottom-shadowed gradient masked to the shield's own
   outline (same path as the icon) so the shading reads as a domed surface and
   never spills onto the banners. The dark lower half also knocks down the
   yellow intensity. */
.turn-counter::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0.45),
    rgba(255, 255, 255, 0) 32%,
    rgba(0, 0, 0, 0.12) 52%,
    rgba(0, 0, 0, 0.5)
  );
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M256 16c25 24 100 72 150 72v96c0 96-75 240-150 312-75-72-150-216-150-312V88c50 0 125-48 150-72z'/%3E%3C/svg%3E")
    center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M256 16c25 24 100 72 150 72v96c0 96-75 240-150 312-75-72-150-216-150-312V88c50 0 125-48 150-72z'/%3E%3C/svg%3E")
    center / contain no-repeat;
}

.turn-number {
  position: relative;
  z-index: 2;
  font-weight: bold;
  font-size: 1.2rem;
  color: var(--text);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
}

.board-wrap { position: relative; width: var(--board-width); margin: 0 auto; }

/* The turn log is a modal dialog (reuses .dialog); its entries scroll within a
   bounded card while the title and close button stay put. */
.turn-log { max-height: min(80vh, 44rem); }

.turn-log-body {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  overflow-y: auto;
  min-height: 0;
}

.turn-log-title { font-weight: bold; color: var(--gold); }

.turn-log-empty {
  color: var(--muted);
  text-align: center;
  padding: 2rem 0;
}

.turn-log-group {
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 0.4rem 0.6rem;
  margin: 0;
}

.turn-log-legend {
  color: var(--muted);
  font-weight: bold;
  font-size: 0.85rem;
  padding: 0 0.35rem;
}

.turn-log-entries {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.turn-log-entry {
  line-height: 1.4;
  font-size: 0.95rem;
  color: var(--text);
}

.turn-log-entry.kind-destroyed { color: var(--red); font-weight: bold; }
.turn-log-entry.kind-revive { color: var(--green); }
.turn-log-entry.kind-deploy { color: var(--muted); }
/* The board acting rather than a player: set apart from the turn's own lines. */
.turn-log-entry.kind-spire_pulse,
.turn-log-entry.kind-spire_bullrush { color: var(--gold); font-style: italic; }

/* End-of-game result overlay. No sheet or blur on the panel itself — it's just
   a centering frame over the live board. The "Liquid Glass" card floats on top
   (mirrors .preload-panel and the .hand dish: translucent --tile tint, an inset
   rim carving depth, and a heavy backdrop blur that frosts the board showing
   through it, lifted off the scene by an outer drop shadow). */
.winner-panel {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  border-radius: 6px;
  z-index: 15;
}

.winner-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  text-align: center;
  padding: 1.5rem 2rem;
  border-radius: 1.25rem;
  background:
    /* specular catch on the far, lower-right inner wall */
    radial-gradient(
      130% 120% at 80% 92%,
      color-mix(in srgb, white 22%, transparent) 0%,
      transparent 46%
    ),
    /* shadowed near, upper-left inner wall */
    radial-gradient(
      120% 120% at 20% 6%,
      color-mix(in srgb, black 34%, transparent) 0%,
      transparent 52%
    ),
    /* base translucent glass tint, deepening toward lower-right */
    linear-gradient(
      135deg,
      color-mix(in srgb, var(--tile) 22%, transparent) 0%,
      color-mix(in srgb, var(--tile) 48%, transparent) 100%
    );
  box-shadow:
    inset 1px 1px 0 color-mix(in srgb, white 30%, transparent),
    inset 2px 3px 7px rgba(0, 0, 0, 0.4),
    inset -2px -3px 8px color-mix(in srgb, white 15%, transparent),
    0 10px 30px rgba(0, 0, 0, 0.45);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  backdrop-filter: blur(16px) saturate(140%);
}

.winner-result {
  font-size: 1.6rem;
  font-weight: bold;
  color: var(--gold);
  letter-spacing: 1px;
}

.winner-actions {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  flex-wrap: wrap;
}

.rating-swing {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
}

.rating-swing-label {
  font-size: 0.6rem;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--muted);
}

.rating-pill {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-title);
  font-weight: bold;
  font-size: 0.9rem;
  padding: 2px 9px;
  border-radius: 20px;
  line-height: 1;
}

.rating-pill.up {
  color: var(--green);
  background: rgba(92, 184, 92, 0.14);
  border: 1px solid rgba(92, 184, 92, 0.5);
}

.rating-pill.down {
  color: var(--red);
  background: rgba(224, 82, 82, 0.14);
  border: 1px solid rgba(224, 82, 82, 0.5);
}

.rating-value {
  font-family: var(--font-title);
  font-weight: bold;
  font-size: 2.4rem;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.rating-value.up { color: var(--gold); }
.rating-value.down { color: #c9c2d8; }

.rating-pop { animation: rating-pop 0.5s ease-out; }

@keyframes rating-pop {
  0% { transform: scale(1); }
  40% { transform: scale(1.14); }
  100% { transform: scale(1); }
}

/* ---- Board geometry + 3/4 metrics --------------------------------------- */
/* The sheet's SECOND :root block, sited next to the board rules it feeds
   rather than with the palette and type scale at the top. Grepping `:root`
   finds both. */
:root {
  /* Portrait board is 5 wide × 9 tall. The vw term keeps it usable in a
     narrow side-docked window, the vh term keeps the tall board inside the
     viewport, and the px cap stops a maximized browser from over-scaling. */
  --tile-size: min(17vw, 9.5vh, 72px);
  /* The board's true rendered width: 5 tiles + 4 inner gaps (3px) + 2
     borders (3px). The top bar and tray pin to this so all three rows share
     one edge-to-edge width and center as a single column. */
  --board-width: calc(var(--tile-size) * 5 + 18px);
  /* Board height mirrors --board-width: 9 tiles + 8 inner gaps (3px) + 2
     borders (3px). Used to collapse the tilted scene's reserved height. */
  --board-height: calc(var(--tile-size) * 9 + 30px);
  /* 3/4 tilted board. ONE angle drives both the board tilt and the piece/FX
     counter-rotation, so the diorama retunes from here. */
  --board-tilt: 28deg;
  /* Billboarding, as a composable value. A `.piece` stands upright off the
     tilted floor by counter-rotating; any transform animation on that piece
     REPLACES its transform for the animation's whole duration, so the sprite
     lies flat in the ground plane until the animation ends — silent, and
     visible in only one of the two view modes.

     So: every `.piece` keyframe must compose this variable rather than set a
     bare transform — `transform: var(--piece-billboard) scale(…)`. One keyframe
     block then serves both views, because the flat value is an identity
     rotation. (It is `rotateX(0deg)` and not `none` precisely so it composes;
     `none scale(…)` is invalid and would drop the declaration.)
     `piece_billboard_guard_test` fails the build on a keyframe that forgets.
     The tilted subtree overrides it on `.board-scene.view-tilted`. */
  --piece-billboard: rotateX(0deg);
  --board-perspective: 820px;
  /* The tilted board projects shorter than its flat layout box; this factor
     collapses the scene's reserved height to the projected height so the freed
     space closes up toward the top player bar. Calibrated by eye. */
  --board-squash: 0.82;
  /* Height of the 3/4-mode floor-depth riser (the extruded front faces of the
     near row). The scene reserves this below the board and the board lifts off
     the bottom by it, so the hand tray flows down to make room. */
  --board-riser-h: 24px;
  /* How far the riser's BOTTOM edge pulls in on each side, tapering the front
     face into a trapezoid so the near wall reads as receding (deepens the 3D
     slab). Top stays flush with the board; only the bottom narrows. */
  --board-riser-taper: 7px;
}

.board-scene { width: var(--board-width); margin: 0 auto; }

.board {
  position: relative;
  display: grid;
  grid-template-columns: repeat(5, var(--tile-size));
  gap: 3px;
  background: var(--line);
  border: 3px solid var(--line);
  border-radius: 6px;
  margin: 0 auto;
  width: max-content;
}

/* Per-map ground texture, painted inside the board beneath the tile grid. It is
   absolutely positioned so it fills the board's padding box without taking a
   grid cell: the 3px tile gaps reveal it at full strength while the tiles show
   it through their (< 100%) --board-tile-alpha. The default --board-texture:
   none paints nothing, so an untextured board reads exactly as before. As a
   child of .board it inherits the 3/4 tilt; .view-tilted .tile's translateZ(1px)
   keeps the tiles above it. */
.board-texture {
  position: absolute;
  inset: 0;
  border-radius: 4px;
  pointer-events: none;
  /* --board-texture-size defaults to `cover`; a map can zoom in (e.g. 110%) to
     crop unwanted shapes at the image edges while staying centred. */
  background: var(--board-texture) center / var(--board-texture-size, cover) no-repeat;
}

.tile {
  position: relative;
  aspect-ratio: 1;
  /* --board-tile-alpha (100% unless the map supplies a board texture) fades the
     tile FILL only — never the tile's contents, so pieces stay fully opaque —
     letting the .board-texture beneath read through. */
  background: color-mix(in srgb, var(--tile) var(--board-tile-alpha), transparent);
  /* An opaque grid line drawn back over the 3px gap (which otherwise reveals the
     .board-texture at full strength). --board-line-alpha fades the map's --line:
     0% = invisible (gap shows texture, the untextured/default look), 100% = a
     solid texture-free line. Outline sits outside the tile box so it fills the
     gap without insetting the face; it composes with the home-row box-shadow and
     is overridden by .selected's gold ring. */
  outline: 2px solid
    color-mix(in srgb, var(--board-line, var(--line)) var(--board-line-alpha), transparent);
  outline-offset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  min-width: 0;
}

.tile:nth-child(even) {
  background: color-mix(in srgb, var(--tile-alt) var(--board-tile-alpha), transparent);
}
.tile.home-mine { box-shadow: inset 0 -3px 0 var(--muted); }
.tile.home-enemy { box-shadow: inset 0 3px 0 var(--muted); }

.tile.selected { outline: 3px solid var(--gold); outline-offset: -3px; z-index: 1; }
/* A legal-action tile gets a translucent color fill keyed to what the action */
/* does: blue to move, red to attack, green to heal/revive/aid an ally. The   */
/* fill is a ::before overlay that sits above the floor tile but below the     */
/* floor icons and character sprite, so both stack cleanly on top. The intent */
/* class (target-move/attack/heal) overrides this default green.             */
.tile.targeted { z-index: 1; }
.tile.targeted::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(92, 184, 92, 0.35);
  pointer-events: none;
}
.tile.target-move::before { background: rgba(79, 143, 224, 0.35); }
.tile.target-attack::before { background: rgba(224, 82, 82, 0.35); }
.tile.target-heal::before { background: rgba(92, 184, 92, 0.35); }
.tile.targeted:hover { background: #2f4032; }
.tile.target-move:hover { background: #23324a; }
.tile.target-attack:hover { background: #402f2f; }
.tile.target-heal:hover { background: #2f4032; }

/* --- Combat effect pulses (board animation layer) ----------------------- */
/* Classes are toggled by the CombatFx JS hook. Colors: damage red, heal    */
/* green, shield cyan, revive gold; the actor always flares gold.           */

/* Lift an animating tile above its neighbors so its ring/glow and shake are
   not clipped by the adjacent grid squares painted after it. */
.tile.fx-actor,
.tile.fx-damage,
.tile.fx-heal,
.tile.fx-shield,
.tile.fx-revive,
.tile.fx-death,
.tile.fx-raise {
  z-index: 2;
}

.tile.fx-actor {
  animation: fx-actor 0.5s ease-out;
}
@keyframes fx-actor {
  0% { box-shadow: 0 0 0 0 transparent; }
  30% { box-shadow: 0 0 0 4px var(--gold), 0 0 16px 4px var(--gold-muted); }
  100% { box-shadow: 0 0 0 0 transparent; }
}

/* Attack — sharp flood + ring + shake. */
.tile.fx-damage {
  animation: fx-damage 0.65s ease-out;
}
@keyframes fx-damage {
  0% { box-shadow: 0 0 0 0 transparent; background: var(--tile); transform: translateX(0); }
  15% { background: rgba(224, 82, 82, 0.9); transform: translateX(-3px); }
  30% { box-shadow: 0 0 0 4px var(--red), 0 0 18px 6px rgba(224, 82, 82, 0.7); transform: translateX(3px); }
  50% { transform: translateX(0); }
  100% { box-shadow: 0 0 0 0 transparent; background: var(--tile); }
}

/* Heal — slow green swell, no shake. */
.tile.fx-heal {
  animation: fx-heal 1.25s ease-in;
}
@keyframes fx-heal {
  0% { box-shadow: 0 0 0 0 transparent; background: var(--tile); transform: scale(1); }
  55% { background: rgba(92, 184, 92, 0.3); box-shadow: inset 0 -14px 16px -6px rgba(92, 184, 92, 0.5), 0 0 10px 2px rgba(92, 184, 92, 0.25); transform: scale(1.02); }
  85% { background: rgba(92, 184, 92, 0.85); box-shadow: inset 0 0 22px 5px rgba(92, 184, 92, 0.7), 0 0 24px 9px rgba(92, 184, 92, 0.6); transform: scale(1.05); }
  100% { box-shadow: 0 0 0 0 transparent; background: var(--tile); transform: scale(1); }
}

/* Shield — crisp cyan ring snaps in, braces, holds, releases. */
.tile.fx-shield {
  animation: fx-shield 0.95s ease-out;
}
@keyframes fx-shield {
  0% { box-shadow: 0 0 0 0 transparent; transform: scale(1); }
  18% { box-shadow: 0 0 0 3px var(--cyan), 0 0 0 7px rgba(79, 208, 216, 0.35), 0 0 14px 3px rgba(79, 208, 216, 0.4); transform: scale(0.93); }
  30% { transform: scale(1); }
  70% { box-shadow: 0 0 0 3px var(--cyan), 0 0 0 7px rgba(79, 208, 216, 0.35), 0 0 14px 3px rgba(79, 208, 216, 0.4); }
  100% { box-shadow: 0 0 0 0 transparent; transform: scale(1); }
}

/* Revive — very fast bright-gold bloom, long ethereal fade. */
.tile.fx-revive {
  animation: fx-revive 1.3s cubic-bezier(0.15, 0.7, 0.3, 1);
}
@keyframes fx-revive {
  0% { box-shadow: 0 0 0 0 transparent; background: var(--tile); }
  8% { box-shadow: 0 0 0 4px var(--gold), 0 0 30px 12px rgba(224, 180, 82, 0.9); background: rgba(224, 180, 82, 0.75); }
  100% { box-shadow: 0 0 0 0 transparent; background: var(--tile); }
}

/* Death beat — LiveView swaps the unit for its corpse the instant the action
   applies, so by pulse time the living sprite is already gone. The CombatFx
   hook bridges that: it clones the corpse into a living-looking stand-in
   (.fx-dying) shown while the strike lands, hides the real corpse (.fx-hidden)
   underneath, then at the impact removes the stand-in and reveals the corpse
   with this collapse-in — so the unit is visibly struck, then dies. */
.piece.fx-dying { pointer-events: none; }
.piece.fx-hidden { display: none; }
.tile.fx-death .piece.corpse {
  animation: fx-death 0.55s ease-out;
}
@keyframes fx-death {
  0% { transform: var(--piece-billboard) scale(1.18); opacity: 0.2; filter: brightness(1.7); }
  100% { transform: var(--piece-billboard) scale(1); opacity: 1; filter: none; }
}

/* Revive raise — likewise the restored unit is placed on the board the instant
   the action applies. Applied at 0ms, this keeps that unit hidden until the
   gold bloom (the fx-revive beat at ~500ms), then brings it in with the bloom,
   so the character never pops in ahead of its animation. */
.tile.fx-raise .piece.unit {
  animation: fx-raise 1.15s ease-out;
}
@keyframes fx-raise {
  0% { opacity: 0; transform: var(--piece-billboard) scale(0.85); }
  43% { opacity: 0; transform: var(--piece-billboard) scale(0.85); }
  60% { opacity: 1; transform: var(--piece-billboard) scale(1.06); }
  100% { opacity: 1; transform: var(--piece-billboard) scale(1); }
}

/* Teleport dissolve: a cloned piece phases out at the landing tile, then the
   real piece phases in at the destination. Tinted with the arcane blue used by
   the teleport tile marker. Reduced-motion is handled in JS (the sequence is
   skipped entirely), so no @media fallback is needed here. */
@keyframes teleport-out {
  from {
    opacity: 1;
    transform: var(--piece-billboard) scale(1);
    filter: none;
  }
  to {
    opacity: 0;
    transform: var(--piece-billboard) scale(1.25);
    filter: blur(3px) drop-shadow(0 0 10px var(--arcane));
  }
}

@keyframes teleport-in {
  from {
    opacity: 0;
    transform: var(--piece-billboard) scale(0.6);
    filter: blur(3px) drop-shadow(0 0 10px var(--arcane));
  }
  to {
    opacity: 1;
    transform: var(--piece-billboard) scale(1);
    filter: none;
  }
}

.piece.fx-teleport-ghost {
  pointer-events: none;
}

.piece.fx-teleport-out {
  animation: teleport-out 280ms ease-in forwards;
}

.piece.fx-teleport-in {
  animation: teleport-in 280ms ease-out forwards;
}


/* Per-context card colors, keyed by the icon-<name> family class every icon
   carries: swords stay gold (the default), armor reads silver, health green,
   attack spells red. The mapping only sets custom properties — a surface
   changes color only where it opts into --card-color, so UI chrome (nav, the
   turn counter's shield) keeps its own palette. */
.icon-defense,
.icon-abdominal-armor,
.icon-heart-armor,
.icon-cracked-helm {
  --card-color: var(--silver);
  --card-glow: rgba(201, 210, 222, 0.9);
}
.icon-hp,
.icon-vial,
.icon-fast-arrow,
.icon-boar,
.icon-beer-stein {
  --card-color: var(--green);
  --card-glow: rgba(92, 184, 92, 0.9);
}
.icon-assault,
.icon-graveyard {
  --card-color: var(--red);
  --card-glow: rgba(224, 82, 82, 0.9);
}
/* Card/stat surfaces that adopt the family color (unmapped icons keep the
   surface's own color via the currentColor fallback). */
.spell-glyph .icon,
.codex-tile-icon .icon,
.codex-card-icon .icon,
.char-hp-label .icon {
  color: var(--card-color, currentColor);
}

/* Flat, screen-aligned combat-FX layer. Sits over the tilted board (not
   rotated) so spell flashes, oriented attack/heal sprites, and floating
   numbers face the camera. Children are placed by the CombatFx hook via inline
   left/top in this layer's local px. */
.board-fx {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: visible; /* far-tile FX may extend past the collapsed board-wrap */
  z-index: 3;
}

/* Spell-cast flash — the cast spell's icon (an SVG <use> created and removed
   by the CombatFx hook) stamps onto the target cell with a quick "click in",
   holds while the hits land, then blooms: enlarging and fading away. A
   targetless spell has no cell, so its -global variant plays the same beat
   centered over the whole board. */
.spell-fx {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--tile-size) * 0.72);
  height: calc(var(--tile-size) * 0.72);
  color: var(--card-color, var(--gold));
  filter: drop-shadow(0 0 6px var(--card-glow, rgba(224, 180, 82, 0.85)));
  pointer-events: none;
  z-index: 4;
  opacity: 0;
  transform: translate(-50%, -50%);
  animation: spell-fx 1.05s linear forwards;
}
.spell-fx-global {
  width: calc(var(--tile-size) * 1.15);
  height: calc(var(--tile-size) * 1.15);
}
/* Linear overall so each phase keeps its real-time share (a whole-animation
   ease-out would squeeze hold+bloom into the first quarter second); the easing
   that matters happens between keyframes. */
@keyframes spell-fx {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(1.7); animation-timing-function: ease-out; }
  14% { opacity: 1; transform: translate(-50%, -50%) scale(0.9); animation-timing-function: ease-out; }
  24% { transform: translate(-50%, -50%) scale(1); }
  48% { opacity: 1; transform: translate(-50%, -50%) scale(1); animation-timing-function: ease-out; }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(2.4); }
}

/* Ring-FX — a hand-drawn pixel-art shockwave that expands from a tile on the
   flat overlay (tilt-immune), spawned by the CombatFx hook's `ringFx`. Reusable:
   the Spire discharge is the first caller (fire ring on the magic pulse, force
   ring following it at overload), but any spell can call ringFx with the same
   classes — a caster's Bull Rush / Grave Tide is the obvious backport.

   Two stacked animations do the work. `ring-grow` scales the tile-sized element
   up so the sprite SWEEPS across the board (geometric expansion + fade), while
   `ring-churn` steps across the 9-frame strip via PIXEL offsets so each step
   lands on a clean frame boundary (percentage + steps() would not) — the source
   art only animated its internal texture (flames licking / dust spinning), so
   the outward growth is ours. image-rendering keeps the upscaled pixels crisp. */
/* The ground-plane FX layer, a child of #board. It carries no transform, so it
   sits in the board's plane: flat top-down by default, and — because #board is
   preserve-3d in 3/4 mode — lying ON the tilted floor there, foreshortened with
   the board and depth-sorted BEHIND the standing pieces (the Spire tower draws
   over it, like every unit's drop shadow does). z-index 1 seats it above the
   tile floors but below the pieces (z-index 2) for the flat-mode paint order.
   Rings are positioned in board-local px (see ringFx), not projected screen px. */
.board-ground-fx {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1;
}
.view-tilted .board-ground-fx {
  transform-style: preserve-3d; /* keep the rings in the board's 3D space */
  /* Sit just above the tiles' own translateZ(1px) so the ring paints ON the
     floor instead of behind the opaque tiles (where it only leaked through the
     grout gaps). Still far below the standing pieces — whose bodies rise to
     ~translateZ(30px) — so the tower and units keep depth-sorting in front. */
  transform: translateZ(2px);
}
.ring-fx {
  position: absolute;
  width: var(--tile-size);
  height: var(--tile-size);
  /* Centre the tile-sized element on its (left,top) point via negative margins,
     leaving `transform` free for the scale so the two never fight. */
  margin: calc(var(--tile-size) * -0.5) 0 0 calc(var(--tile-size) * -0.5);
  transform-origin: center;
  background-repeat: no-repeat;
  background-size: 900% 100%; /* 9 frames laid out horizontally */
  image-rendering: pixelated;
  pointer-events: none;
  opacity: 0;
}
.ring-fx-fire {
  background-image: url("/images/sprites/fx-fire-ring-3778313c7033fb3d3df467268a0810eb.png?vsn=d");
  animation: ring-grow 1.15s linear forwards, ring-churn 1.15s steps(9) forwards;
}
.ring-fx-force {
  background-image: url("/images/sprites/fx-force-ring-4f09dfa9be76ead0db4bc463e820a1d7.png?vsn=d");
  /* Plays immediately when spawned — the SERVER sequences the beats, pushing the
     force ring (and the knockback board swap) only after the fire ring's run, so
     the shove reads as a distinct second action tied to the wave. */
  animation: ring-grow 1s linear forwards, ring-churn 1s steps(9) forwards;
}
@keyframes ring-grow {
  0% { opacity: 0; transform: scale(0.5); }
  12% { opacity: 1; }
  82% { opacity: 1; }
  100% { opacity: 0; transform: scale(9); }
}
@keyframes ring-churn {
  from { background-position-x: 0; }
  to { background-position-x: calc(var(--tile-size) * -9); }
}
@media (prefers-reduced-motion: reduce) {
  .ring-fx { animation: none; display: none; }
}
/* Area burst: a hand-cast area spell's floor bloom (see app.js burstFx). Unlike
   the ring — a thin shockwave that scales outward — this fills its whole
   footprint, so the box is fixed at the spell's size and the ART carries the
   ignite-bloom-fade over its frames; the wrapper only sweeps the strip and runs
   the opacity envelope. Lives on the ground-plane layer, so units stand IN the
   flames. Sheets are 9-frame horizontal strips, stepped like the rings. */
/* The base carries the 3x3-AoE geometry + motion a ground burst uses (Firestorm
   today): the box spans the footprint and centres on the AoE tile via negative
   margins (half the box each way), then two stacked animations — the same split
   the ring uses — play. `burst-grow` erupts it from a point out to the full
   footprint and fades it (transform + opacity); `burst-churn` sweeps the 9-frame
   strip (a full box-width per frame) for live motion. A per-spell rule below adds
   only its sheet. `.burst-fx` never appears without one of those classes (JS
   always pairs them), so putting the geometry here is safe; a future non-3x3
   burst can override width/height/margin. */
.burst-fx {
  position: absolute;
  transform-origin: center;
  width: calc(var(--tile-size) * 3);
  height: calc(var(--tile-size) * 3);
  margin: calc(var(--tile-size) * -1.5) 0 0 calc(var(--tile-size) * -1.5);
  background-repeat: no-repeat;
  background-size: 900% 100%; /* 9 frames laid out horizontally */
  image-rendering: pixelated;
  pointer-events: none;
  opacity: 0;
  animation: burst-grow 1s ease-out forwards, burst-churn 1s steps(9) forwards;
}
/* Firestorm: a 3x3 fire bloom. */
.burst-fx-firestorm {
  background-image: url("/images/sprites/fx-firestorm-55951474698931d46a72656818eb76c0.png?vsn=d");
}
@keyframes burst-grow {
  0% { opacity: 0; transform: scale(0.25); }
  20% { opacity: 1; transform: scale(1); }
  70% { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.1); }
}
@keyframes burst-churn {
  from { background-position-x: 0; }
  to { background-position-x: calc(var(--tile-size) * -27); }
}
@media (prefers-reduced-motion: reduce) {
  .burst-fx { animation: none; display: none; }
}
/* Upright cast billboard: an area spell's FX that must STAND UP off the board
   (Grave Tide's tombstones) rather than lie flat on it. Lives on the flat,
   screen-aligned overlay (see app.js billboardFx), pinned by inline left/top to
   the AoE tile's projected centre, then translated so the art's ground line sits
   at that centre with the sprite rising above it (facing the camera like the
   card-flash icons — no floor tilt to shear it). The ART carries the whole
   rise-hold-sink arc across its 9 frames; `cast-churn` sweeps the strip (a full
   box-width per frame) and `cast-fade` runs the opacity envelope. Like the
   burst, a per-spell rule adds only its sheet. */
.cast-fx {
  position: absolute;
  transform: translate(-50%, -66%);
  width: calc(var(--tile-size) * 3);
  height: calc(var(--tile-size) * 3);
  background-repeat: no-repeat;
  background-size: 900% 100%; /* 9 frames laid out horizontally */
  image-rendering: pixelated;
  pointer-events: none;
  opacity: 0;
  animation: cast-churn 1.2s steps(9) forwards, cast-fade 1.2s ease-out forwards;
}
/* Grave Tide: a field of tombstones erupting from the earth and sinking back —
   the name made literal, the necrotic counterpart to Firestorm's fire. */
.cast-fx-gravetide {
  background-image: url("/images/sprites/fx-gravetide-c6763d9bb2f33ee63b318b45a6c85c73.png?vsn=d");
}
@keyframes cast-churn {
  from { background-position-x: 0; }
  to { background-position-x: calc(var(--tile-size) * -27); }
}
@keyframes cast-fade {
  0% { opacity: 0; }
  8% { opacity: 1; }
  88% { opacity: 1; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .cast-fx { animation: none; display: none; }
}

/* Stampede: a board-wide DIRECTIONAL cast FX — an abstract rolling wall of dust
   that advances across the whole board, blowing the enemy back. Unlike the
   tile-anchored burst/billboard, this spans the flat overlay's width and travels
   its full height; the JS sets the band height and the per-cast custom props.
   --sweep-from/--sweep-to are the start/end translateY (px) and --sweep-flip
   mirrors the cloud (scaleY) so its billowing edge leads whichever way it goes,
   together carrying the caster-relative direction; --sweep-ms syncs the CSS run
   to the JS reap. */
.sweep-fx {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  background-repeat: no-repeat;
  /* A 16-frame animation strip laid horizontally (each frame one band-width).
     Keep the count in sync with app.js SWEEP_FRAMES + sweep-churn steps(16). */
  background-size: 1600% 100%;
  pointer-events: none;
  will-change: transform, background-position, opacity;
  transform: translateY(var(--sweep-from)) scaleY(var(--sweep-flip, 1));
  /* Two motions at once: the band TRAVELS across the board (sweep-across) while
     its frames CHURN (sweep-churn) so the cloud roils as it advances, instead of
     one static image sliding. */
  animation:
    sweep-across var(--sweep-ms, 1300ms) linear forwards,
    sweep-churn var(--sweep-churn-ms, 2000ms) steps(16) infinite;
}
.sweep-fx-stampede {
  background-image: url("/images/sprites/fx-stampede-e3fb341a30b902d0b4a20671dfb5d676.png?vsn=d");
}
@keyframes sweep-across {
  0% {
    transform: translateY(var(--sweep-from)) scaleY(var(--sweep-flip, 1));
    opacity: 0;
  }
  12% { opacity: 1; }
  80% { opacity: 1; }
  100% {
    transform: translateY(var(--sweep-to)) scaleY(var(--sweep-flip, 1));
    opacity: 0;
  }
}
@keyframes sweep-churn {
  from { background-position-x: 0; }
  to { background-position-x: var(--sweep-churn-to, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .sweep-fx { animation: none; display: none; }
}

/* Stampede pushback (lagged): the shoved unit is already in its NEW tile, but
   `shoveSlideFx` sets --shove-dx/-dy to the old->new board-local offset and
   holds it there (animation-delay + `both`) until the dust wall reaches it, then
   slides it home. The translate is applied in the board plane and `var(--piece-
   billboard)` composed AFTER it (identity when flat, the counter-rotation when
   tilted), so one keyframe serves both views and the unit foreshortens with the
   floor exactly like its resting sprite (see piece_billboard_guard_test). */
.piece.fx-shoved {
  animation: shove-slide 320ms ease-out var(--shove-delay, 550ms) both;
}
@keyframes shove-slide {
  from {
    transform: translate(var(--shove-dx, 0), var(--shove-dy, 0)) var(--piece-billboard);
  }
  to { transform: translate(0, 0) var(--piece-billboard); }
}
@media (prefers-reduced-motion: reduce) {
  .piece.fx-shoved { animation: none; }
}

/* Lift the cast tile above its neighbors (and their simultaneous hit pulses,
   z-index 2) so the bloom's overflow isn't clipped under them. */
.tile.fx-spell { z-index: 3; }

/* Basic-attack animation: an oriented, abstract sprite for the attack's
   type x range, layered over the target tile's damage FX. The wrapper carries
   placement + the attacker->target rotation (set inline per-strike by the
   CombatFx hook); the inner `.attack-fx-frames` element carries the frame
   stepping + fade — a 9-frame horizontal sprite sheet stepped by `steps(9)`
   (see app.js attackFx). */
.attack-fx {
  --fx-size: calc(var(--tile-size) * 0.8);
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--fx-size);
  height: var(--fx-size);
  pointer-events: none;
  z-index: 5;
  /* transform (translate + rotate) is set inline by the hook. */
}
/* Ranged reads a touch smaller than a melee swing (tunable). A themed strip
   takes the same smaller box: it plays one strip at any distance, so it has no
   melee/ranged split to size against — `.attack-fx-themed` is what says so. */
.attack-fx-ranged,
.attack-fx-themed {
  --fx-size: calc(var(--tile-size) * 0.64);
}
/* The visual: a 9-frame horizontal sprite strip. `background-size: auto 100%`
   scales the strip so each square frame fills the box; `attack-fx-play` steps
   the strip one frame-width (`--fx-size`) at a time, and `attack-fx-fade`
   handles the show-hide envelope. */
.attack-fx-frames {
  display: block;
  width: 100%;
  height: 100%;
  background-repeat: no-repeat;
  background-size: auto 100%;
  background-position: 0 0;
  image-rendering: pixelated;
  filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.35));
  animation:
    attack-fx-play 0.72s steps(9) forwards,
    attack-fx-fade 0.72s ease forwards;
}
/* An UNSTYLED attacker's sheet is its damage type x range — the only place the
   melee/ranged split is real. Everything below is a themed style, which plays
   one strip at any distance and so hangs off `.attack-fx-themed` instead. (The
   FX_STYLES key and the PNG both still read `-ranged`: that pair names the art
   file, not the selector.) */
.attack-fx-physical.attack-fx-melee .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-physical-melee-ac54aee4cfa8e4ce6c5bcbab0605e2a5.png?vsn=d");
}
.attack-fx-physical.attack-fx-ranged .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-physical-ranged-5eebc52d04e3aa0148de4a4b054f9854.png?vsn=d");
}
.attack-fx-magic.attack-fx-melee .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-magic-melee-2d5ab32e4a5f4ba535e59d844ffaaaef.png?vsn=d");
}
.attack-fx-magic.attack-fx-ranged .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-magic-ranged-406a8726267cc919ae647f9f8ac3d220.png?vsn=d");
}
/* Brother Fulmen's lightning style: a themed primary bolt, plus the connecting
   arc the chain rider draws between struck enemies (see app.js chainFx). Both
   are 576x64 nine-frame strips like the other attack-fx art. */
.attack-fx-lightning.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-lightning-ranged-064865cc762926b6184eefdf0ae878c8.png?vsn=d");
}
.attack-fx-lightning.attack-fx-arc .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-lightning-arc-aca07761f03e8dd765811cc2f9699722.png?vsn=d");
}
/* Magnus the Necromancer's necrotic style: a themed primary bolt of violet-green
   void magic, withering to ash on impact. Single-target (no chain rider), so no
   arc strip — just the 576x64 nine-frame primary, like the other attack-fx art. */
.attack-fx-necrotic.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-necrotic-ranged-624481c1c9a04e8575970430a3e08907.png?vsn=d");
}
/* Mogra the Witch's witchfire style: a themed primary bolt of bilious green-sulfur
   plague-flame, splattering to corrosive cinders on impact. Single-target basic
   attack (her corpse-explosion is a separate ability), so just the 576x64
   nine-frame primary, like the other attack-fx art. */
.attack-fx-witchfire.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-witchfire-ranged-d8e9114f63bed653b353483bb89c6916.png?vsn=d");
}
/* Zug's crossbow style: not energy but a solid projectile — a heavy iron quarrel
   that streaks point-first at the target and bites in with a red blood-splatter
   on impact. The first non-magic caster style; same 576x64 nine-frame strip. */
.attack-fx-bolt.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-bolt-ranged-600c7d319dd36b49b9db290a847626d5.png?vsn=d");
}
/* Cassian's arrow style: a blessed longbow arrow that streaks point-first at the
   target and bursts in a radiant golden light-flash (not blood — the Crusader's
   righteous counterpart to Zug's brutal bolt). Traveling projectile like bolt;
   same 5-flight + 4-impact strip layout. */
.attack-fx-arrow.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-arrow-ranged-6a65e5b86ec53905903587dfcf61c833.png?vsn=d");
}
/* Pandora's barb style: a barbed vampiric arrow that streaks point-first at the
   target and bites in a crimson blood-splatter — then her tether reels the
   impaled target one tile toward her (see app.js pullFx). Traveling projectile
   like bolt/arrow; same 5-flight + 4-impact strip layout. */
.attack-fx-barb.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-barb-ranged-145597bd952bae2cf1733884fb836a8d.png?vsn=d");
}
/* Gunner's iron slug and Grenadier's lobbed bomb — both traveling projectiles
   like bolt/arrow, same 5-flight + 4-impact strip layout. Slug bursts in a grey
   dust-and-spark puff; grenade in a fiery explosion. */
.attack-fx-slug.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-slug-ranged-2d3be60b0536aebd539244381f0a6167.png?vsn=d");
}
.attack-fx-grenade.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-grenade-ranged-61f09082e5a9d1eeb94bbf87cd2f870c.png?vsn=d");
}
/* Bastion's cleave — a steel slash arc that sweeps through the target. A melee
   style, so it plays in place at the strike (not a traveling projectile). Runs
   faster than the shared 0.72s attack strip so the blade snaps through rather
   than drifting; only the duration is overridden (name/timing/fill inherit). */
.attack-fx-cleave.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-cleave-ranged-b3e14f55e086d806c80aeafb4028783a.png?vsn=d");
  animation-duration: 0.42s;
}
/* Silas's shadowblade — a vertical slash stands, a horizontal crosses it, then
   the cross morphs into a gold crucifix that glows and vanishes. Melee style
   (plays in place, target-biased). A hair slower than the cleave so the three
   beats read. */
.attack-fx-shadowblade.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-shadowblade-ranged-2910a760028cb7ddef078398c455dc7a.png?vsn=d");
  animation-duration: 0.52s;
}
/* Vlad's bloodarc — a wet crimson slash arc sweeps through the target (grows
   head-first, holds, then fades and spreads), a gorier cousin of Bastion's
   steel cleave. Melee style (plays in place, target-biased). Snappy, like the
   cleave, so the slash lands hard. */
.attack-fx-bloodarc.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-bloodarc-ranged-8dc83db05c6aaa1cc775246649c59b26.png?vsn=d");
  animation-duration: 0.46s;
}
/* Claudia's clawrake — three glowing crimson blood-magic claw gashes rake across
   the target (drawn head-first, brief hold, then fade + spread). Melee style
   (plays in place, target-biased). Fast, like an assassin's swipe. */
.attack-fx-clawrake.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-clawrake-ranged-2e2e5690d70fbfbbd3da56b46b9d3486.png?vsn=d");
  animation-duration: 0.42s;
}
/* Grokk's maul — a brutal execute smash: a bright core bursts into a radial
   spray of blood and bone shards, then fades and spreads. Melee style (plays in
   place, target-biased), direction-agnostic. A hair slower/heavier so the blow
   lands like a killing hit. */
.attack-fx-maul.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-maul-ranged-99e8577c56e4efd07c490cda54c433c3.png?vsn=d");
  animation-duration: 0.5s;
}
/* Ugthar's axe — a heavy orc axe chop: the axe head swings down through the
   target trailing a steel slash-arc, a white impact flash on contact, then
   follow-through fade. Melee style (plays in place, target-biased). */
.attack-fx-axe.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-axe-ranged-1e08bd9c08528c83582fb734501a8562.png?vsn=d");
  animation-duration: 0.36s;
}
/* The Annihilator's arcane detonation — a purple blast erupts on the target
   (seated there even at range via DETONATE_FX) and, through the splash rider,
   on each neighbour caught in the blast: a room-clearing bomb. Direction-
   agnostic; a beefy detonation so it lands with weight. */
/* The lava wave / dust splash read bigger than a bolt — they're a ground eruption
   that should fill the struck cell, so they override the smaller themed box.
   (Same specificity as .attack-fx-themed but later in source, so this wins.) */
.attack-fx-annihilate,
.attack-fx-rubble {
  --fx-size: calc(var(--tile-size) * 0.85);
}
.attack-fx-annihilate.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-annihilate-ranged-5f74c2656994489850cd347d6ec63cdb.png?vsn=d");
  animation-duration: 0.5s;
}
/* The Annihilator's splash — a rolling cloud of dust and rubble (no lava) flows
   out from the impact corner on each neighbour caught in the blast, distinct
   from his own lava-wave strike. Same flow + timing as the wave. */
.attack-fx-rubble.attack-fx-themed .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-rubble-ranged-d5086aa96464fc5988016bb433ea2eb4.png?vsn=d");
  animation-duration: 0.5s;
}
/* Pandora's reel-in (app.js pullFx), on the flat #board-fx overlay. The tether is
   a thin crimson cord from the shooter to the impaled target; JS anchors it at the
   shooter (transform-origin left-centre) and scales it down as the target is
   dragged in. The ghost is a clone of the target's 48px sprite sliding one tile
   toward her while the real piece is hidden. */
.pull-tether {
  position: absolute;
  height: 3px;
  border-radius: 2px;
  transform-origin: 0 50%;
  pointer-events: none;
  z-index: 3;
}
.pull-tether-barb {
  background: linear-gradient(90deg, rgba(110, 6, 18, 0.15), rgba(190, 22, 42, 0.95));
  box-shadow: 0 0 4px rgba(200, 30, 50, 0.6);
}
.pull-yank-ghost {
  position: absolute;
  pointer-events: none;
  z-index: 4;
}

/* Heal-action animation: an oriented sprite fired from the healer toward the
   healed cell — green motes for a heal, a golden beam for a resurrection —
   layered over the tile's existing heal/revive pulse. Same oriented-sprite
   mechanism as .attack-fx (see app.js spawnOrientedFx); reuses the shared
   attack-fx-play / attack-fx-fade keyframes. Single size (no melee/ranged
   split). Only plays for a character :heal action, not a cast spell. */
.heal-fx {
  --fx-size: calc(var(--tile-size) * 0.8);
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--fx-size);
  height: var(--fx-size);
  pointer-events: none;
  z-index: 5;
  /* transform (translate + rotate) is set inline by the hook. */
}
.heal-fx-frames {
  display: block;
  width: 100%;
  height: 100%;
  background-repeat: no-repeat;
  background-size: auto 100%;
  background-position: 0 0;
  image-rendering: pixelated;
  filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.35));
  animation:
    attack-fx-play 0.72s steps(9) forwards,
    attack-fx-fade 0.72s ease forwards;
}
.heal-fx-heal .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-motes-55f7447d0c0e94ee43d204f2d7d9156c.png?vsn=d");
}
.heal-fx-revive .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-revive-f3146bbdea8d497de3512d12c16bf1ec.png?vsn=d");
}
/* Mercy's holy mending: a radiant gold-white burst / light-lance, replacing the
   generic green motes and plain golden beam for the Crusader cleric. */
.heal-fx-holy-heal .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-holy-heal-9e13439337c9233f1bbedf34fe6439e3.png?vsn=d");
}
.heal-fx-holy-revive .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-holy-revive-67188e9cc301f1852bb0f14522daaf44.png?vsn=d");
}
/* Maharet's sanguine mending: a crimson blood bloom / blood-geyser lance, for
   the Vampire cleric who heals through the bloodline. */
.heal-fx-sanguine-heal .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-sanguine-heal-591bcea045f31ab66cdde73aadafcef2.png?vsn=d");
}
.heal-fx-sanguine-revive .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-sanguine-revive-5743476cd97cca56f5dbbb3a635e4e3a.png?vsn=d");
}
/* Bogrot's spirit mending: a green ancestral spirit that streaks to the ally /
   an emerald spirit-burst revive, for the Orc shaman. */
.heal-fx-spirit-heal .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-spirit-heal-3b264fe313d0234078165f223128bbbf.png?vsn=d");
}
.heal-fx-spirit-revive .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-spirit-revive-d6dd0abd98aefc9087e5ce6378945888.png?vsn=d");
}
/* Thane's forge mending: molten runes and forge-embers knit the wound / a
   struck-anvil burst of forge-light raises the fallen, for the Dwarf
   healer-fighter — a smith's rite, not a caster's. */
.heal-fx-forge-heal .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-forge-heal-e55713d71339f6dfb5892201b5435b07.png?vsn=d");
}
.heal-fx-forge-revive .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-forge-revive-9d7e68e527465b42fcfb913cd017ca97.png?vsn=d");
}
@keyframes attack-fx-play {
  from {
    background-position: 0 0;
  }
  to {
    background-position: calc(var(--fx-size) * -9) 0;
  }
}
@keyframes attack-fx-fade {
  0% {
    opacity: 0;
  }
  12% {
    opacity: 1;
  }
  75% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
/* Traveling-projectile archer FX (see app.js projectileFx). The strip is split:
   frames 0-4 are the flight loop, frames 5-8 the impact. The wrapper's cross-gap
   travel is driven by the Web Animations API in JS (the distance is dynamic);
   these keyframes only step the sprite sheet within each phase. Durations are set
   inline from the JS PROJECTILE_FX table so the two never drift. */
@keyframes attack-fx-flight {
  from {
    background-position: 0 0;
  }
  to {
    background-position: calc(var(--fx-size) * -5) 0;
  }
}
@keyframes attack-fx-impact {
  from {
    background-position: calc(var(--fx-size) * -5) 0;
  }
  to {
    background-position: calc(var(--fx-size) * -9) 0;
  }
}
/* Flight fades in fast (no hard pop at the shooter); impact starts solid (the
   bolt has already arrived) and fades out as the splatter settles. */
@keyframes attack-fx-flight-fade {
  0% {
    opacity: 0;
  }
  25% {
    opacity: 1;
  }
  100% {
    opacity: 1;
  }
}
@keyframes attack-fx-impact-fade {
  0% {
    opacity: 1;
  }
  65% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/* Floating combat number — the HP a hit applied, spawned over the struck cell
   by the CombatFx hook and removed once it has risen and faded. Damage red,
   heal green (the same intent palette as the attack/heal outlines). */
.fx-number {
  position: absolute;
  left: 50%;
  top: 32%;
  transform: translate(-50%, -50%);
  font-weight: 700;
  font-size: calc(var(--tile-size) * 0.34);
  line-height: 1;
  pointer-events: none;
  z-index: 5;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.7);
  opacity: 0;
  animation: fx-number-rise 1s ease-out forwards;
}
.fx-number-damage { color: var(--red); }
.fx-number-heal { color: var(--green); }
@keyframes fx-number-rise {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(0.7); }
  20% { opacity: 1; transform: translate(-50%, -80%) scale(1); }
  75% { opacity: 1; transform: translate(-50%, -120%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -150%) scale(1); }
}

/* Reduced motion: keep the color meaning, drop the movement. */
@media (prefers-reduced-motion: reduce) {
  .tile.fx-actor { animation: fx-tint-gold 0.6s ease; }
  .tile.fx-damage { animation: fx-tint-red 0.6s ease; }
  .tile.fx-heal { animation: fx-tint-green 0.6s ease; }
  .tile.fx-shield { animation: fx-tint-cyan 0.6s ease; }
  .tile.fx-revive { animation: fx-tint-gold 0.6s ease; }
  .tile.fx-death .piece.corpse { animation: none; }
  .tile.fx-raise .piece.unit { animation: none; }
  .spell-fx { animation: spell-fx-still 0.9s ease forwards; }
  .fx-number { animation: fx-number-still 1s ease forwards; }
  .hp-fill { transition: none; }
  /* Hold one representative frame (index 1, the full effect) and fade it in
     place — no frame-stepping, no scale. */
  .attack-fx-frames,
  .heal-fx-frames {
    background-position: calc(var(--fx-size) * -1) 0;
    animation: attack-fx-still 0.6s ease-out forwards;
  }
  @keyframes attack-fx-still {
    0%, 100% { opacity: 0; }
    30%, 70% { opacity: 1; }
  }
}
/* Reduced-motion spell flash: the icon fades in place, no stamp or bloom. */
@keyframes spell-fx-still {
  0%, 100% { opacity: 0; }
  20%, 70% { opacity: 1; }
}
/* Reduced-motion floating number: fades in place, no upward drift. */
@keyframes fx-number-still {
  0%, 100% { opacity: 0; transform: translate(-50%, -50%); }
  20%, 70% { opacity: 1; transform: translate(-50%, -50%); }
}
@keyframes fx-tint-red { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 50% { box-shadow: 0 0 0 3px var(--red); } }
@keyframes fx-tint-green { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 50% { box-shadow: 0 0 0 3px var(--green); } }
@keyframes fx-tint-cyan { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 50% { box-shadow: 0 0 0 3px var(--cyan); } }
@keyframes fx-tint-gold { 0%, 100% { box-shadow: 0 0 0 0 transparent; } 50% { box-shadow: 0 0 0 3px var(--gold); } }

.control {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0.7;
  pointer-events: none;
}
/* Sized as a fraction of the tile so the modifier scales with the board at
   every breakpoint (and the fixed-size codex preview) — roughly double the
   glyph's previous 22px-on-a-72px-tile footprint. */
.control .icon { width: 60%; height: 60%; }
.control-power .icon { color: var(--icon-power); }
.control-defense .icon { color: var(--icon-defense); }
.control-assault .icon { color: var(--icon-attack); }
/* Spire flank tiles: the army-wide power (red) and fortify (blue) boosts,
   tinted to match the tower's sockets so the tile reads as the bonus it grants. */
.control-spire_attack .icon { color: var(--icon-attack); }
.control-spire_defense .icon { color: color-mix(in srgb, var(--blue) 65%, var(--tile)); }

.deploy {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--icon-spawn);
  opacity: 0.7;
  pointer-events: none;
}
.deploy .icon { width: 55%; height: 55%; }

.teleport {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--icon-teleport);
  opacity: 0.7;
  pointer-events: none;
}
.teleport .icon { width: 60%; height: 60%; }

/* Engraved relief: shadow toward the top-right, highlight toward the
   bottom-left, so each glyph reads as carved into the tile. */
.control .icon,
.deploy .icon {
  filter:
    drop-shadow(0.5px -0.5px 0 rgba(0, 0, 0, 0.55))
    drop-shadow(-0.5px 0.5px 0 rgba(255, 255, 255, 0.3));
}
.deploy-up { transform: rotate(-90deg); }
.deploy-down { transform: rotate(90deg); }

.piece {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  z-index: 2;
  width: 80%;
}

/* Persistent "shielded" overlay: a translucent crystalline orb for any unit or
   crystal that holds a :shield absorb pool. It sits BEHIND its target — a ward
   the character stands in front of — via z-index:-1 inside the piece's own
   stacking context (still above the tile floor). `.piece` is centred both axes
   in the square tile, so its centre is the cell centre; anchoring the orb there
   with the translate keeps it cell-centred, and it billboards with the piece in
   the tilted view. Sized to within a couple of pixels of the whole cell. Tune
   the opacity below to taste. */
.piece-shield {
  position: absolute;
  z-index: -1;
  top: 50%;
  left: 50%;
  width: calc(var(--tile-size) - 4px);
  height: calc(var(--tile-size) - 4px);
  transform: translate(-50%, -50%);
  background: url("/images/sprites/shield-orb-e8feb9dc0b09221969fac6b1a1a54ef0.png?vsn=d") center / contain no-repeat;
  image-rendering: pixelated;
  opacity: 0.29; /* reduced-motion fallback; the pulse below drives it otherwise */
  pointer-events: none;
  animation: shield-pulse 2.4s ease-in-out infinite;
}
@keyframes shield-pulse {
  0%, 100% { opacity: 0.2; }
  50% { opacity: 0.38; }
}
@media (prefers-reduced-motion: reduce) {
  .piece-shield { animation: none; }
}

/* Army-wide Spire boost aura: when a side holds a flank tile every one of its
   units is empowered, not just the tower — so each affected unit's sprite gets
   a soft coloured glow (red = attack power, blue = fortify). A `drop-shadow`
   filter, not a transform, so it stays correct on billboarded 3/4 pieces; a
   slow pulse reads it as a live buff rather than a static outline. */
.piece.unit.aura-attack .sprite {
  filter: drop-shadow(0 0 2px var(--red)) drop-shadow(0 0 4px var(--red));
  animation: unit-aura 1.6s ease-in-out infinite;
}
.piece.unit.aura-defense .sprite {
  filter: drop-shadow(0 0 2px var(--blue)) drop-shadow(0 0 4px var(--blue));
  animation: unit-aura 1.6s ease-in-out infinite;
}
.piece.unit.aura-attack.aura-defense .sprite {
  filter: drop-shadow(0 0 3px var(--red)) drop-shadow(0 0 3px var(--blue));
  animation: unit-aura 1.6s ease-in-out infinite;
}
@keyframes unit-aura {
  0%, 100% { opacity: 0.85; }
  50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .piece.unit.aura-attack .sprite,
  .piece.unit.aura-defense .sprite,
  .piece.unit.aura-attack.aura-defense .sprite { animation: none; }
}

/* ---- 3/4 tilted board view --------------------------------------------- */
/* Opt-in via #board-scene.view-tilted, toggled by the Settings "3/4 view"
   control and persisted per game in localStorage (see app.js window.BoardView).
   The default board is flat top-down; everything below only applies when the
   scene carries view-tilted. */
.board-scene.view-tilted {
  position: relative;
  /* Reserve the projected height plus the riser strip below the near edge; the
     tilted board is bottom-anchored and its receding top spills upward as
     visible overflow into freed headroom. The +riser term flows the hand down. */
  height: calc(var(--board-height) * var(--board-squash) + var(--board-riser-h));
  perspective: var(--board-perspective);
  /* Break the tilted scene out to the full viewport width (at every breakpoint)
     so its clip-path clips at the screen edge, not the narrower board column,
     then scale the board up so it (nearly) fills that width. Small-responsive
     and desktop now share ONE size/position — the values below were tuned on a
     phone but the board inside is a fixed pixel size (capped tile-size), so it
     stays viewport-centered and simply reads bigger on desktop too. The extra
     size also lifts the receding top back up toward where the flat board sits. */
  width: 100vw;
  margin-left: calc(50% - 50vw);
  /* X fills the width WITHOUT crossing the screen edge (the clip cuts flush at
     100vw, so a board scaled past it loses its outermost band — exactly where
     the edge column's selection/target highlight lives). Y lifts the top back
     up toward the flat position but shy of the player bars. Tune independently. */
  --board-tilt-fill-x: 1.1;
  --board-tilt-fill-y: 1.12;
  /* Stand pieces upright off this scene's tilted floor. See the variable's
     definition in :root for why every .piece keyframe has to compose it. */
  --piece-billboard: rotateX(calc(-1 * var(--board-tilt)));
  /* Clips the fill-scaled near edge out the bottom (and the widened corners out
     the sides, now that the board scales to the screen edge) while leaving the
     top open so far-piece heads are never cropped. Negative top inset = do not
     clip above. */
  clip-path: inset(-9999px 0 0 0);
}
.view-tilted .board {
  position: absolute; /* out of flow so the scene's height reclaims space */
  bottom: var(--board-riser-h); /* lift off the bottom to seat the depth riser */
  left: 50%;
  transform-style: preserve-3d;
  /* --board-tilt-fill-x / -y (default 1 = unchanged) scale the flat board about
     its bottom-center BEFORE the tilt. The near/bottom edge is fixed, so X>1
     widens the board (its bottom corners spill toward the screen edge) while Y>1
     lifts the receding top independently. Phones raise both (see the 480px
     block): X to fill the width, Y to buy back the headroom the tilt's squash
     costs at the top. Split so width and top height tune separately. */
  transform: translateX(-50%) rotateX(var(--board-tilt))
    scale(var(--board-tilt-fill-x, 1), var(--board-tilt-fill-y, 1));
  transform-origin: center bottom; /* near edge fixed, far edge recedes/narrows */
}
.view-tilted .tile {
  transform-style: preserve-3d; /* so pieces can counter-rotate in 3D */
  /* Lift each tile a hair toward the viewer so it sits above the board's
     coplanar background plane; without this the board surface composites over
     an empty tile's center and swallows deploy/move clicks. */
  transform: translateZ(1px);
}
.view-tilted .piece {
  /* Resting billboard. Animated pieces compose the same value themselves. */
  transform: var(--piece-billboard);
  transform-origin: center bottom;
}
.view-tilted .tile.has-piece::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 10%;
  width: 68%;
  height: 30%;
  transform: translateX(-50%);
  background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0) 70%);
  pointer-events: none;
  z-index: 1;
}

/* Floor-depth riser — the near row's tiles extruded toward the viewer so the
   board reads as a slab with thickness. Flat mode hides it. */
.board-riser { display: none; }
/* A 5-column grid mirroring the board (same track sizes, gap, grout color, and
   frame) so each face lines up under its tile. Hinged at the board's near edge
   (top) and counter-rotated by the tilt so it stands upright as a front wall
   instead of lying in the floor plane. left/right:-3px reach outside the board's
   border so the riser frame aligns with the board frame. The frame + grout are
   painted by a gradient background showing through the 3px padding and gaps
   (rather than a solid border, which can't fade): it runs from the board's grout
   color at the top to a deep shadow at the bottom, so the in-between seams darken
   downward and the bottom edge is darkest. */
.view-tilted .board-riser {
  display: grid;
  grid-template-columns: repeat(5, var(--tile-size));
  gap: 3px;
  position: absolute;
  left: -3px;
  right: -3px;
  top: 100%;
  height: var(--board-riser-h);
  padding: 0 3px 3px;
  background: linear-gradient(
    to bottom,
    var(--line) 0%,
    color-mix(in srgb, var(--line) 32%, #000) 100%
  );
  border-radius: 0 0 6px 6px;
  /* Trapezoid taper: bottom two corners pull in by --board-riser-taper while the
     top stays flush with the board edge, so the front wall appears to recede. */
  clip-path: polygon(
    0 0,
    100% 0,
    calc(100% - var(--board-riser-taper)) 100%,
    var(--board-riser-taper) 100%
  );
  transform-origin: center top;
  transform: rotateX(calc(-1 * var(--board-tilt)));
}
/* Each face is a shade of its tile darkened into shadow, deepening toward the
   lower-right — the light comes from the upper left, so this front wall falls
   away from it. */
.board-riser-face {
  background: linear-gradient(
    to bottom right,
    color-mix(in srgb, var(--tile) 52%, #000) 0%,
    color-mix(in srgb, var(--tile) 26%, #000) 100%
  );
}

.sprite {
  width: 48px;
  height: 48px;
  display: inline-block;
  background-repeat: no-repeat;
  background-position-x: center;
  /* Hero portraits are 128×1024 vertical 8-direction strips: size the image so
     one 128px cell fills the square container, and default to the South
     (front-facing) cell — the pose hand cards, the codex, and dev pages want.
     Board units override background-position-y per `.face-<dir>` below;
     single-frame sprites (crystals, rubble, gravestones) opt back out. */
  background-size: 100% 800%;
  background-position-y: 0;
  image-rendering: pixelated;
}

.tile .sprite {
  width: calc(var(--tile-size) * 0.78);
  height: calc(var(--tile-size) * 0.78);
}

/* Every roster hero now ships as a high-res 128px strip (smooth cel-shaded art,
   not pixel art), so they downscale smoothly rather than nearest-neighbor. The
   base `.sprite` rule above still defaults to `image-rendering: pixelated`, which
   now applies only to the single-frame board art (crystals, rubble, gravestones).
   Same 100% 800% strip / face-<dir> machinery — only the filter differs. */
.sprite-bastion,
.sprite-cassian,
.sprite-fulmen,
.sprite-mercy,
.sprite-silas,
.sprite-vlad,
.sprite-pandora,
.sprite-magnus,
.sprite-maharet,
.sprite-claudia,
.sprite-grokk,
.sprite-zug,
.sprite-mogra,
.sprite-bogrot,
.sprite-ugthar,
.sprite-thane,
.sprite-gunner,
.sprite-grenadier,
.sprite-engineer,
.sprite-annihilator {
  image-rendering: auto;
}

/* Single-frame sprites are NOT strips — fit the whole image as before. */
.sprite-crystal,
.sprite-crystal-dmg1,
.sprite-crystal-dmg2,
.sprite-crystal-dmg3,
.sprite-crystal-rubble,
.piece.corpse .sprite {
  background-size: contain;
  background-position: center;
}

/* 8-direction facing: pick the strip cell for a board unit's screen-facing.
   Strip order is CCW from South (matches Rules.Facing.all/0 and the art built
   by bin/build_dir_strips.py): s0 se1 e2 ne3 n4 nw5 w6 sw7. Cell i sits at
   background-position-y i/7 · 100% (8 cells → 7 gaps). `face-<dir>` is the
   per-viewer screen compass (BoardLayout.screen_facing/2), so no extra
   mirroring is needed here. */
.sprite.face-s  { background-position-y: 0; }
.sprite.face-se { background-position-y: calc(100% * 1 / 7); }
.sprite.face-e  { background-position-y: calc(100% * 2 / 7); }
.sprite.face-ne { background-position-y: calc(100% * 3 / 7); }
.sprite.face-n  { background-position-y: calc(100% * 4 / 7); }
.sprite.face-nw { background-position-y: calc(100% * 5 / 7); }
.sprite.face-w  { background-position-y: calc(100% * 6 / 7); }
.sprite.face-sw { background-position-y: calc(100% * 7 / 7); }

/* Flat view only: characters always face forward — collapse the 8 directions to
   SW / S / SE so a unit never shows its back. Tilted 3/4 view keeps full facing
   (it adds .view-tilted, which drops these overrides). S/SE/SW already resolve to
   the right cells above, so only N, E/NE, and W/NW need redirecting.
   Option A: lean by horizontal component — S,N→S; SE,E,NE→SE; SW,W,NW→SW. */
.board-scene:not(.view-tilted) .sprite.face-n  { background-position-y: 0; }
.board-scene:not(.view-tilted) .sprite.face-e,
.board-scene:not(.view-tilted) .sprite.face-ne { background-position-y: calc(100% * 1 / 7); }
.board-scene:not(.view-tilted) .sprite.face-w,
.board-scene:not(.view-tilted) .sprite.face-nw { background-position-y: calc(100% * 7 / 7); }

.side-red .sprite-crystal { background-image: url("/images/sprites/crystal_red-caad48913f9dbdcf9372890df6f12058.png?vsn=d"); }
.side-blue .sprite-crystal { background-image: url("/images/sprites/crystal_blue-0c6fa962f9b63d251122df4ac9a2a2e1.png?vsn=d"); }
/* Crystal damage tiers by remaining health: dmg1 ≥50%, dmg2 ≥25%, dmg3 <25%,
   rubble at 0% (the crystal is destroyed and the tile becomes walkable). */
.side-red .sprite-crystal-dmg1 { background-image: url("/images/sprites/crystal_dmg1_red-c18ca86c86d8669ea94e063459c7766b.png?vsn=d"); }
.side-blue .sprite-crystal-dmg1 { background-image: url("/images/sprites/crystal_dmg1_blue-35e97184cc07e0f422edeb763009acf2.png?vsn=d"); }
.side-red .sprite-crystal-dmg2 { background-image: url("/images/sprites/crystal_dmg2_red-c95bc04eccb57371f5946ec1a3e4933c.png?vsn=d"); }
.side-blue .sprite-crystal-dmg2 { background-image: url("/images/sprites/crystal_dmg2_blue-c48ea363151675cab2538814ac53ad2c.png?vsn=d"); }
.side-red .sprite-crystal-dmg3 { background-image: url("/images/sprites/crystal_dmg3_red-3a9999afde6a458a64bf44d2d436894d.png?vsn=d"); }
.side-blue .sprite-crystal-dmg3 { background-image: url("/images/sprites/crystal_dmg3_blue-1a4968c7a5ebb87c7220dd0d2f88dd83.png?vsn=d"); }
.side-red .sprite-crystal-rubble { background-image: url("/images/sprites/crystal_rubble_red-71f08f463f655bc38fd4220b64578e89.png?vsn=d"); }
.side-blue .sprite-crystal-rubble { background-image: url("/images/sprites/crystal_rubble_blue-60143add43fa386c06abf337c4106b4e.png?vsn=d"); }

/* Death marker: a corpse renders as a team-tinted gravestone instead of the
   living hero tipped sideways. Keyed on `.piece.corpse` (specificity 0,4,0 beats
   each `.sprite-<hero>.side-*` at 0,2,0) so the CombatFx death-beat stand-in — a
   clone with the `corpse` class stripped — still shows the living hero while the
   strike lands, then collapses into the gravestone via `fx-death`. The inspector
   uses `.char-portrait.dead` (a different selector) and keeps its dimmed hero. */
.piece.corpse .sprite.side-blue { background-image: url("/images/sprites/gravestone_blue-07db403e6b8646e4da2e9ed667f2757b.png?vsn=d"); }
.piece.corpse .sprite.side-red { background-image: url("/images/sprites/gravestone_red-2f40572dca52ee90ebbab5185f003c9e.png?vsn=d"); }

.hp-bar {
  position: relative; /* keep the bar (and later-in-DOM badges) above .piece-shield */
  width: 100%;
  height: 5px;
  margin-top: 3px;
  background: rgba(0, 0, 0, 0.6);
  border-radius: 2px;
  overflow: hidden;
}

/* Ease the fill toward its new width, delayed so the drop/rise lands with the
   combat pulse's impact beat (~500ms) rather than snapping the instant the
   authoritative state applies. */
.hp-fill {
  display: block;
  height: 100%;
  background: var(--green);
  transition: width 0.45s ease-out 0.5s;
}

/* Color health bars by team so they match the crystal colors. */
.side-red .hp-fill { background: var(--red); }
.side-blue .hp-fill { background: var(--blue); }

/* Equipped-item icon row: anchored to the tile's top-right corner (`.piece`
   is already position: relative) so it never competes with the HP bar below
   the sprite. Same markup for both sides' pieces. */
.equip-badges {
  position: absolute;
  top: 0;
  right: 0;
  display: flex;
  gap: 1px;
}

.equip-badges .icon {
  width: 11px;
  height: 11px;
  color: var(--card-color, var(--gold));
  filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.8));
}

/* Status badges — transient buffs (Scroll's surge, War Feast's empower)
   mirror the equip badges on the opposite corner, so a queued power spike is
   public board information just like carried gear. */
.status-badges {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  gap: 1px;
}

.status-badges .icon {
  width: 11px;
  height: 11px;
  color: var(--card-color, var(--gold));
  filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.8));
}

/* ---------- Tray: one pill ---------- */

/* The whole control surface is a single glass "pill": an AP pie end-cap, the
   hand cards, and an action end-cap. --h is the pill height; the caps are true
   semicircles of width --h/2 so they align with the pill's rounded ends and the
   pie wedges stay round. */
.tray {
  /* 0.6rem wider than the board on each side, so the pill's end caps overhang
     the board edges (like the header's scroll endcaps — see .plate-endcap). The
     extra width also lets the hand cards breathe. The −0.6rem side margins cancel
     that added width in the flow box, so the parent `.game` (max-width:
     max-content) stays board-width and the bar straddles the board symmetrically
     instead of spilling only rightward. */
  /* The turn-button (right .act-cap) is a bullet nose wider than a plain --h/2
     semicircle; --act-cap-extra is the amount it grows beyond that old 30px. We
     add it to the pill width so the nose gets its own room instead of stealing
     from the hand cards. The negative side margins stay symmetric (half the extra
     each side) so the whole bar stays centred under the board — see the base
     comment above. */
  --act-cap-extra: 11.2px;
  width: calc(var(--board-width) + 1.2rem + var(--act-cap-extra));
  margin: 0.5rem calc(-0.6rem - var(--act-cap-extra) / 2) 0;
  /* The HandInspect hook long-presses cards to inspect them; mirror .board-wrap
     so iOS neither selects the glyph/label text nor pops its callout menu. The
     tray doesn't scroll, so suppressing the callout is safe. */
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

.hand {
  --h: 60px;
  /* Right turn-button cap: the old --h/2 (30px) semicircle plus the extra that
     stretches its 999px curve into an elongated bullet nose. --act-cap-extra is
     inherited from .tray. */
  --act-cap-w: calc(var(--h) / 2 + var(--act-cap-extra));
  display: flex;
  align-items: stretch;
  height: var(--h);
  width: 100%;
  max-width: calc(var(--board-width) + 1.2rem + var(--act-cap-extra));
  margin: 0 auto;
  border-radius: 999px;
  /* Concave glass dish, lit from the upper-left. A bowl carved into the surface
     shades opposite to a bump: the near (upper-left) inner wall faces away from
     the light so it falls into shadow, while the far (lower-right) inner wall
     faces the light and catches a bright specular. Kept translucent so the
     frosted backdrop reads through as glass body.

     No `border`: the AP pie forms the pill's rounded left end, and with global
     box-sizing:border-box a 1px border shrinks the pie's inscribed circle 1px
     smaller than the pill's end — so its rounded top peeks out above the pie and
     reads as a clip. The rim is drawn with inset shadows instead, which paint
     below the pie and never clip it. */
  background:
    /* specular catch on the far, lower-right inner wall (paints on top) */
    radial-gradient(
      130% 120% at 80% 92%,
      color-mix(in srgb, white 26%, transparent) 0%,
      transparent 46%
    ),
    /* shadowed near, upper-left inner wall */
    radial-gradient(
      120% 120% at 20% 6%,
      color-mix(in srgb, black 32%, transparent) 0%,
      transparent 52%
    ),
    /* base translucent glass tint, deepening toward lower-right */
    linear-gradient(
      135deg,
      color-mix(in srgb, var(--tile) 16%, transparent) 0%,
      color-mix(in srgb, var(--tile) 42%, transparent) 100%
    );
  /* inset shadows carve the depth and stand in for the rim: a 1px highlight on
     the lit upper-left lip, the interior dropping into shadow beneath it, and
     the far lower-right wall glowing where the light lands */
  box-shadow:
    inset 1px 1px 0 color-mix(in srgb, white 30%, transparent),
    inset 2px 3px 7px rgba(0, 0, 0, 0.4),
    inset -2px -3px 8px color-mix(in srgb, white 15%, transparent);
  /* "Liquid Glass": heavily frost whatever backdrop shows through the dish so
     the sprites read against a fuzzed field. -webkit- for the iOS PWA. */
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  backdrop-filter: blur(16px) saturate(140%);
}

/* --- AP pie end-cap (left) --- */
.ap-cap {
  position: relative;
  flex: 0 0 auto;
  width: calc(var(--h) / 2);
  height: 100%;
  padding: 0;
  border: none;
  background: none;
}
.ap-cap .ap-pie { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* Wedge fills live here, not in a `fill="var(--…)"` presentation attribute:
   var() in a presentation attribute doesn't repaint until a style recalc, so
   the wedges rendered dark until the first LiveView DOM patch. The stroke is
   the bg colour, acting as the slice dividers; the slices' rounded inner tips
   come from the path geometry (see ap_wedge_path/1). */
.ap-pie .ap-wedge { stroke: var(--bg); stroke-width: 3; stroke-linejoin: round; }
.ap-pie .remaining { fill: var(--gold); }
.ap-pie .spent { fill: var(--gold-dim); }
.ap-cap.dim { opacity: 0.4; }
/* On the opponent's turn the right cap becomes the panel-2 ".next" button, so
   in the live hand recolour the passive AP pie into that same panel-2 family (in
   place of gold) and drop the .dim opacity, so both end caps read at matching
   strength. Scoped under .hand so the codex's passive .ap-cap.dim keeps gold. */
.hand .ap-cap.dim { opacity: 1; }
.hand .ap-cap.dim .ap-pie .remaining { fill: var(--panel-2); }
.hand .ap-cap.dim .ap-pie .spent {
  fill: color-mix(in srgb, var(--panel-2) 35%, transparent);
}
.ap-cap.undoable { cursor: pointer; }
/* On your turn the cap is a live AP gauge even with nothing to undo, so opt it
   out of the global `button:disabled` dimming; it's only not-tappable, not
   inactive. The passive opponent-turn dimming is handled by .ap-cap.dim above. */
.ap-cap.undoable:disabled { cursor: default; opacity: 1; }
/* spent wedges brighten on hover/focus so the cap reads as "tap to undo" */
.ap-cap.undoable:not(:disabled):hover .ap-pie .spent,
.ap-cap.undoable:not(:disabled):focus-visible .ap-pie .spent {
  fill: var(--gold-muted);
}

/* --- hand cards (middle) --- */
.hand-cards {
  flex: 1 1 auto;
  display: flex;
  justify-content: space-evenly;   /* fewer cards → more gap between them */
  align-items: center;
  min-width: 0;
  padding: 0 4px;
}

.hand-card {
  flex: 0 0 auto;
  width: 44px;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: 8px;
  padding: 0;
  cursor: pointer;
}
.hand-card .sprite { width: 100%; height: auto; max-width: 44px; aspect-ratio: 1; }
.hand-card .spell-glyph { width: 60%; max-width: 40px; color: var(--gold); }
.hand-card .spell-glyph svg { width: 100%; height: auto; aspect-ratio: 1; }
.hand-card.selected { outline: 3px solid var(--gold); outline-offset: 2px; z-index: 1; }

/* --- action end-cap (right) --- */
.act-cap {
  flex: 0 0 auto;
  /* --act-cap-w > --h/2, so with the right corners' 999px radii clamped to the
     half-height the semicircle stretches into an elongated half-ellipse — the
     "more than a half circle" bullet nose, using the same curve we already had.
     Falls back to --h/2 for reuses (codex) that don't set the pill vars. */
  width: var(--act-cap-w, calc(var(--h) / 2));
  height: 100%;
  border: none;
  /* with border-box the <button>'s default UA padding would eat the content box
     and shrink the %-sized icon to nothing */
  padding: 0;
  border-radius: 0 999px 999px 0;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* filled game-icons already read bold. Width drives the size; aspect-ratio
   derives a square height (the <.icon> <svg> has no viewBox, so a %-based
   `height` has nothing to resolve against — the hand-card glyphs pin it too). */
.act-cap .icon { width: 78%; height: auto; aspect-ratio: 1; }
/* in the live hand the cap is a wide bullet, so pin the icon at a fixed size in
   the body of the nose rather than a % that would balloon with the width. Scoped
   under .hand so the small codex reuse keeps its own %-based sizing. */
.hand .act-cap .icon { width: 26px; }
/* Flat fill (no shading) with a thin dark seam on the cap's straight left edge —
   the same divider the AP pie draws between its wedges (stroke: var(--bg)), so
   the button reads as one more slice of that widget family. The AP stroke-width
   of 3 renders as ~1px (its 0–100 viewBox scales into a 30px cap), so we match
   with a 1px --bg line here. */
.act-cap.submit { background: var(--gold); color: #221a06; }
.act-cap.next { background: var(--panel-2); color: var(--gold); }
.act-cap.submit,
.act-cap.next {
  box-shadow: inset 1px 0 0 var(--bg);
}
/* gentle feedback: brighten the fill on hover/focus, dim slightly on press */
.act-cap:not(:disabled):hover,
.act-cap:not(:disabled):focus-visible {
  filter: brightness(1.06);
}
.act-cap:not(:disabled):active {
  filter: brightness(0.94);
}

/* --- deck count (a non-interactive "virtual card" hugging the right cap) --- */
.deck-cap {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: calc(var(--h) / 2);
  height: 100%;
  color: var(--muted);
  pointer-events: none;
}
.deck-cap .icon { width: 66%; height: auto; aspect-ratio: 1; }
.deck-cap .deck-count {
  font-size: 0.85rem;
  font-weight: 700;
  line-height: 1;
  /* numeral inherits the tent's colour so the two read as one unit */
  color: inherit;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8), 0 0 3px rgba(0, 0, 0, 0.6);
}

/* ---------- Dashboard ---------- */

/* main.container dropped its global padding (#180) so the logged-out brand mark
   could sit flush; the dashboard (and the other three game pages, via
   .game-page; plus the Account page, via .settings) re-add their own inset so
   their rows/fields don't hug a phone's edges. The installed-PWA shell already
   pads the safe area on <body>, so this stays a plain inset to avoid
   double-counting the home indicator. */
.dashboard, .game-page, .settings { padding: 0 1rem 1rem; }

.dash-section { margin-top: 1.5rem; }
.dash-section h2 { color: var(--muted); font-size: 1rem; text-transform: uppercase; letter-spacing: 2px; }

.game-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }

/* The row is the "bar": the game link fills it and the pop-out icon sits on
   the right, inside the border. The whole row is one tap target — the game
   link stretches over it (see .game-card::after) so a mis-tap anywhere in the
   bar still navigates, while the action buttons ride above it (z-index) and
   keep their own taps. `position: relative` anchors that stretched overlay. */
.game-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  background: var(--panel);
  border: 2px solid var(--line);
  border-radius: 6px;
  padding: 0.6rem 1rem;
}

.game-row:hover { border-color: var(--gold); }
.game-row.your-move { border-left: 6px solid var(--gold); }

.game-card {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex: 1;
  min-width: 0;
  text-decoration: none;
  color: var(--text);
}

/* Stretch the link's hit area across the entire row so the whole bar — not
   just the text — is the navigation target. The action buttons sit above it
   via z-index and stay independently clickable. */
.game-row .game-card::after {
  content: "";
  position: absolute;
  inset: 0;
}

/* Ride above the stretched game-card overlay so their own taps win. */
.game-row .popout-btn,
.game-row .share-btn,
.game-row .fav-toggle {
  position: relative;
  z-index: 1;
}

.game-card .turn { color: var(--muted); margin-left: auto; font-size: 13px; }

.dash-empty { color: var(--muted); font-style: italic; margin-top: 1.5rem; }
.dash-truncated { color: var(--muted); font-size: 13px; margin-top: 1.25rem; text-align: center; }
.game-card .icon { color: var(--muted); flex: 0 0 auto; }
.game-card .opponent { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Stretch, not center: every button matches the row's tallest sibling
   (the text buttons) instead of an icon-only button sizing itself to its
   shorter bare-icon content. Each button then re-centers its own content
   on both axes now that its box may be taller than that content. */
.dash-header { display: flex; align-items: stretch; justify-content: center; gap: 1rem; margin-top: 1.5rem; }
/* inline-flex collapses the literal space between each stop's icon and text,
   so restore it with an internal gap (no effect on the icon-only History stop). */
.dash-header .button { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; }
.dash-header .current { color: var(--gold); }
/* Touch devices leave :hover stuck on the last-tapped stop, so the gold
   interaction border from `.button:hover` lingers after navigation until the
   next tap. On no-hover pointers, drop that sticky-hover border and paint the
   gold frame only while the stop is actively pressed (:active) — so it flashes
   on tap but clears the moment the finger lifts, not on focus/blur. */
@media (hover: none) {
  .dash-header .button:hover { border-color: var(--line); }
  .dash-header .button:active { border-color: var(--gold); }
}
/* Just wide enough to frame the icon — narrower than a text button gives
   the whole centered row more breathing margin on the page. */
.dash-header .icon-only { padding-left: 0.6rem; padding-right: 0.6rem; }

/* Onboarding CTA above the dash header nav — same button.primary treatment
   used elsewhere (gold fill), centered like .game via max-width: max-content. */
/* Dashboard opt-in banner for background push notifications; dismissible,
   client-owned Enable action (permission needs a user gesture). */
.push-banner {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 1rem;
  padding: 0.6rem 1rem;
  background: var(--panel);
  border: 2px solid var(--line);
  border-radius: 6px;
}
.push-banner span { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; color: var(--text); }
.push-banner .icon { color: var(--gold); flex: 0 0 auto; }
.push-banner button { padding: 0.3rem 0.75rem; }
.push-banner .banner-primary { background: var(--gold); color: #221a06; border-color: var(--gold); font-weight: bold; }
.push-banner .banner-secondary { color: var(--muted); }

.dialog-layer { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 1rem; }
/* The codex/info modal changes height as you switch tabs or drill into a card.
   Anchor it to the top so the close button and tab nav stay put instead of
   re-centering (and jumping) on every navigation. */
#info-dialog { align-items: flex-start; }
.dialog-underlay { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.6); }
.dialog {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 26rem;
  background: var(--panel);
  border: 2px solid var(--line);
  border-radius: 8px;
  padding: 1.25rem;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.dialog h2 { margin: 0 2rem 0 0; font-size: 1.1rem; }
.share-dialog-desc { margin: 0; color: var(--muted); }
.dialog-close { position: absolute; top: 0.5rem; right: 0.5rem; background: none; border: none; color: var(--muted); padding: 0.25rem 0.5rem; }
.dialog-close:hover { color: var(--text); border-color: transparent; }
.challenge-picker { display: flex; flex-direction: column; gap: 0.3rem; }
.challenge-picker label { margin: 0; }
.challenge-picker-row { display: flex; gap: 0.5rem; }
.challenge-picker-row select { flex: 1; min-width: 0; }
.challenge-picker-empty { opacity: 0.7; font-size: 0.9rem; }

.dialog-action { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; }
.dialog .invite-url { margin: 0; }
.invite-url-row { display: flex; gap: 0.5rem; align-items: stretch; }
.invite-url-row input { flex: 1; min-width: 0; }
.invite-copy { flex: 0 0 auto; padding: 0 0.7rem; font-size: 1.05rem; line-height: 1; }
.invite-action-btn { display: inline-flex; align-items: center; justify-content: center; }
.invite-action-btn .icon { width: 1.1em; height: 1.1em; }
.invite-action-btn.confirming {
  border-color: var(--red);
  color: var(--red);
  background: color-mix(in srgb, var(--red) 18%, var(--panel-2));
}
.invite-action-btn.confirming:hover:not(:disabled) { border-color: var(--red); }
.invite-confirm-hint { margin: 0.15rem 0 0; font-size: 0.85rem; color: var(--red); text-align: right; }

.settings-title {
  margin: 0 0 0.75rem;
  font-size: 1.1rem;
  color: var(--text);
}
.settings-list { list-style: none; margin: 0; padding: 0; }
.settings-list > li { margin: 0.25rem 0; }
.settings-list button {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  padding: 0.6rem 0.75rem;
  background: none;
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--text);
  cursor: pointer;
  font: inherit;
  text-align: left;
}
.settings-list button:hover { border-color: var(--text); }
/* Scoped to beat `.settings-list button`'s colour, which is more specific than
   a bare `.settings-resign` and silently won before. */
.settings-list .settings-resign { color: var(--red); }
/* Loudness slider row — the toggle buttons' framed look, laid out as a label
   plus a range that fills the remaining width. Dimmed while sound is off. */
.settings-volume {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: 6px;
}
.settings-volume input[type="range"] {
  flex: 1;
  accent-color: var(--gold);
  cursor: pointer;
}
.settings-volume input[type="range"]:disabled { opacity: 0.4; cursor: default; }
.settings-confirm {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--gold);
  border-radius: 6px;
}
.settings-confirm-text {
  color: var(--gold);
  text-align: center;
}
.settings-confirm-actions {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
}
.settings-confirm-actions button { width: auto; justify-content: center; text-align: center; }

/* The install explainer is a native <dialog> (Settings page is not a LiveView,
   so it can't use the phx-driven .dialog-layer). Strip the element's default
   chrome and lean on the inner .dialog card; ::backdrop replaces .dialog-underlay. */
dialog#install-dialog {
  width: min(26rem, calc(100vw - 2rem));
  padding: 0;
  border: none;
  background: transparent;
  color: var(--text);
}
dialog#install-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.install-steps { margin: 0; color: var(--muted); }
.install-steps strong { color: var(--text); }

/* ---------- Army step (two-step new game / join) ---------- */

.new-game-page { max-width: 30rem; margin: 1.5rem auto 0; display: flex; flex-direction: column; gap: 1rem; }

/* The two primary start mechanisms: bigger, equal-width, side-by-side. */
.new-game-primary { display: flex; gap: 0.75rem; }
.new-game-primary-action {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 1.1rem 0.75rem;
  font-size: 1.05rem;
  text-align: center;
}
.new-game-primary-action .icon { width: 1.8em; height: 1.8em; }
.history-page { margin-top: 1.5rem; }

/* --- Public replay (/r/:token) ------------------------------------------- */
/* Standard page inset (mirrors .game-page/.dashboard/.settings) so the title
   and controls don't hug a phone's edges; the board sizes itself within. */
.replay { padding: 0 1rem 1rem; }

.replay__meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.75rem 0;
  margin-bottom: 0.75rem;
  border-bottom: 2px solid var(--line);
}

.replay__players {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-weight: bold;
}

.replay__player { display: flex; flex-direction: column; }
.replay__player--blue { color: var(--blue); }
.replay__player--red { color: var(--red); }
.replay__vs { color: var(--muted); font-weight: normal; font-size: 0.85rem; }
.replay__faction { color: var(--muted); font-weight: normal; }
/* Cap the width one character past the max username length (14 → 15ch) so the
   "<name> wins in <n>" copy wraps just after the longest possible username. */
.replay__result {
  color: var(--gold);
  font-weight: bold;
  max-width: 15ch;
  overflow-wrap: anywhere;
  text-align: right;
}

/* Controls occupy the space where a hand tray would sit on the live board;
   lay them out as a single horizontal row so the scrubber can flex to fill
   the gap between the transport buttons and the turn counter. */
.replay__controls {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-top: 0.75rem;
}
.replay__scrubber { flex: 1; }
.replay__scrubber input[type="range"] { width: 100%; }
.replay__position { color: var(--muted); font-size: 0.85rem; white-space: nowrap; }

.replay-not-found {
  text-align: center;
  padding: 3rem 1rem;
  color: var(--muted);
}
.replay-not-found h1 { color: var(--text); margin-bottom: 0.5rem; }

.army-step { display: flex; flex-direction: column; gap: 0.5rem; }
.army-options { display: flex; flex-direction: column; gap: 0.6rem; }

.army-option {
  display: block;
  width: 100%;
  padding: 0;
  overflow: hidden;
  color: var(--text);
  border: 2px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
  cursor: pointer;
  text-align: left;
}
.army-option:hover { border-color: var(--gold); }
.army-option.selected { border-color: var(--blue); box-shadow: 0 0 0 1px var(--blue); }

.army-option-banner { position: relative; display: block; line-height: 0; }
.army-option-img { display: block; width: 100%; height: auto; }

.army-option-title {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.45rem 0.6rem;
  line-height: 1.1;
  color: #f6f3ec;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0));
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95), 0 0 6px rgba(0, 0, 0, 0.7);
}
.army-option-name { font-weight: bold; font-size: 1rem; }
.army-option-crest { color: #f6f3ec; width: 1.15rem; height: 1.15rem; }

/* Spell/equipment loadout: floats over the bottom of the banner, evenly spaced across the full width. */
.army-option-loadout {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 0.35rem;
  padding: 0.4rem 0.6rem;
  line-height: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0));
}

.army-tile {
  flex: 0 1 44px;
  min-width: 0;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
}
.army-tile .spell-glyph { width: 60%; max-width: 40px; color: var(--gold); }
.army-tile .spell-glyph svg { width: 100%; height: auto; aspect-ratio: 1; }

.army-recap { display: flex; align-items: center; gap: 0.5rem; }
.army-recap-crest { width: 1.5rem; height: 1.5rem; }
.army-recap-name { font-weight: bold; }
.army-recap-change {
  margin-left: auto;
  background: none;
  border: none;
  color: var(--muted);
  padding: 0.25rem 0.4rem;
  cursor: pointer;
  font: inherit;
  text-decoration: underline;
}
.army-recap-change:hover { color: var(--text); border-color: transparent; }

/* ---------- Character info sheet ---------- */

/* A long-press to inspect must not select text or pop iOS's callout menu on the
   tiles it holds; the board doesn't scroll, so suppressing the callout is safe. */
.board-wrap {
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

/* Reuses the .dialog card; overrides tuned for a taller, scrollable read. */
.char-sheet {
  max-width: 24rem;
  max-height: 88vh;
  overflow-y: auto;
  gap: 0.85rem;
  animation: char-sheet-in 0.16s ease-out;
}
@keyframes char-sheet-in {
  from { opacity: 0; transform: translateY(6px) scale(0.98); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}

.char-head { display: flex; gap: 0.75rem; align-items: center; }
/* Cancel the board's .tile .sprite shrink — the portrait uses the base 48px. */
.char-portrait { flex: 0 0 auto; width: 48px; height: 48px; }
.char-portrait.dead { opacity: 0.5; filter: grayscale(0.8); }
/* Standalone /codex unit page: show a larger 3/4 (south-east) portrait. Scoped to
   .codex-page so the in-game piece-inspect view (shared char_sheet) keeps its
   48px front-facing portrait. Percentage background-size means the SE cell
   position is size-independent (same calc as .sprite.face-se). */
.codex-page .char-portrait {
  width: 128px;
  height: 128px;
  background-position-y: calc(100% * 1 / 7);
}
.char-head-text { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.char-name { font-size: 1.15rem; font-weight: 600; line-height: 1.15; }
.char-subtitle { color: var(--muted); font-size: 0.85rem; }
.char-side-tag {
  align-self: flex-start;
  margin-top: 0.15rem;
  padding: 0.05rem 0.5rem;
  border-radius: 999px;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  border: 1px solid var(--line);
}
.char-side-tag.mine { color: var(--blue); border-color: color-mix(in srgb, var(--blue) 60%, transparent); }
.char-side-tag.enemy { color: var(--red); border-color: color-mix(in srgb, var(--red) 60%, transparent); }

.char-hp { display: flex; flex-direction: column; gap: 0.3rem; }
.char-hp-row { display: flex; align-items: center; justify-content: space-between; }
.char-hp-label { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.85rem; }
.char-hp-value { font-weight: 600; font-variant-numeric: tabular-nums; }
.char-hp-bar {
  display: block;
  width: 100%;
  height: 8px;
  background: rgba(0, 0, 0, 0.5);
  border-radius: 999px;
  overflow: hidden;
}
.char-hp-fill { display: block; height: 100%; background: var(--green); }
.char-hp-bar.side-red .char-hp-fill { background: var(--red); }
.char-hp-bar.side-blue .char-hp-fill { background: var(--blue); }

.char-statuses { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; }
.char-status {
  padding: 0.25rem 0.5rem;
  border-radius: 6px;
  font-size: 0.85rem;
  color: var(--cyan);
  background: color-mix(in srgb, var(--cyan) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--cyan) 35%, transparent);
}

.char-stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.35rem 0.9rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel-2);
}
.char-stat { display: flex; align-items: center; gap: 0.4rem; }
.char-stat .icon { color: var(--card-color, var(--gold)); flex: 0 0 auto; }
.char-stat-label { color: var(--muted); font-size: 0.85rem; }
.char-stat-value { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; }

/* char_sheet renders as a fragment into the plain-block .codex-body, so it
   owns its own vertical rhythm here rather than inheriting a parent gap. */
.char-sheet-body { display: flex; flex-direction: column; gap: 0.8rem; }

/* Extra air above a section heading groups it with the block it labels,
   pulling the major sections (Equipment / Stats / Abilities) apart. */
.char-section-title {
  margin: 0.5rem 0 0;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--muted);
}
/* The Stats grid has no heading; give it the same separation from the block
   above (the health bar, or the equipment grid) that titled sections get. */
.char-stats-core { margin-top: 0.5rem; }

.char-abilities { display: flex; flex-direction: column; gap: 0.6rem; }
.char-ability { display: flex; gap: 0.55rem; }
.char-ability .icon { color: var(--card-color, var(--gold)); flex: 0 0 auto; margin-top: 0.2rem; }
.char-ability-body { min-width: 0; }
.char-ability-name { font-weight: 600; }
.char-ability-text { color: var(--muted); font-size: 0.9rem; line-height: 1.35; }

.char-lore {
  margin: 0.5rem 0 0;
  padding-top: 0.85rem;
  border-top: 1px solid var(--line);
  font-style: italic;
  color: var(--muted);
  font-size: 0.85rem;
}

/* Reach diagrams: two same-scale Manhattan-diamond grids (Move, Attack) with
   the hero on the center cell. Sizes come from the resolved kit, so an item
   that boosts range/move enlarges the grid automatically. Hidden for corpses. */
.char-reach {
  --reach-cell: 13px;
  display: flex;
  gap: 1.4rem;
  flex-wrap: wrap;
}
.reach-diagram { display: flex; flex-direction: column; gap: 0.35rem; }
.reach-label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--muted);
}
.reach-grid { display: inline-grid; gap: 2px; width: max-content; }
.reach-cell {
  width: var(--reach-cell);
  height: var(--reach-cell);
  border-radius: 2px;
  background: var(--panel-2);
}
.reach-cell.in.move { background: var(--blue); }
.reach-cell.in.attack { background: var(--red); }
.reach-cell.center {
  background: var(--gold);
  box-shadow: 0 0 0 1px var(--line) inset;
}

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

/* --- Info codex --- */

/* Standalone /codex page: the .codex component below is normally framed by
   .dialog (a modal); as a page it needs its own outer container instead. */
.codex-page { padding: 1rem; max-width: 48rem; margin: 0 auto; }
.codex-page-title { display: flex; align-items: center; gap: 0.5rem; }
.codex-page-title .icon { color: var(--gold); }

.codex.char-sheet { max-width: 30rem; }
.codex-breadcrumb {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35rem;
  border-bottom: 1px solid var(--line);
  margin: 0 0 0.75rem;
  padding-bottom: 0.5rem;
  font-size: 0.9rem;
}
.codex-crumb {
  background: none;
  border: none;
  padding: 0;
  color: var(--gold);
  font: inherit;
  cursor: pointer;
}
.codex-crumb:hover { text-decoration: underline; }
.codex-crumb.current { color: var(--muted); cursor: default; }
.codex-crumb-sep { color: var(--muted); }

.codex-index {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}
.codex-index-link {
  display: block;
  width: 100%;
  text-align: left;
  padding: 0.75rem 0.5rem;
  background: none;
  border: none;
  border-bottom: 1px solid var(--line);
  color: var(--gold);
  font: inherit;
  cursor: pointer;
}
.codex-index-link:hover { background: rgba(255, 255, 255, 0.04); }
.codex-body { min-height: 12rem; }

.codex-grid,
.codex-board-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr));
  gap: 0.5rem;
}
.codex-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  position: relative;
  padding: 0.5rem;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--line);
  border-radius: 0.4rem;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.codex-tile:hover { border-color: var(--gold); }
.codex-tile-name { font-size: 0.8rem; text-align: center; }
.codex-badge {
  position: absolute;
  top: 0.2rem;
  right: 0.35rem;
  font-size: 0.72rem;
  color: var(--muted);
}
/* Codex Board tab: a single framed board tile used as a feature thumbnail.
   Pin --tile-size (as .board-snapshot does) so the crystal piece's sprite and
   HP bar scale to this frame — without it, `.tile .sprite`'s
   calc(var(--tile-size) * …) is undefined here and the sprite falls back to its
   48px base, overflowing the 44px square. */
.codex-tile-preview {
  --tile-size: 44px;
  position: relative;
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
}

.codex-tile-icon,
.codex-card-icon { font-size: 1.5rem; color: var(--gold); }
.codex-card-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 0.6rem;
}
.codex-card-name { font-weight: 600; }
.codex-card-type { font-size: 0.8rem; color: var(--muted); }
.codex-card-desc { margin: 0.3rem 0; }
.codex-card-targeting { font-size: 0.85rem; color: var(--muted); }

/* Codex per-army sections */
.codex-section { margin-bottom: 1.1rem; }
.codex-section-head {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0 0 0.5rem;
}
.codex-section-name,
.codex-army-name {
  font-size: 0.9rem;
  font-weight: bold;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--gold);
}

/* Crest, and its letter fallback for un-vendored art */
.faction-crest {
  flex: none;
  width: 1rem;
  height: 1rem;
}
.faction-crest-letter {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1rem;
  height: 1rem;
  border: 1px solid var(--line);
  border-radius: 0.25rem;
  font-size: 0.7rem;
  font-weight: bold;
  color: var(--gold);
}
.codex-section-crest,
.codex-army-crest { width: 1.15rem; height: 1.15rem; }

/* Codex Armies identity tab */
.codex-armies { display: flex; flex-direction: column; gap: 1rem; }
.codex-army {
  position: relative;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--line);
  border-radius: 0.4rem;
  overflow: hidden;
}
.codex-army:hover { border-color: var(--gold); }
/* Whole-card drill-down: a transparent button stretched over the army card so
   the entire card is one clickable, keyboard-focusable target (the card's rich
   flow content can't itself live inside a <button>). */
.codex-army-hit {
  position: absolute;
  inset: 0;
  /* Sit above the card's siblings (banner image, title) so a click anywhere on
     the card reaches the button — otherwise the later-painted image/title
     swallow the click and only the text below the button navigates. */
  z-index: 1;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
  border-radius: inherit;
}
.codex-army-hit:focus-visible { outline: 2px solid var(--gold); outline-offset: -2px; }
/* Codex Army detail page: identity header (reuses .codex-army-* banner/title/
   passive/lore) above the deployable roster sections. */
.codex-army-detail .codex-army-banner { border-radius: 0.4rem; overflow: hidden; }
.codex-army-detail .codex-army-passive,
.codex-army-detail .codex-army-lore { margin-left: 0; margin-right: 0; }
.codex-army-detail .codex-section { margin-top: 1rem; }
.codex-army-banner { position: relative; line-height: 0; }
.codex-army-img { display: block; width: 100%; height: auto; }
.codex-army-title {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.45rem 0.6rem;
  line-height: 1.1;
  color: #f6f3ec;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0));
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95), 0 0 6px rgba(0, 0, 0, 0.7);
}
.codex-army-title .codex-army-name { color: #f6f3ec; margin: 0; }
.codex-army-title .codex-army-crest { color: #f6f3ec; }
.codex-army-passive { margin: 0.5rem 0.7rem 0.2rem; }
.codex-army-passive-name { font-weight: 600; color: var(--gold); margin-right: 0.35rem; }
.codex-army-lore { margin: 0.3rem 0.7rem 0.6rem; font-size: 0.85rem; color: var(--muted); font-style: italic; }

/* Goal walkthrough */
.codex-steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 1.1rem;
}
.codex-step {
  display: grid;
  grid-template-columns: 1.75rem 1fr auto;
  align-items: center;
  gap: 0.75rem;
}
.codex-step-num {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 50%;
  background: var(--gold);
  color: var(--bg);
  font-weight: 700;
  font-size: 0.9rem;
}
.codex-step-body { min-width: 0; }
.codex-step-title { margin: 0; font-weight: 600; }
.codex-step-text { margin: 0.15rem 0 0; font-size: 0.9rem; color: var(--muted); }
.codex-step-art {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
  min-width: 96px;
}
.codex-demo-hand { display: flex; gap: 0.4rem; }
.codex-demo-lane {
  position: relative;
  width: 144px;
  height: 48px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--line);
  border-radius: 0.3rem;
  overflow: hidden;
}
.codex-demo-clone {
  position: absolute;
  top: 0;
  left: 0;
  width: 48px;
  height: 48px;
}

.codex-demo-move .codex-demo-clone { animation: codex-slide-fwd 2.4s ease-in-out infinite; }
.codex-demo-undo .codex-demo-clone { animation: codex-slide-back 2.4s ease-in-out infinite; }

@keyframes codex-slide-fwd {
  0%, 15% { transform: translateX(0); }
  50%, 100% { transform: translateX(96px); }
}
@keyframes codex-slide-back {
  0%, 15% { transform: translateX(96px); }
  50%, 100% { transform: translateX(0); }
}

/* The real AP gauge (BoardComponents.ap_pie/1) reused inline: the live .ap-cap
   sizes off the hand-tray --h, so pin an explicit 1:2 box here. Steps that
   spend AP drain the top wedge; Undo restores it. */
.codex-art-ap { flex: 0 0 auto; width: 22px; height: 44px; }
/* The reused .ap-cap is a <span> here (not a flex-item <button> as in the tray),
   so it defaults to inline and its width/height collapse — make it a block so
   the absolutely-positioned .ap-pie gauge has a sized containing box. */
.codex-art-ap .ap-cap { display: block; width: 100%; height: 100%; }
.codex-art-ap-drain .ap-pie .ap-wedge:nth-child(1) {
  animation: codex-ap-drain 2.4s ease-in-out infinite;
}
@keyframes codex-ap-drain {
  0%, 22% { fill: var(--gold); }
  55%, 78% { fill: var(--gold-dim); }
  100% { fill: var(--gold); }
}

/* Undo: one full spend-and-refund loop. The gauge starts full, spends top-down
   (top wedge first), then undo refunds bottom-up (bottom wedge first) — so the
   top wedge is spent first and restored last (LIFO, like the undo stack). Each
   wedge holds its own spent window; nth-child(1) is the top (12 o'clock) slice,
   nth-child(5) the bottom. */
.codex-art-ap-restore .ap-pie .ap-wedge:nth-child(1) { animation: codex-ap-undo-1 5s linear infinite; }
.codex-art-ap-restore .ap-pie .ap-wedge:nth-child(2) { animation: codex-ap-undo-2 5s linear infinite; }
.codex-art-ap-restore .ap-pie .ap-wedge:nth-child(3) { animation: codex-ap-undo-3 5s linear infinite; }
.codex-art-ap-restore .ap-pie .ap-wedge:nth-child(4) { animation: codex-ap-undo-4 5s linear infinite; }
.codex-art-ap-restore .ap-pie .ap-wedge:nth-child(5) { animation: codex-ap-undo-5 5s linear infinite; }
@keyframes codex-ap-undo-1 {
  0%, 4% { fill: var(--gold); }
  8%, 85% { fill: var(--gold-dim); }
  89%, 100% { fill: var(--gold); }
}
@keyframes codex-ap-undo-2 {
  0%, 12% { fill: var(--gold); }
  16%, 77% { fill: var(--gold-dim); }
  81%, 100% { fill: var(--gold); }
}
@keyframes codex-ap-undo-3 {
  0%, 20% { fill: var(--gold); }
  24%, 69% { fill: var(--gold-dim); }
  73%, 100% { fill: var(--gold); }
}
@keyframes codex-ap-undo-4 {
  0%, 28% { fill: var(--gold); }
  32%, 61% { fill: var(--gold-dim); }
  65%, 100% { fill: var(--gold); }
}
@keyframes codex-ap-undo-5 {
  0%, 36% { fill: var(--gold); }
  40%, 53% { fill: var(--gold-dim); }
  57%, 100% { fill: var(--gold); }
}

/* Win-condition beat: the enemy crystal is destroyed on a loop, stepping through
   the same damage-tier sprites the board uses (crystal → dmg1 → dmg2 → dmg3 →
   rubble). Each tier is a stacked sprite, cross-faded in turn; at rest the gauge
   shows rubble — the destroyed, winning state. */
.codex-demo-crystal .piece.crystal { position: relative; width: 56px; height: 56px; }
.codex-crystal-tier {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  /* The crystal art is centred in its canvas (~11px transparent each side),
     unlike the right-hugging hero sprites and flush caps — nudge it right so its
     visible edge aligns with the other right-aligned illustrations. */
  transform: translateX(8px);
}
.codex-crystal-tier:nth-child(5) { opacity: 1; }
.codex-crystal-tier:nth-child(1) { animation: codex-crystal-t0 5s linear infinite; }
.codex-crystal-tier:nth-child(2) { animation: codex-crystal-t1 5s linear infinite; }
.codex-crystal-tier:nth-child(3) { animation: codex-crystal-t2 5s linear infinite; }
.codex-crystal-tier:nth-child(4) { animation: codex-crystal-t3 5s linear infinite; }
.codex-crystal-tier:nth-child(5) { animation: codex-crystal-t4 5s linear infinite; }
@keyframes codex-crystal-t0 {
  0%, 15% { opacity: 1; }
  20%, 100% { opacity: 0; }
}
@keyframes codex-crystal-t1 {
  0%, 15% { opacity: 0; }
  20%, 35% { opacity: 1; }
  40%, 100% { opacity: 0; }
}
@keyframes codex-crystal-t2 {
  0%, 35% { opacity: 0; }
  40%, 55% { opacity: 1; }
  60%, 100% { opacity: 0; }
}
@keyframes codex-crystal-t3 {
  0%, 55% { opacity: 0; }
  60%, 75% { opacity: 1; }
  80%, 100% { opacity: 0; }
}
@keyframes codex-crystal-t4 {
  0%, 75% { opacity: 0; }
  80%, 100% { opacity: 1; }
}

/* How to Play — per-step illustrations */

/* 1. Goal: two crystals face off, static. */
.codex-art-goal { gap: 0.4rem; }
.codex-art-goal .piece.crystal { width: 34px; }
.codex-art-vs { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; }

/* 3. Deploy: a clone drops onto a deploy tile, on a loop. */
.codex-art-deploy { position: relative; width: 96px; height: 48px; }
.codex-deploy-tile {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--gold-muted);
  border: 1px dashed var(--line);
  border-radius: 0.3rem;
}
.codex-deploy-clone {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 44px;
  height: 44px;
  transform: translateX(-50%);
  animation: codex-drop 2.6s ease-in-out infinite;
}
@keyframes codex-drop {
  0%, 10% { opacity: 0; transform: translate(-50%, -22px); }
  40%, 80% { opacity: 1; transform: translate(-50%, 0); }
  100% { opacity: 0; transform: translate(-50%, 0); }
}

/* 4. Move & attack: reuses .codex-demo-move slide; an enemy waits and a flash pops. */
.codex-art-enemy { position: absolute; top: 0; right: 0; width: 48px; height: 48px; }
.codex-art-flash {
  position: absolute;
  top: 50%;
  right: 24px;
  width: 24px;
  height: 24px;
  transform: translateY(-50%);
  color: var(--gold);
  opacity: 0;
  animation: codex-flash 2.4s ease-in-out infinite;
}
@keyframes codex-flash {
  0%, 45% { opacity: 0; transform: translateY(-50%) scale(0.6); }
  52% { opacity: 1; transform: translateY(-50%) scale(1.1); }
  62%, 100% { opacity: 0; transform: translateY(-50%) scale(1.1); }
}

/* 5. Equip & cast: a glyph flies onto a unit and bursts, on a loop. */
.codex-art-cast { position: relative; width: 96px; height: 48px; }
.codex-cast-unit { position: absolute; left: 0; top: 0; width: 48px; height: 48px; }
.codex-cast-glyph {
  position: absolute;
  left: 4px;
  top: 4px;
  width: 24px;
  height: 24px;
  color: var(--gold);
  animation: codex-cast 2.8s ease-in-out infinite;
}
@keyframes codex-cast {
  0%, 10% { opacity: 0; transform: translate(48px, 0) scale(0.8); }
  45% { opacity: 1; transform: translate(0, 0) scale(1); }
  60% { opacity: 1; transform: translate(0, 0) scale(1.3); }
  75%, 100% { opacity: 0; transform: translate(0, 0) scale(1.3); }
}

/* 7. Long-press: a gentle sustained "hold" ring over a unit. Calm, not busy. */
.codex-art-hold { position: relative; display: inline-block; width: 48px; height: 48px; }
.codex-hold-unit { display: block; width: 48px; height: 48px; }
.codex-hold-ring {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 40px;
  height: 40px;
  transform: translate(-50%, -50%);
  border: 2px solid var(--gold);
  border-radius: 50%;
  animation: codex-hold 2.6s ease-in-out infinite;
}
@keyframes codex-hold {
  0%, 100% { opacity: 0.25; transform: translate(-50%, -50%) scale(0.7); }
  50% { opacity: 0.9; transform: translate(-50%, -50%) scale(1); }
}

/* 8. End turn: the real Submit control (BoardComponents renders the live one as
   a .act-cap.submit button); size the reused end-cap for the step-art slot. */
/* A true half-circle end-cap like the tray's: width = radius = half the height,
   so the 0/999/999/0 border-radius reads as a semicircle (a square box would be
   too wide and only round the corners). */
.codex-art-end .act-cap,
.codex-art-next .act-cap { width: 22px; height: 44px; }

@media (prefers-reduced-motion: reduce) {
  .codex-demo-move .codex-demo-clone,
  .codex-demo-undo .codex-demo-clone,
  .codex-crystal-tier,
  .codex-deploy-clone,
  .codex-art-flash,
  .codex-cast-glyph,
  .codex-hold-ring,
  .codex-art-ap-drain .ap-pie .ap-wedge:nth-child(1),
  .codex-art-ap-restore .ap-pie .ap-wedge {
    animation: none;
  }
  /* Freeze to a representative state. */
  .codex-demo-move .codex-demo-clone { transform: translateX(96px); }
  .codex-deploy-clone { opacity: 1; transform: translateX(-50%); }
  .codex-cast-glyph { opacity: 1; transform: translate(0, 0) scale(1); }
  .codex-hold-ring { opacity: 0.6; transform: translate(-50%, -50%) scale(1); }
  .codex-art-flash { opacity: 0; }
}

.open-game-card { cursor: default; }
.open-game-card:hover { border-color: var(--line); }
.open-game-cancel { margin-left: auto; flex: 0 0 auto; }

@media (max-width: 640px) {
  body { font-size: 14px; }
}

@media (max-width: 480px) {
  /* A unified segmented bar instead of separate stacked buttons: equal-width
     segments, square inner edges, rounded only at the two outer corners, and
     a -2px overlap so adjoining borders share one line instead of doubling. */
  .dash-header { gap: 0; }
  .dash-header .button {
    flex: 1;
    width: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 0;
    position: relative;
  }
  .dash-header .button:not(:first-child) { margin-left: -2px; }
  .dash-header .button:first-child { border-top-left-radius: 4px; border-bottom-left-radius: 4px; }
  .dash-header .button:last-child { border-top-right-radius: 4px; border-bottom-right-radius: 4px; }
  /* The -2px overlap above means each button's shared edge is normally
     painted over by its next sibling — only the last button ever shows a
     complete border. Raise whichever button is being interacted with so
     its full border (all four sides) draws on top instead. */
  .dash-header .button:hover,
  .dash-header .button:active,
  .dash-header .button:focus {
    z-index: 1;
  }
}

/* ---------- Logged-out landing ---------- */

.landing {
  /* Cap the whole column at the banner's width so the content below (the
     preview + blurb split) aligns to the banner and never overflows it. */
  max-width: 688px;
  margin: 1.5rem auto;
  padding: 0 1rem 2rem;
  text-align: center;
}

.landing-mark {
  color: var(--gold);
  letter-spacing: 2px;
  text-transform: uppercase;
  font-size: 1.4rem;
  margin: 0.2rem 0;
}

.landing-tagline { font-size: 1.15rem; font-weight: bold; margin: 0.6rem 0 0.4rem; }
.landing-cta { display: flex; gap: 0.6rem; justify-content: center; flex-wrap: wrap; }

/* A single faction-assembly banner under the subheading (the shown one is
   picked at random per request in PageController). */
.landing-banner {
  display: block;
  width: 100%;
  max-width: 688px;
  height: auto;
  margin: 1.2rem auto 1.6rem;
  border-radius: 6px;
  border: 1px solid rgba(255, 255, 255, 0.12);
}

/* Below the banners: an even split — game preview left, blurb + CTAs right —
   on large displays; stacked on mobile (see the 760px query below). */
.landing-split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.5rem;
  align-items: center;
}

.landing-preview { display: flex; justify-content: center; }
.landing-info { text-align: center; }

@media (max-width: 760px) {
  .landing-split { grid-template-columns: 1fr; }
}

/* ---------- Landing board snapshot ---------- */

.board-snapshot {
  /* pin tile size so the full board renders at a deterministic height:
     9 tiles * 55 + 8 gaps * 3 + 2 * 3 border = 525px */
  --tile-size: 55px;
  width: max-content;
  max-width: 100%;
  margin: 1rem auto 1.2rem;
  border-radius: 6px;
}

.landing-support {
  color: var(--muted);
  margin: 0 0 1.2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}

.landing-support-text { text-align: center; }

/* --- UI icons --------------------------------------------------------- */
.icon {
  width: 1em;
  height: 1em;
  display: inline-block;
  vertical-align: -0.125em;
  fill: currentColor;
  color: inherit;
}
.topbar nav .icon {
  width: 1.4rem;
  height: 1.4rem;
}
.topbar nav a {
  display: inline-flex;
  align-items: center;
  padding: 0.25rem;
}

/* Popout game window: edge-to-edge, no container gutter. */
.game-window {
  margin: 0;
  padding: 0;
  width: 100%;
}

/* Game-screen container is transparent so the fixed .game-backdrop (a child
   of #game) shows through. See the forest-backdrop plan. */
main.container:has(#game),
.game-window {
  background: transparent;
  /* The 3/4 tilted board scene bleeds to 100vw (see .board-scene.view-tilted)
     so its clip reaches the screen edge on a phone. On a desktop with a classic
     (space-taking) vertical scrollbar, 100vw exceeds the content width and would
     add a horizontal scrollbar. Clip the bleed here at the container edge — the
     centered board is far narrower than the container, so nothing visible is
     cut. `clip` (not `hidden`) leaves the vertical axis `visible`, so the tilted
     board's receding top still spills upward into the header as intended. */
  overflow-x: clip;
}

/* --- Per-map board themes ------------------------------------------------- */
/* A theme = board palette (--tile/--tile-alt/--line; the special-tile icons
   follow via their color-mix from --tile) + backdrop (image + edge-colour side
   fill). game_live stamps map-<layout_id> on #game; the block for that id
   overrides these vars, which cascade to the board tiles and the .game-backdrop
   child. The .game default leaves the backdrop empty so a map with no theme
   block keeps today's look (default palette, no scene). Add a theme by dropping
   in one .game.map-<id> block + its PNG (see docs/content-authoring.md,
   "Adding a board layout"). */
.game {
  --backdrop-img: none;
  --backdrop-edge: var(--bg);
  /* Per-map ground texture painted inside the board, under the tile grid (see
     the `.board-texture` rule). Defaults to none; each map theme supplies a PNG
     and a --board-tile-alpha < 100% so the texture reads through the tiles.
     100% alpha here keeps an untextured board's tiles fully opaque (unchanged). */
  --board-texture: none;
  --board-tile-alpha: 100%;
  /* Grid-line opacity between tiles. The 3px grid gaps sit over the
     .board-texture, so by default (0%) the border between cells reveals the
     texture at full strength. A textured map raises this so the tile outline
     paints the map's --line back over the gap, showing progressively LESS
     texture in the border (100% = a solid, texture-free grid line). Tile faces
     are untouched — this is a gap-local outline, not the shared texture layer. */
  --board-line-alpha: 0%;
  /* In-game header ink (brand text + back/settings icons), which sit directly
     over the top of the backdrop with no panel behind them. Default = light
     text for the dark-topped scenes; a scene whose top is light overrides these
     (see .game.map-outdoor_four). --header-halo is a subtle opposite-polarity
     shadow that keeps the row legible where the backdrop varies across it — a
     bright centre under the brand with dark corners under the icons. */
  --header-ink: var(--text);
  --header-ink-dim: var(--muted);
  --header-halo: rgba(0, 0, 0, 0.85);
  /* Slide-out active-games panel width. Capped to the viewport so on a phone
     the panel fits and the board simply clips off the right edge (no scroll). */
  --games-menu-w: min(20rem, 78vw);
}

.game.map-outdoor_solo {
  --tile: #382c52;
  --tile-alt: #322949;
  --line: #3a3450;
  --backdrop-img: url(/images/backdrop/forest-04a71282b45f3aaf22933b3a44a7ce49.png?vsn=d);
  --backdrop-edge: #161d28;
  --board-texture: url(/images/board-texture/outdoor_solo-05aeab5a5ff10f9fe1cec878026684c7.png?vsn=d);
  --board-tile-alpha: 65%;
  /* Grid line darker than --line for stronger contrast on the dim forest floor. */
  --board-line: #121020;
  --board-line-alpha: 100%;
  /* Dark canopy top → light ink, faintly cool-tinted from it (halo default). */
  --header-ink: #e7e9eb;
  --header-ink-dim: #adb4ba;
}

/* Sahara-meets-Death-Valley: dune wall + eroded mesa canyons framing a cracked
   salt-flat basin under an amber dusk. Warm sandstone board palette kept
   mid-dark so the gold/blue/red/green accent rings stay legible over it. */
.game.map-legacy {
  --tile: #4a3a2e;
  --tile-alt: #443528;
  --line: #35281f;
  --backdrop-img: url(/images/backdrop/desert-776817d9d43a43fb017cebe3750e73fd.png?vsn=d);
  --backdrop-edge: #1f0f13;
  /* Dark dusk top → light ink, faintly warm-tinted from it (halo default). */
  --header-ink: #eeeaeb;
  --header-ink-dim: #c4b8ba;
}

/* Legacy of War: the same desert battlefield as Legacy, ashier and darker, with
   the Spire brooding at its centre. */
.game.map-legacy_of_war {
  --tile: #40342a;
  --tile-alt: #392e25;
  --line: #2c211a;
  --backdrop-img: url(/images/backdrop/desert-776817d9d43a43fb017cebe3750e73fd.png?vsn=d);
  --backdrop-edge: #1f0f13;
  --board-texture: url(/images/board-texture/legacy_of_war-afb8970f1992f380ac74e2fdf047d51e.png?vsn=d);
  --board-tile-alpha: 45%;
  /* Grid line darker than --line so the border reads a touch heavier here. */
  --board-line: #1a130e;
  --board-line-alpha: 100%;
  --header-ink: #eeeaeb;
  --header-ink-dim: #c4b8ba;
}

/* The Spire is a board obstacle rendered as a unit-style piece, so it stands
   upright in the tilted 3/4 view like a character (via `.view-tilted .piece`).
   Its dormant tower is the base layer; each held tile stacks a hand-drawn energy
   overlay in a fixed screen slot — front-left/front-right sockets and edge-left/
   edge-right far glows — server-rendered only when lit (see spire_lit/3). A
   `filter` flicker (billboard-safe, no transform) marks danger and overload. */
.piece.spire {
  position: relative;
}
.piece.spire .spire-stack {
  position: relative;
  display: block;
  width: 48px;
  height: 48px;
}
.piece.spire .spire-art {
  position: absolute;
  inset: 0;
  background-repeat: no-repeat;
  background-size: contain;
  background-position: center bottom;
  image-rendering: pixelated;
}
.spire-base-art { background-image: url(/images/sprites/spire_base-fc9d447e44fadb61878020f65ccfde11.png?vsn=d); }
.spire-lit-fl { background-image: url(/images/sprites/spire_fl-d7cb8966d7134f41cf80b226d15bf0bc.png?vsn=d); }
.spire-lit-fr { background-image: url(/images/sprites/spire_fr-da4a91e3b11b749a1afa57600693a776.png?vsn=d); }
.spire-lit-el { background-image: url(/images/sprites/spire_el-e010ef809c16b10532d19d405906f661.png?vsn=d); }
.spire-lit-er { background-image: url(/images/sprites/spire_er-801546bf4bbf9178d492cde2c4e9b0c5.png?vsn=d); }
.spire-lit-overload { background-image: url(/images/sprites/spire_overload-8aa08797b4bb813c222dccc33950a717.png?vsn=d); }
.piece.spire.spire-danger { animation: spire-flicker 0.5s steps(2, jump-none) infinite; }
.piece.spire.spire-overload { animation: spire-flicker 0.26s steps(2, jump-none) infinite; }
@keyframes spire-flicker {
  0%, 100% { filter: brightness(1); }
  50% { filter: brightness(1.4); }
}
@media (prefers-reduced-motion: reduce) {
  .piece.spire.spire-danger,
  .piece.spire.spire-overload { animation: none; }
}

/* Yosemite Half Dome: granite dome + cliff walls over a pine valley and river
   under soft overcast light. Cool blue-slate board palette pulled from the
   scene's shadowed granite, mid-dark so the accent rings stay legible. */
.game.map-outdoor_four {
  --tile: #3a475c;
  --tile-alt: #333f52;
  --line: #26303f;
  --backdrop-img: url(/images/backdrop/valley-2428b2c6e1aa6390b7e7008e945486be.png?vsn=d);
  --backdrop-edge: #06101d;
  --board-texture: url(/images/board-texture/outdoor_four-411182c3bb7625c670e82b24a112b14d.png?vsn=d);
  --board-tile-alpha: 45%;
  --board-line-alpha: 70%;
  /* The pale overcast sky fills the whole top, so the default light header text
     washed out — flip to a dark ink drawn from the sky's cool hue, with a light
     halo. */
  --header-ink: #1c2833;
  --header-ink-dim: #33414f;
  --header-halo: rgba(233, 238, 245, 0.85);
}

/* Magical crystal cave: glowing amethyst/cyan/sapphire crystals framing a dark
   cavern, near-black rock edges. Amethyst-violet board palette pulled from the
   scene's crystal rock, mid-dark so the accent rings stay legible. */
.game.map-indoor_four {
  --tile: #352a4e;
  --tile-alt: #2e2444;
  --line: #201736;
  --backdrop-img: url(/images/backdrop/cave-072a5d5707dabe49a402b3ac798b1c8f.png?vsn=d);
  --backdrop-edge: #08030e;
  --board-texture: url(/images/board-texture/indoor_four-578e3e965ec56032a999fa26bbc4ba82.png?vsn=d);
  --board-texture-size: 110%;
  --board-tile-alpha: 70%;
  --board-line-alpha: 100%;
  /* Dark cavern top (light shaft only at centre) → light ink; the halo carries
     the brand over that shaft. Faint cool-violet tint. */
  --header-ink: #e8e8ea;
  --header-ink-dim: #b2b2b8;
}

/* Deep dark bubbling swamp: mossy cypress framing murky green water with
   will-o'-wisps. Muddy bark-brown board palette pulled from the scene's tree
   trunks and roots, mid-dark so the accent rings stay legible. */
.game.map-triple_defense {
  --tile: #3a3123;
  --tile-alt: #342c20;
  --line: #221a12;
  --backdrop-img: url(/images/backdrop/swamp-c7674372e208010899fbe29462498b8a.png?vsn=d);
  --backdrop-edge: #1a1213;
  --board-texture: url(/images/board-texture/triple_defense-b8a48882a57009a53cb0593b0215d535.png?vsn=d);
  --board-tile-alpha: 70%;
  --board-line-alpha: 100%;
  /* Dark corners but a misty-bright centre → light ink; the halo carries the
     brand over the mist. Faint green tint. */
  --header-ink: #ededeb;
  --header-ink-dim: #c1c2bb;
}

/* --- In-game backdrop ----------------------------------------------------- */
/* Full-bleed, fixed-to-viewport scene supplied by the active map theme via
   --backdrop-img / --backdrop-edge (see the per-map themes above). The scene is
   scaled VERTICALLY (background-size: auto 100%) so its full height always
   shows; width follows aspect and is centered. On viewports wider than the
   scaled image, the sides fall back to the theme's --backdrop-edge (each scene's
   own predominant edge colour, the mode of its left/right pixel columns) so the
   art reads as continuing rather than butting a mismatched fill. A light radial
   "clearing" vignette adds a touch more shadow behind the board. When a map has
   no theme block, --backdrop-img is none (no scene) and --backdrop-edge is --bg,
   yielding today's plain look. z-index:-1 keeps it behind all game content; the
   board/banner/hand are unchanged and paint on top. pointer-events:none so it
   never intercepts taps. Lives inside #game, so it is inherently scoped to the
   match screen and covers the pop-out window route too.

   GOTCHA: position:fixed is viewport-relative ONLY while no ancestor of
   .game-backdrop has a transform/filter/perspective. The 3/4-tilt transforms
   live on .board-scene/.board (descendants of #game, siblings of this),
   so they do not trap it. If #game or an ancestor ever gains a transform, this
   backdrop breaks — revisit then. */
.game-backdrop {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(115% 85% at 50% 55%, transparent 40%, rgba(10, 9, 16, 0.45) 100%),
    var(--backdrop-img) center / auto 100% no-repeat,
    var(--backdrop-edge);
}

/* --- Preload overlay (VS loading screen) --------------------------------- */
/* Full-bleed map-themed panel shown over a freshly-mounted game while the
   GamePreloader hook fetches every art asset (see lib/oathfell/game_assets.ex),
   then cross-faded away. Opaque by default so the board never flashes before the
   hook runs. The failsafe animation guarantees the overlay leaves even if JS
   never loads; adding .preload-done cancels it and hands off to a transition. */
.game-preload {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2rem;
  padding: 2rem;
  text-align: center;
  color: var(--header-ink, #e7e9eb);
  background:
    radial-gradient(120% 90% at 50% 45%, transparent 30%, rgba(6, 5, 12, 0.55) 100%),
    var(--backdrop-img, none) center / auto 100% no-repeat,
    var(--backdrop-edge, #0a0910);
  animation: preload-failsafe 8s linear forwards;
}

.game-preload.preload-done {
  animation: none;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.45s ease, visibility 0s linear 0.45s;
}

/* Decode-warmer for the sprite-sheet FX (built by the GamePreloader hook). Each
   `.fx-warm` cell paints one FX sheet for the game's lifetime, keeping its
   decoded bitmap resident so the first real FX paints instantly instead of
   playing blank while the CSS background re-decodes. Kept on-screen (top-left,
   not off-screen/visibility:hidden) so the browser actually rasters it —
   `opacity:0.001`, NOT 0: WebKit/Blink skip painting zero-opacity layers, so a
   0 cell never decodes. Painted at the natural 64px frame size (`background-size:
   auto`), NOT scaled: Chromium keys its decode cache by destination scale, so a
   `cover`-scaled cell would cache a useless mip instead of the playback frames.
   Non-interactive, behind everything, imperceptible — never seen. */
.fx-warm-layer {
  position: fixed;
  left: 0;
  top: 0;
  width: 1px;
  height: 1px;
  overflow: visible;
  pointer-events: none;
  z-index: -1;
}
.fx-warm {
  position: absolute;
  left: 0;
  top: 0;
  width: 64px;
  height: 64px;
  opacity: 0.001;
  background-repeat: no-repeat;
  background-position: 0 0;
  background-size: auto;
}

@keyframes preload-failsafe {
  0%,
  90% {
    opacity: 1;
    visibility: visible;
  }
  100% {
    opacity: 0;
    visibility: hidden;
  }
}

/* "Liquid Glass" panel wrapping the VS plates + progress, so the loader reads
   against a frosted, map-tinted field instead of directly on the busy scene
   (which is legible only once the backdrop has painted). Mirrors the .hand
   dish: translucent --tile tint, an inset rim carving depth, and a heavy
   backdrop blur; an outer drop shadow lifts this floating panel off the scene
   (the docked hand doesn't need one). */
.preload-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.75rem;
  padding: 1.75rem 2.25rem;
  /* Never let the panel's content minimums push it past the .game-preload
     padding — on a narrow phone the VS row would otherwise overflow to the
     screen edges. Cap to the padded container and let the row shrink. */
  max-width: 100%;
  border-radius: 1.25rem;
  background:
    /* specular catch on the far, lower-right inner wall */
    radial-gradient(
      130% 120% at 80% 92%,
      color-mix(in srgb, white 22%, transparent) 0%,
      transparent 46%
    ),
    /* shadowed near, upper-left inner wall */
    radial-gradient(
      120% 120% at 20% 6%,
      color-mix(in srgb, black 34%, transparent) 0%,
      transparent 52%
    ),
    /* base translucent glass tint, deepening toward lower-right */
    linear-gradient(
      135deg,
      color-mix(in srgb, var(--tile) 22%, transparent) 0%,
      color-mix(in srgb, var(--tile) 48%, transparent) 100%
    );
  box-shadow:
    inset 1px 1px 0 color-mix(in srgb, white 30%, transparent),
    inset 2px 3px 7px rgba(0, 0, 0, 0.4),
    inset -2px -3px 8px color-mix(in srgb, white 15%, transparent),
    0 10px 30px rgba(0, 0, 0, 0.45);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  backdrop-filter: blur(16px) saturate(140%);
}

.preload-vs {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  max-width: 100%;
}

.preload-player {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  /* Was a hard min-width: 7rem, which forced the panel wider than the
     viewport on phones. Shrink to share the row (min-width: 0 lets the name
     ellipsis instead of overflowing). */
  flex: 1 1 0;
  min-width: 0;
}

.preload-name {
  font-size: 1.25rem;
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.preload-rating {
  font-size: 1rem;
  opacity: 0.85;
}

/* Reuse the project's canonical side-accent vars (defined in :root, see
   --red/--blue near the top of this file) so the VS plates match the rest of
   the board's red/blue. Fallback hexes only apply if those vars are ever
   undefined. */
.preload-player.side-red .preload-name {
  color: var(--red, #e0736b);
}
.preload-player.side-blue .preload-name {
  color: var(--blue, #6ba6e0);
}

.preload-versus {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.25rem;
  font-size: 1.5rem;
  font-weight: 800;
  letter-spacing: 0.1em;
  opacity: 0.9;
}

.preload-versus svg {
  width: 2rem;
  height: 2rem;
}

.preload-progress {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  width: min(20rem, 70vw);
}

.preload-bar-track {
  height: 0.4rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.18);
  overflow: hidden;
}

.preload-bar {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: currentColor;
  transition: width 0.2s ease;
}

.preload-status {
  margin: 0;
  font-size: 0.85rem;
  opacity: 0.8;
}

/* Phones: trim the overlay's outer and panel padding and the VS gap so the
   loader keeps a clear margin from the screen edges instead of hugging them. */
@media (max-width: 480px) {
  .game-preload {
    padding: 1rem;
  }
  .preload-panel {
    padding: 1.5rem 1.25rem;
  }
  .preload-vs {
    gap: 0.75rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .preload-bar {
    transition: none;
  }
  .game-preload.preload-done {
    transition: opacity 0.001s linear, visibility 0s linear 0.001s;
  }
}

/* Pop-out affordance: a bare icon button used on the dashboard game rows. */
.popout-btn {
  display: inline-flex;
  align-items: center;
  background: none;
  border: 0;
  padding: 0.25rem;
  font-size: 1.1rem;
  color: var(--muted);
  cursor: pointer;
}
.popout-btn:hover {
  color: var(--gold);
}

/* Share-replay affordance: a bare icon button, same treatment as .popout-btn,
   used on finished-game history rows and the end-of-game winner card. */
.share-btn {
  display: inline-flex;
  align-items: center;
  background: none;
  border: 0;
  padding: 0.25rem;
  font-size: 1.1rem;
  color: var(--muted);
  cursor: pointer;
}
.share-btn:hover {
  color: var(--gold);
}

/* --- Connecting toast ----------------------------------------------------- */
/* Shown whenever the LiveView socket has not joined. Driven entirely by
   LiveView's own container classes:

     - `.phx-connected`      → live; overlay hidden (`display: none`).
     - anything else         → not joined (`.phx-error` genuine drop, or the
                               benign `.phx-loading` / no-class connect, nav, or
                               reconnect blip). The overlay shows IMMEDIATELY —
                               no grace delay — so a stuck or slow connect always
                               surfaces and taps are blocked while it's up.

   The backdrop is fully transparent: it blocks interaction with the main
   content (a wedged socket silently swallows taps) but does NOT shade the app.
   It sits below the sticky topbar (z-index 45) so its nav stops (Dashboard,
   Friends, Codex, Settings) stay usable. The toast pops up from the bottom. No
   JS except the Reload button, revealed after 5s of continuous disconnect. */
.connecting-panel {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  /* Extra bottom padding lifts the toast clear of the iOS PWA home indicator
     without stretching the card itself (the card keeps symmetric padding). */
  padding: 1rem;
  padding-bottom: calc(1rem + env(safe-area-inset-bottom));
  /* Transparent, but still pointer-blocking: a transparent element hit-tests,
     so it swallows taps during a wedged socket without shading the app. */
  background: transparent;
}

.phx-connected .connecting-panel {
  display: none;
}

/* Toast: a bottom horizontal bar — [spinner] Connecting… [Reload]. Slides up
   from the bottom edge the moment the socket drops. */
.connecting-card {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 26rem;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem 1rem;
  background: var(--panel);
  border: 2px solid var(--line);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  transform: translateY(120%);
  animation: connecting-slide-up 0.25s ease-out forwards;
}

.connecting-spinner {
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  border: 3px solid var(--gold-muted);
  border-top-color: var(--gold);
  border-radius: 50%;
  animation: connecting-spin 700ms linear infinite;
}

.connecting-primary {
  margin: 0;
}

/* Escalation: the Reload button, hidden for the first 5s of continuous
   disconnect, then revealed pinned to the right of the toast. Because the
   animation restarts every time the toast re-shows (display none -> flex), the
   5s always measures *continuous* non-connected time. visibility keeps the
   Reload button non-clickable before it appears. */
.connecting-escalated {
  margin-left: auto;
  flex-shrink: 0;
  opacity: 0;
  visibility: hidden;
  animation: connecting-escalate 0.2s ease 5s forwards;
}

@keyframes connecting-slide-up {
  to {
    transform: translateY(0);
  }
}

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

@keyframes connecting-escalate {
  to {
    opacity: 1;
    visibility: visible;
  }
}

/* Reduced motion: kill the cosmetic spinner spin and the toast's slide-up — the
   toast just appears in place. The 5s escalation reveal is a load-bearing
   visibility timer (not motion), so it keeps running or the Reload button would
   never appear. */
@media (prefers-reduced-motion: reduce) {
  .connecting-spinner {
    animation: none;
  }

  .connecting-card {
    transform: none;
    animation: none;
  }
}

/* ---------- Game-mode header ---------- */
.game-header {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  width: 100%;
  padding: 0.5rem 0.25rem;
  background: none;
  border: none;
  /* A 1px multi-directional outline (plus a soft drop) in --header-halo so the
     brand reads over a backdrop that varies across the row — the light text
     stays legible over bright patches (swamp mist, cave shaft). Inherited by
     the brand text; the SVG icons get the matching filter outline below
     (text-shadow does not affect SVG fill). Per-map --header-halo. */
  text-shadow:
    1px 1px 0 var(--header-halo), -1px 1px 0 var(--header-halo),
    1px -1px 0 var(--header-halo), -1px -1px 0 var(--header-halo),
    0 1px 0 var(--header-halo), 0 -1px 0 var(--header-halo),
    1px 0 0 var(--header-halo), -1px 0 0 var(--header-halo),
    0 1px 3px var(--header-halo);
}
.game-header-actions {
  justify-self: end;
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
}
/* The left group (back arrow + games-menu hamburger) balances the right group
   so the brand stays centred: two mini icons on each side of the header. */
.game-header-actions.start { justify-self: start; }
.game-header-back,
.game-header-menu,
.game-header-rewatch,
.game-header-settings {
  display: inline-flex;
  align-items: center;
  color: var(--header-ink-dim);
  background: none;
  border: none;
  padding: 0.25rem;
  cursor: pointer;
}
.game-header-back:hover,
.game-header-menu:hover,
.game-header-rewatch:hover,
.game-header-settings:hover { color: var(--header-ink); }
/* The re-watch button lives only when you can act and haven't moved; dim and
   un-clickable otherwise, mirroring the undo control's disabled treatment. */
.game-header-rewatch:disabled { opacity: 0.35; cursor: default; }
.game-header-brand {
  justify-self: center;
  color: var(--header-ink);
  font-weight: 600;
  letter-spacing: 0.02em;
}
/* Outline the SVG icons to match the brand's text-shadow (chained drop-shadows
   since a single one leaves the icon washed over bright patches). */
.game-header-back svg,
.game-header .icon {
  filter:
    drop-shadow(1px 0 0 var(--header-halo)) drop-shadow(-1px 0 0 var(--header-halo))
    drop-shadow(0 1px 0 var(--header-halo)) drop-shadow(0 -1px 0 var(--header-halo));
}

/* ---------- Slide-out active-games panel ----------
   The play column (.game-play) and the panel (.games-menu) share one centred
   flex row. Closed, the panel is zero-width and the board is centred as usual.
   Open, the panel takes its width, pushing the board right; the row stays
   `width: max-content; margin: 0 auto`, so it CENTRES when it fits (desktop)
   and PINS the panel to the left with the board clipping off the right when it
   doesn't (PWA) — the container's `overflow-x: clip` does the clipping, so no
   horizontal scroll ever appears. No transform is used (a transformed ancestor
   would trap the fixed backdrop/toast/dialogs) — the board is shoved by the
   growing sibling instead. */
.game-stage {
  display: flex;
  /* Stretch the panel to the play column's height so it spans from the player
     plates down to the bottom of the hand — its list scrolls inside rather than
     growing the page (no whole-screen scroll on an iPad/laptop when you have
     many games waiting). */
  align-items: stretch;
  width: max-content;
  margin: 0 auto;
}

.games-menu {
  flex: 0 0 auto;
  width: 0;
  /* Gap between the panel and the board, so the board isn't tucked right up
     against it. Zero when closed (board stays centred); opens with the slide. */
  margin-right: 0;
  overflow: hidden;
  visibility: hidden;
  position: relative;
  z-index: 1;
  background: var(--bg);
  transition:
    width 0.25s ease,
    margin-right 0.25s ease,
    visibility 0s linear 0.25s;
}
.game.menu-open .games-menu {
  width: var(--games-menu-w);
  margin-right: 1rem;
  visibility: visible;
  transition:
    width 0.25s ease,
    margin-right 0.25s ease;
}
/* Fixed-width inner box, pinned to fill the (stretched) panel so it never
   reflows while the wrapper animates its width from 0 to open. A flex column:
   the heading stays put and the list scrolls beneath it. */
.games-menu-inner {
  position: absolute;
  inset: 0;
  width: var(--games-menu-w);
  box-sizing: border-box;
  padding: 0.75rem;
  display: flex;
  flex-direction: column;
}
/* Mirror the dashboard section heading. */
.games-menu-title {
  flex: 0 0 auto;
  margin: 0 0 0.75rem;
  color: var(--muted);
  font-size: 1rem;
  text-transform: uppercase;
  letter-spacing: 2px;
}
/* The list takes the remaining height and scrolls within it. `min-height: 0`
   lets a flex child shrink below its content so the overflow actually engages. */
.games-menu .game-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
/* The current game rides in the list, marked in bold gold and inert (it has no
   switch link — you're already on it). */
.games-menu .game-row.current {
  border-color: var(--gold);
  background: color-mix(in srgb, var(--gold) 12%, var(--panel));
  cursor: default;
}
.games-menu .game-row.current .opponent {
  color: var(--gold);
  font-weight: 700;
}
.games-menu .game-row.current .turn { color: var(--gold); }

.game-play {
  position: relative;
  z-index: 2;
}

/* The tilted board normally breaks out to the full viewport width and
   re-centres on the screen (see .board-scene.view-tilted) — which would ignore
   the panel and sit on top of it. While the panel is open, confine the scene to
   its own board-width column so the flex row can place it cleanly to the right
   of the panel (and centre the pair on desktop / clip it on the right in a PWA).
   The fill-scale is dialled back to 1 so the board fits its column without its
   widened corners being clipped away. */
.game.menu-open .board-scene.view-tilted {
  width: var(--board-width);
  margin-left: auto;
  margin-right: auto;
  --board-tilt-fill-x: 1;
}

/* A transparent scrim over the slid board: any tap on the board closes the
   panel (rendered only while open). */
.game-play-scrim {
  position: absolute;
  inset: 0;
  z-index: 5;
  background: transparent;
  border: none;
  padding: 0;
  cursor: pointer;
}

@media (prefers-reduced-motion: reduce) {
  .games-menu,
  .game-play {
    transition: none;
  }
}

/* On a phone / installed PWA (where the board clips off-screen anyway) the
   panel should run all the way to the bottom of the display rather than stopping
   at the hand — otherwise it floats with dead space beneath it. Make the game
   fill the viewport height and let the stage (hence the stretched panel) grow
   into it. Only the panel's background extends: the board and hand keep their
   natural top-anchored positions, and the list still scrolls within the panel.
   iPads/laptops (wider than this) keep the centred cluster, panel matched to the
   board's height. */
@media (max-width: 640px) {
  .game {
    display: flex;
    flex-direction: column;
    /* Fill the SAFE viewport — matching how the app shell sizes main.container
       (its only scroller) and the board's own tile-size term: 100dvh minus the
       safe-area insets that <body> pads in the installed PWA. Plain 100dvh
       overshoots by those insets, so main.container gains a spurious scroll
       (~the top inset, about the header's height) and the fixed backdrop clips
       at the bottom — visible only on a real notched phone, never in a desktop
       simulator where the insets resolve to 0. */
    min-height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
    min-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
  }
  .game-stage {
    flex: 1 1 auto;
    min-height: 0;
  }
  /* While open, pin the stage to the left edge instead of centring it. The
     centred `margin: 0 auto` re-centres the cluster every frame it's still
     narrower than the screen (the start of opening / end of closing), which
     drags the panel's left edge in and out. Left-anchored, the panel stays
     stuck to the display's left edge and only its right edge (the board) moves. */
  .game.menu-open .game-stage {
    margin-left: 0;
    margin-right: 0;
  }
}

/* Developer codex (dev-only) */
/* Fixed 48px tiles so Move/Attack/AoE diagrams render at the game's native
   sprite scale (1:1) rather than a shrunk-to-fit approximation. */
.dev-grid { display: grid; grid-template-columns: repeat(9, 48px); gap: 2px; width: max-content; }
.dev-tile { aspect-ratio: 1; background: #1b1b22; border: 1px solid #2c2c36; position: relative; }
.dev-tile.origin { background: #3a3a52; border-color: #6a6a9a; }
.dev-tile.reachable { background: #1f3a2a; border-color: #3f7a54; }
.dev-tile.in-range { background: #3a2a1f; border-color: #7a5a3f; }
.dev-tile.footprint { background: #3a1f22; border-color: #7a3f47; }
.dev-tile .sprite { position: absolute; inset: 0; }

.dev-codex { max-width: 960px; margin: 0 auto; padding: 1rem; }
.dev-nav { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; border-bottom: 2px solid var(--line); padding-bottom: 0.75rem; }
.dev-nav-link {
  color: var(--muted);
  text-decoration: none;
  padding: 0.35rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: 4px;
}
.dev-nav-link:hover { color: var(--text); border-color: var(--gold-muted); }
.dev-nav-link.active { color: var(--gold); border-color: var(--gold); background: var(--panel-2); }

.dev-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; }
.dev-list li a {
  display: block;
  padding: 0.5rem 0.75rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 4px;
  text-decoration: none;
  color: var(--text);
}
.dev-list li a:hover { border-color: var(--gold-muted); }

.dev-detail { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 1rem 1.25rem; }
.dev-detail h2 { display: flex; align-items: center; gap: 0.4rem; margin-top: 0; }
.dev-subtitle { color: var(--muted); }
.dev-lore { font-style: italic; color: var(--muted); }

.dev-sprites { display: flex; gap: 1rem; margin: 0.75rem 0; }
.dev-sprites .sprite { position: relative; width: 48px; height: 48px; }

.dev-stats { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 0.75rem; margin: 0.75rem 0; }
.dev-stats dt { color: var(--muted); }
.dev-stats dd { margin: 0; }

.dev-abilities { display: grid; gap: 0.5rem; margin: 0.75rem 0; }
.dev-ability { background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 0.5rem 0.75rem; }
.dev-ability p { margin: 0.25rem 0 0; color: var(--muted); }

.dev-diagrams { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-top: 1rem; }
.dev-diagrams figure { margin: 0; }
.dev-diagrams figcaption { color: var(--muted); margin-bottom: 0.25rem; }

.dev-deck { list-style: none; margin: 0 0 0.75rem; padding: 0; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.dev-deck li { background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 0.15rem 0.5rem; }

/* Clickable entity squares (faction roster / spells / equipment). */
.dev-cards { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0 0 1rem; }
.dev-card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  width: 84px;
  padding: 0.6rem 0.4rem 0.5rem;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 6px;
  text-decoration: none;
  color: var(--text);
}
.dev-card:hover { border-color: var(--gold-muted); }
.dev-card-qty {
  position: absolute;
  top: -0.5rem;
  right: -0.5rem;
  min-width: 1.4rem;
  padding: 0.05rem 0.35rem;
  background: var(--gold);
  color: var(--bg);
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 700;
  text-align: center;
}
.dev-card-art { width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; }
.dev-card-art .sprite { position: relative; width: 48px; height: 48px; }
.dev-card-art .icon { width: 40px; height: 40px; }
.dev-card-name { font-size: 0.8rem; text-align: center; line-height: 1.15; }

/* Listing rows with leading art (heroes' sprites, items' icons). */
.dev-list-link { display: flex; align-items: center; gap: 0.6rem; }
.dev-list-link .sprite { position: relative; width: 32px; height: 32px; flex: none; }
.dev-list-link .icon { width: 24px; height: 24px; flex: none; }

/* Board layout tile roles + legend swatches. */
.dev-tile.crystal-red { background: #4a1f24; border-color: #b34a54; }
.dev-tile.crystal-blue { background: #1f2f4a; border-color: #4a74b3; }
.dev-tile.deploy-red { background: #3a2626; border-color: #8a5a5a; }
.dev-tile.deploy-blue { background: #26303a; border-color: #5a7a8a; }
.dev-tile.control-power { background: #2f2a12; border-color: #b39a3a; }
.dev-tile.control-defense { background: #122f24; border-color: #3ab37a; }
.dev-tile.control-assault { background: #2f1220; border-color: #b33a7a; }
.dev-tile.teleport { background: #16283f; border-color: #4aa3ff; }
.dev-legend { list-style: none; margin: 0.5rem 0 0.75rem; padding: 0; display: flex; flex-wrap: wrap; gap: 0.75rem; color: var(--muted); font-size: 0.8rem; }
.dev-legend li { display: flex; align-items: center; gap: 0.35rem; }
.dev-swatch { display: inline-block; width: 0.9rem; height: 0.9rem; border-radius: 3px; border: 1px solid var(--line); }
.dev-swatch.crystal-red { background: #4a1f24; border-color: #b34a54; }
.dev-swatch.deploy-red { background: #3a2626; border-color: #8a5a5a; }
.dev-swatch.control-power { background: #2f2a12; border-color: #b39a3a; }
.dev-swatch.control-defense { background: #122f24; border-color: #3ab37a; }
.dev-swatch.control-assault { background: #2f1220; border-color: #b33a7a; }
.dev-swatch.teleport { background: #16283f; border-color: #4aa3ff; }
.dev-swatch.control-spire { background: #2a2440; border-color: #8a7ad6; }
.dev-swatch.control-spire_attack { background: #3a1f1c; border-color: #d6704a; }
.dev-swatch.control-spire_defense { background: #16303a; border-color: #4aa8c6; }

/* Installed-PWA viewport lock: pin the shell to one screen, no bounce, respect
   safe areas. Gated on display-mode so web / mobile-browser visitors keep
   normal page scrolling. */
@media (display-mode: standalone) {
  html { height: 100%; }

  /* The app shell: pinned to the viewport, never scrolls or rubber-bands, with
     the scroll living on main.container so a tall board/list can be reached
     without the shell moving. A flex column carries the viewport height down to
     that scroll column. */
  body {
    position: fixed;
    inset: 0;
    height: 100%;
    overflow: hidden;
    overscroll-behavior: none;
    display: flex;
    flex-direction: column;
    padding:
      env(safe-area-inset-top) env(safe-area-inset-right)
      env(safe-area-inset-bottom) env(safe-area-inset-left);
  }

  /* LiveView wraps every live route in <div id="phx-…" data-phx-main>, so on
     the dashboard/game main.container is body's GRANDCHILD, not its child — a
     flex/grid column on body alone lays out only this wrapper (which just grows
     to its content) and never constrains main's height, so its overflow-y never
     scrolls and the list runs off the bottom. Re-establish the column on the
     wrapper so the height reaches main. (Static pages like Settings have no
     wrapper; there main.container is a direct flex child and this rule is inert
     — harmless.) */
  body > [id^="phx-"] {
    flex: 1;
    min-height: 0;
    display: flex;
    flex-direction: column;
  }

  /* The scroll container. flex: 1 fills the space its flex parent (body on
     static pages, the wrapper on live ones) leaves after the header; min-height:
     0 lets it shrink below its content so overflow-y actually scrolls instead of
     overflowing the shell. width: 100% restores cross-axis fill: main.container's
     base margin: 0 auto would otherwise suppress the flex item's default stretch
     and shrink-wrap it to content width — collapsing wide pages (e.g. Settings)
     into a narrow centered column here only; a definite width still centers it
     up to max-width on a wide standalone window while filling a phone. */
  main.container,
  main.game-window {
    flex: 1;
    min-height: 0;
    width: 100%;
    overflow-y: auto;
    overscroll-behavior: none;
    -webkit-overflow-scrolling: touch;
  }

  /* The header fills the shell width too. Like main.container it carries a base
     max-width: 960px; margin: 0 auto, so as a stretch-suppressed grid item it
     would otherwise shrink-wrap to its content (brand + nav) and render as a
     narrow bar. width: 100% fills the phone; margin: 0 auto still centers it up
     to max-width on a wide standalone window, keeping it flush with main. */
  header.topbar,
  header.topbar-brand {
    width: 100%;
  }

  /* No chrome to pop a detached window out of when we ARE the window: the
     installed PWA has no browser frame, and window.open can't spawn a useful
     second one. Hide the per-game "Open in window" button here. */
  .popout-btn { display: none; }

  /* Board sizing must fit the *non-scrolling* shell, not the raw viewport. The
     base --tile-size (:root) caps the board at 9.5vh per row, but on a phone the
     17vw term wins and reserves nothing for the game-header, players/turn bar,
     tray, or safe-area insets — so the 9-row board plus that chrome overruns the
     safe viewport by ~10-15px and clips the bottom of the hand. Re-derive the
     height term against the space actually left after that chrome: 100dvh minus
     the top/bottom insets minus a fixed reserve for the header + turn bar + tray
     (+ the board's own gaps/borders). Whichever of the three terms is smallest
     still wins, so a wide window keeps the 72px cap and a narrow phone keeps 17vw
     up until the board would collide with the tray — then this term shrinks it to
     fit. Reserve breakdown: game-header ~44 + topbar-players ~29 + tray ~68 +
     board gaps/border ~30 ≈ 170; rounded to 180 for a small safety margin. */
  .game {
    --tile-size: min(
      17vw,
      calc(
        (100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 180px) / 9
      ),
      72px
    );
  }
}

/* ---------- Settings hub page ---------- */

.settings-page { margin: 1.5rem auto; padding: 0 1rem; }

.settings-list a {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  color: var(--text);
  text-decoration: none;
}
.settings-list a:hover { border-color: var(--text); }
.settings-list .icon { width: 1.4rem; height: 1.4rem; }
/* active (unmuted / alerts-granted) toggle state */
.settings-list .on { color: var(--gold); }

/* ---------- Logged-out brand header ---------- */

/* No bar chrome: the mark sits on the page's dark background exactly like the
   landing page. Top offset mirrors the landing mark's position — the landing's
   .landing block sits 1.5rem below the top of main.container (which no longer
   has top padding), and .landing-mark keeps its own 0.2rem margin. */
header.topbar-brand {
  max-width: 460px;
  margin: 1.5rem auto 0;
  padding: 0 1rem;
  text-align: center;
}
header.topbar-brand a { color: var(--gold); text-decoration: none; }

/* Favorites list */
.friend-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.friend-row .opponent {
  flex: 1;
}
.friend-remove {
  display: inline-flex;
  align-items: center;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  color: var(--muted);
}

.fav-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  color: var(--muted);
}
.fav-toggle.on {
  color: var(--gold);
}

.profile-dialog {
  text-align: center;
}
.profile-name {
  margin: 0 0 0.25rem;
}
.profile-ratio {
  font-size: 2rem;
  font-weight: 700;
  margin: 0.5rem 0 0;
}
.profile-record,
.profile-rating,
.profile-joined {
  opacity: 0.7;
  margin: 0.25rem 0;
}
/* Favorite and Block sit side by side, each taking half the row and
   centering its own label within that half. */
.profile-actions {
  display: flex;
  margin-top: 1rem;
}
.profile-actions .fav-toggle {
  flex: 1;
  justify-content: center;
}
/* Blocked state shows a single Unblock button — center it on its own row. */
.profile-blocked-note + .profile-block {
  display: flex;
  width: fit-content;
  margin: 1rem auto 0;
}
.profile-blocked-note {
  margin: 1rem 0 0;
  font-size: 0.85rem;
  opacity: 0.7;
}

/* BEGIN GENERATED SPRITE RULES */
/* Per-hero sprite art, generated from Oathfell.Rules.HeroCatalog.
   Do not hand-edit — run `mix oathfell.gen_sprite_css` and commit the result.
   sprite_guard_test.exs fails the build when this block goes stale. */
.sprite-bastion.side-red { background-image: url("/images/sprites/bastion_red-b3d557b7d63383e6799b3253e19b57e7.png?vsn=d"); }
.sprite-bastion.side-blue { background-image: url("/images/sprites/bastion_blue-7aa51ae8f844262f9e674cd2dcbca0cb.png?vsn=d"); }
.sprite-cassian.side-red { background-image: url("/images/sprites/cassian_red-145aa033ac4af44837d740a2fe710975.png?vsn=d"); }
.sprite-cassian.side-blue { background-image: url("/images/sprites/cassian_blue-d39430c0359bf57de5b3604d36a4dd3e.png?vsn=d"); }
.sprite-fulmen.side-red { background-image: url("/images/sprites/fulmen_red-f9e005d96455c495acc6b5d39fc9d071.png?vsn=d"); }
.sprite-fulmen.side-blue { background-image: url("/images/sprites/fulmen_blue-4b632bb9971e5bf7c5160a7a323453d6.png?vsn=d"); }
.sprite-mercy.side-red { background-image: url("/images/sprites/mercy_red-8ff1417bfa6fc4d5c84bdab21c6f1806.png?vsn=d"); }
.sprite-mercy.side-blue { background-image: url("/images/sprites/mercy_blue-060d149b5c4f1db4c18c8c8abe4a98c8.png?vsn=d"); }
.sprite-silas.side-red { background-image: url("/images/sprites/silas_red-f61bdcd279d4788f7938d15584c228d6.png?vsn=d"); }
.sprite-silas.side-blue { background-image: url("/images/sprites/silas_blue-ca4e48fa2b1be82447f1967fb53aec69.png?vsn=d"); }
.sprite-vlad.side-red { background-image: url("/images/sprites/vlad_red-5275c974be13082c9848eb9b9472c477.png?vsn=d"); }
.sprite-vlad.side-blue { background-image: url("/images/sprites/vlad_blue-1112fe157d248a3c7854300c8359396e.png?vsn=d"); }
.sprite-pandora.side-red { background-image: url("/images/sprites/pandora_red-ab21e710d17be191a114ef30b034def2.png?vsn=d"); }
.sprite-pandora.side-blue { background-image: url("/images/sprites/pandora_blue-63629b14e27ba8786f0b0ee935335d1b.png?vsn=d"); }
.sprite-magnus.side-red { background-image: url("/images/sprites/magnus_red-9e9d40018e95ccb4741e73482f93d02d.png?vsn=d"); }
.sprite-magnus.side-blue { background-image: url("/images/sprites/magnus_blue-213860f93021e7cae93d48e01a03a43b.png?vsn=d"); }
.sprite-maharet.side-red { background-image: url("/images/sprites/maharet_red-3f65facaad3a7637b305d15af88802bb.png?vsn=d"); }
.sprite-maharet.side-blue { background-image: url("/images/sprites/maharet_blue-fb2a089442fd6edfbdcadf0c38df2fe5.png?vsn=d"); }
.sprite-claudia.side-red { background-image: url("/images/sprites/claudia_red-ccdd1dabe6c5fd90b35e5d265191b4e7.png?vsn=d"); }
.sprite-claudia.side-blue { background-image: url("/images/sprites/claudia_blue-e2f9fd8e89a2f46d746a9ccf57f586ee.png?vsn=d"); }
.sprite-thane.side-red { background-image: url("/images/sprites/thane_red-db88ff83fe310e5425f04aac3c06e00b.png?vsn=d"); }
.sprite-thane.side-blue { background-image: url("/images/sprites/thane_blue-b64f59d7e974f0710f1dcf80996916eb.png?vsn=d"); }
.sprite-gunner.side-red { background-image: url("/images/sprites/gunner_red-3885008cfce6866f6c8101063a0c55eb.png?vsn=d"); }
.sprite-gunner.side-blue { background-image: url("/images/sprites/gunner_blue-3fff61eb61caf2c3fe352c8175be4917.png?vsn=d"); }
.sprite-grenadier.side-red { background-image: url("/images/sprites/grenadier_red-ee5559e24ccab4236ccd3237ffc5c4c5.png?vsn=d"); }
.sprite-grenadier.side-blue { background-image: url("/images/sprites/grenadier_blue-5cfabc9173750e71bf15542a9495748d.png?vsn=d"); }
.sprite-engineer.side-red { background-image: url("/images/sprites/engineer_red-ede637d30a370f2b253f5a1363dc3634.png?vsn=d"); }
.sprite-engineer.side-blue { background-image: url("/images/sprites/engineer_blue-a9618ae378194a566aaea49a78f3a6aa.png?vsn=d"); }
.sprite-annihilator.side-red { background-image: url("/images/sprites/annihilator_red-0dc6d45e3f6d13ef254f69cc0b2ae976.png?vsn=d"); }
.sprite-annihilator.side-blue { background-image: url("/images/sprites/annihilator_blue-708ac66e6a1f1ecf8e3ae109be8d9b59.png?vsn=d"); }
.sprite-grokk.side-red { background-image: url("/images/sprites/grokk_red-41cca3bf41f68ec485c5031792f5de1b.png?vsn=d"); }
.sprite-grokk.side-blue { background-image: url("/images/sprites/grokk_blue-f3734501f6c16eccdc8bd9b904b66ddc.png?vsn=d"); }
.sprite-zug.side-red { background-image: url("/images/sprites/zug_red-44956eaa0335a234cd24758f4527f803.png?vsn=d"); }
.sprite-zug.side-blue { background-image: url("/images/sprites/zug_blue-70f18fb9ee8ca8ca3a31ee52984e1719.png?vsn=d"); }
.sprite-mogra.side-red { background-image: url("/images/sprites/mogra_red-39336c349bd04c1d65f78215c0496984.png?vsn=d"); }
.sprite-mogra.side-blue { background-image: url("/images/sprites/mogra_blue-9761c8b2dbd3b66c0995f10fcd7f6670.png?vsn=d"); }
.sprite-bogrot.side-red { background-image: url("/images/sprites/bogrot_red-10917f60158230fb603868fddf358ef0.png?vsn=d"); }
.sprite-bogrot.side-blue { background-image: url("/images/sprites/bogrot_blue-abd8239c153b8b9db535e7ff2b1ac383.png?vsn=d"); }
.sprite-ugthar.side-red { background-image: url("/images/sprites/ugthar_red-ea796dbcab030dd5c8f78b67b29caeaa.png?vsn=d"); }
.sprite-ugthar.side-blue { background-image: url("/images/sprites/ugthar_blue-584f900304bf50ccdedfc7cfa0b262ed.png?vsn=d"); }
/* END GENERATED SPRITE RULES */

/* Changelog ------------------------------------------------------------- */
.changelog-page {
  max-width: 46rem;
  margin: 0 auto;
}
.changelog-intro {
  color: var(--muted);
  margin-bottom: 2rem;
}
.changelog-release {
  padding: 1.25rem 0 1.5rem;
  border-top: 1px solid var(--line);
}
.changelog-release-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem 1rem;
  margin-bottom: 0.75rem;
}
.changelog-version {
  font-size: 1.2rem;
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  flex-wrap: wrap;
}
.changelog-version-number {
  font-family: var(--font-title);
}
.changelog-date {
  color: var(--muted);
  font-size: 0.85rem;
  margin: 0;
}
.changelog-changes {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.changelog-change {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
}
.changelog-tag {
  flex: none;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0.1rem 0.4rem;
  border-radius: 0.4rem;
  border: 1px solid currentColor;
  line-height: 1.4;
}
.changelog-tag--added {
  color: var(--green);
}
.changelog-tag--improved {
  color: var(--blue);
}
.changelog-tag--fixed {
  color: var(--gold);
}
.changelog-change-text {
  color: var(--text);
}

/* Public footer -------------------------------------------------------- */
.site-footer {
  max-width: 48rem;
  margin: 2rem auto 1.5rem;
  padding: 1rem;
  text-align: center;
  border-top: 1px solid var(--line);
}
.site-footer-nav a {
  color: var(--muted);
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}
.site-footer-nav a:hover {
  color: var(--text);
}

/* Dev-only sound-effect preview harness (DevSfxLive). */
.dev-sfx { display: flex; gap: 1.5rem; align-items: flex-start; padding: 1rem; }
/* The tilted board-scene overflows its column and would otherwise paint (and
   hit-test) over the nav, so after one pick every nav click lands on the board
   instead of a row. A stacking context + opaque background keeps the nav on top
   and clickable; the board's overflow tucks harmlessly behind it. */
.dev-sfx-list { flex: 0 0 22rem; max-height: 90vh; overflow-y: auto;
  position: relative; z-index: 1; background: var(--bg); }
/* Army header (per-faction section); the effect family is a muted sub-heading. */
.dev-sfx-army { margin-bottom: 1.25rem; }
.dev-sfx-list h2 { margin: 1.25rem 0 0.4rem; font-size: 0.95rem; font-weight: 600;
  letter-spacing: 0.02em; opacity: 0.9; border-bottom: 1px solid rgba(255, 255, 255, 0.12);
  padding-bottom: 0.2rem; }
.dev-sfx-list h3 { margin: 0.6rem 0 0.2rem; font-size: 0.72rem; text-transform: uppercase;
  letter-spacing: 0.05em; opacity: 0.5; }
.dev-sfx-list ul { list-style: none; margin: 0; padding: 0; }
.dev-sfx-row { display: flex; justify-content: space-between; gap: 0.5rem; width: 100%;
  padding: 0.4rem 0.6rem; background: none; border: 1px solid transparent; border-radius: 6px;
  color: inherit; text-align: left; cursor: pointer; }
.dev-sfx-row:hover { background: rgba(255, 255, 255, 0.06); }
.dev-sfx-row.is-selected { border-color: currentColor; }
.dev-sfx-file { opacity: 0.6; font-size: 0.8rem; }
.dev-sfx-nosound { color: #d9a441; font-size: 0.8rem; }
.dev-sfx-stage { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.dev-sfx-replay { display: inline-flex; align-items: center; gap: 0.4rem;
  padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; }
.dev-sfx-candidates { width: 100%; max-width: 32rem; text-align: center; }
.dev-sfx-candidates h3 { margin: 0 0 0.5rem; font-size: 0.8rem; text-transform: uppercase;
  letter-spacing: 0.05em; opacity: 0.6; }
.dev-sfx-takes { display: flex; flex-wrap: wrap; gap: 0.4rem; justify-content: center; }
.dev-sfx-take { padding: 0.4rem 0.7rem; font-size: 0.9rem; cursor: pointer;
  border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 0.3rem;
  background: transparent; color: inherit; }
.dev-sfx-take:hover { background: rgba(255, 255, 255, 0.06); }
.dev-sfx-take.is-active { border-color: currentColor; background: rgba(255, 255, 255, 0.12); }
.dev-sfx-nowplaying { margin: 0.5rem 0 0; font-size: 0.85rem; opacity: 0.7; }
/* build unknown */
