/* 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 (Style A) */
  --green: #5cb85c;
  --silver: #c9d2de;
  --cyan: #4fd0d8;
  --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));
  /* 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; }

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); }

.nav-bell { background: none; border: none; color: var(--muted); padding: 0; line-height: 1; }
.nav-bell:hover { color: var(--text); border-color: transparent; }
.nav-bell.on { 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;
}

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"] {
  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;
}

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; }

/* Inline checkbox: box precedes its label text, both on one baseline. */
.field-checkbox {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.6rem 0 0.2rem;
  color: var(--muted);
}
.field-checkbox input { width: auto; margin: 0; }

/* ---------- 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 { max-width: max-content; 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); }

.winner-panel {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(20, 18, 28, 0.72);
  border-radius: 6px;
  z-index: 15;
}

.winner-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.8rem;
  text-align: center;
}

.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); }
}

: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 (Phase 1 experiment). ONE angle drives both the board
     tilt and the piece/FX counter-rotation, so the diorama retunes from here. */
  --board-tilt: 28deg;
  --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 in Task 4. */
  --board-squash: 0.82;
}

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

/* Zoom wrapper. Flat/un-zoomed it is an inert pass-through (block, no
   transform) so the board renders exactly as before. In 3/4 mode it becomes the
   scalable "camera" layer (see .board-scene.view-tilted .board-zoom below). */
.board-zoom { display: block; }

.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;
}

.tile {
  position: relative;
  aspect-ratio: 1;
  background: var(--tile);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  min-width: 0;
}

.tile:nth-child(even) { background: var(--tile-alt); }
.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's dashed outline is tinted by what the action does:   */
/* blue to move, red to attack, green to heal/revive/aid an ally. The intent */
/* class (target-move/attack/heal) overrides this default green.             */
.tile.targeted { outline: 3px dashed var(--green); outline-offset: -3px; z-index: 1; }
.tile.target-move { outline-color: var(--blue); }
.tile.target-attack { outline-color: var(--red); }
.tile.target-heal { outline-color: var(--green); }
.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: scale(1.18); opacity: 0.2; filter: brightness(1.7); }
  100% { transform: scale(1); opacity: 1; filter: none; }
}
/* Tilted mode: the piece's flat scale animation would otherwise replace the
   upright billboard counter-rotation (.view-tilted .piece) for its duration,
   flattening the sprite into the tilted ground plane before it snaps upright.
   Compose the counter-rotation into every keyframe's transform instead. */
.view-tilted .tile.fx-death .piece.corpse {
  animation: fx-death-tilted 0.55s ease-out;
}
@keyframes fx-death-tilted {
  0% { transform: rotateX(calc(-1 * var(--board-tilt))) scale(1.18); opacity: 0.2; filter: brightness(1.7); }
  100% { transform: rotateX(calc(-1 * var(--board-tilt))) 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: scale(0.85); }
  43% { opacity: 0; transform: scale(0.85); }
  60% { opacity: 1; transform: scale(1.06); }
  100% { opacity: 1; transform: scale(1); }
}
/* Tilted mode: see fx-death-tilted above — compose the upright billboard
   counter-rotation into every keyframe so the raised unit stands rather than
   lying flat in the tilted ground plane during the animation. */
.view-tilted .tile.fx-raise .piece.unit {
  animation: fx-raise-tilted 1.15s ease-out;
}
@keyframes fx-raise-tilted {
  0% { opacity: 0; transform: rotateX(calc(-1 * var(--board-tilt))) scale(0.85); }
  43% { opacity: 0; transform: rotateX(calc(-1 * var(--board-tilt))) scale(0.85); }
  60% { opacity: 1; transform: rotateX(calc(-1 * var(--board-tilt))) scale(1.06); }
  100% { opacity: 1; transform: rotateX(calc(-1 * var(--board-tilt))) scale(1); }
}

/* 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-skull-mask {
  --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;
}

/* Drag-zoom grip: hidden by default, shown only when the board is tilted
   (:has() is already used in this file). Pinned flush to the board's right
   BORDER (right: 0, not inset) and kept narrow/short so it sits over that
   border rather than the rightmost tile column's tappable interior — a wider
   inset grip was found to overlay real tile taps (phx-click/long-press dead
   zone) in the right column's mid rows. */
.board-zoom-handle { display: none; }
#board-wrap:has(#board-scene.view-tilted) .board-zoom-handle {
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 12px;
  height: 56px;
  z-index: 6; /* above pieces (2) and the FX overlay (3) */
  border-radius: 6px;
  background: rgba(20, 26, 38, 0.55);
  border: 1px solid rgba(120, 170, 255, 0.4);
  cursor: grab;
  touch-action: none; /* so a vertical drag doesn't scroll the page */
  user-select: none;
}
#board-wrap:has(#board-scene.view-tilted) .board-zoom-handle:active { cursor: grabbing; }
#board-wrap:has(#board-scene.view-tilted) .board-zoom-handle:focus-visible {
  outline: 2px solid var(--gold);
  outline-offset: 2px;
}
.board-zoom-grip {
  display: flex;
  flex-direction: column-reverse; /* first pip = bottom = zoomed out */
  align-items: center;
  justify-content: space-between;
  width: 8px;
  height: 34px;
}
/* Five level pips; the hook lights the one nearest the current zoom (gold). */
.board-zoom-pip {
  width: 6px;
  height: 3px;
  border-radius: 1.5px;
  background: rgba(190, 210, 240, 0.3);
  transition: background 120ms ease, box-shadow 120ms ease;
}
.board-zoom-pip.on {
  background: var(--gold);
  box-shadow: 0 0 5px var(--gold);
}

/* 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); }
}
/* 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 element carries the scale/fade show-hide.
   PLACEHOLDER: inner is an icon <svg>. Phase-2 real art swaps it for a
   `.attack-fx-frames` element — a 6-frame sprite sheet stepped by `steps(6)`
   (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). */
.attack-fx-ranged {
  --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;
}
/* Which sheet plays is the attack's type x range combination. */
.attack-fx-physical.attack-fx-melee .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-physical-melee-7dbcae0cd556a1e46fad8105dd1bfa66.png?vsn=d");
}
.attack-fx-physical.attack-fx-ranged .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-physical-ranged-7723b7154e6c74ae5480dc5e94c79d79.png?vsn=d");
}
.attack-fx-magic.attack-fx-melee .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-magic-melee-34c44a6561f9e580cc895888de926b3f.png?vsn=d");
}
.attack-fx-magic.attack-fx-ranged .attack-fx-frames {
  background-image: url("/images/sprites/attack-fx-magic-ranged-c4059bd2350162c3755cb678aa9b0fae.png?vsn=d");
}

/* 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-6eacc5eebd64c96fbe49040f42b624f3.png?vsn=d");
}
.heal-fx-revive .heal-fx-frames {
  background-image: url("/images/sprites/heal-fx-revive-9c0a9f0c8a6742d515c6f1382e33e785.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;
  }
}

/* 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; }
  /* Higher specificity than the flat rules above, so needed to also silence
     the tilted variants (.view-tilted ...) under reduced motion. */
  .view-tilted .tile.fx-death .piece.corpse { animation: none; }
  .view-tilted .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;
  font-size: 22px;
  opacity: 0.7;
  pointer-events: none;
}
.control-power .icon { color: var(--icon-power); }
.control-defense .icon { color: var(--icon-defense); }
.control-assault .icon { color: var(--icon-attack); }

.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%; }

/* 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%;
}

/* ---- 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 only the projected height; the tilted board is bottom-anchored and
     its receding top spills upward as visible overflow into freed headroom. */
  height: calc(var(--board-height) * var(--board-squash));
  perspective: var(--board-perspective);
  /* Zoom clips the scaled near edge out the bottom (and sides, to the column)
     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);
}
/* The camera layer: a 2D scale of the projected board, pinned to the far edge
   (transform-origin Y is measured onto --board-zoom-originY by the BoardView
   hook). Absolutely positioned to fill the scene so #board's bottom/left anchor
   is unchanged; preserve-3d so the board keeps its perspective tilt. */
.board-scene.view-tilted .board-zoom {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: scale(var(--board-zoom-scale, 1));
  transform-origin: 50% var(--board-zoom-originY, 0px);
}
/* The zoom wrapper is a flat full-scene plane in the preserve-3d scene;
   without this it sits in front of the receding tilted tiles and swallows
   every pointer hit (tiles become unclickable). Make it transparent to
   pointers and re-enable the board so tile clicks / long-press land again.
   The drag handle is a separate element and is unaffected. */
.view-tilted .board-zoom { pointer-events: none; }
.view-tilted .board { pointer-events: auto; }
.view-tilted .board {
  position: absolute; /* out of flow so the scene's height reclaims space */
  bottom: 0;
  left: 50%;
  transform-style: preserve-3d;
  transform: translateX(-50%) rotateX(var(--board-tilt));
  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 {
  /* Stand upright off the tilted ground plane (billboard). */
  transform: rotateX(calc(-1 * var(--board-tilt)));
  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;
}

.sprite {
  width: 48px;
  height: 48px;
  display: inline-block;
  background-repeat: no-repeat;
  background-position-x: center;
  /* Hero portraits are 64×512 vertical 8-direction strips: size the image so
     one 64px 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);
}

/* 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); }

.side-red .sprite-crystal { background-image: url("/images/sprites/crystal_red-0c3d89957b7365489d557c64ec251a3e.png?vsn=d"); }
.side-blue .sprite-crystal { background-image: url("/images/sprites/crystal_blue-7f34f7aaa58b6a90da562bc926364eb1.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-49b01c9521f0594f48243458bc62bbc2.png?vsn=d"); }
.side-blue .sprite-crystal-dmg1 { background-image: url("/images/sprites/crystal_dmg1_blue-4163fdecaa3e1a5bf47aa6861183f32b.png?vsn=d"); }
.side-red .sprite-crystal-dmg2 { background-image: url("/images/sprites/crystal_dmg2_red-7b645a769f52284fe5810aa085af904a.png?vsn=d"); }
.side-blue .sprite-crystal-dmg2 { background-image: url("/images/sprites/crystal_dmg2_blue-dd1993838f86cca7bb945ac66988d2de.png?vsn=d"); }
.side-red .sprite-crystal-dmg3 { background-image: url("/images/sprites/crystal_dmg3_red-bb540ba67d08c205f6017dec94ee0efc.png?vsn=d"); }
.side-blue .sprite-crystal-dmg3 { background-image: url("/images/sprites/crystal_dmg3_blue-0e94508dcfeaaeeb9cdcf540dcd7d6ee.png?vsn=d"); }
.side-red .sprite-crystal-rubble { background-image: url("/images/sprites/crystal_rubble_red-a4197f34de0402b7d278e580ba382647.png?vsn=d"); }
.side-blue .sprite-crystal-rubble { background-image: url("/images/sprites/crystal_rubble_blue-904fd6ba420bae562ced237e4e155d7d.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-7864189fb0bfb7451022c89739c85b47.png?vsn=d"); }
.piece.corpse .sprite.side-red { background-image: url("/images/sprites/gravestone_red-0947590a76b590367a86609b0dcd1bad.png?vsn=d"); }

.hp-bar {
  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. */
  width: calc(var(--board-width) + 1.2rem);
  margin: 0.5rem -0.6rem 0;
}

.hand {
  --h: 60px;
  display: flex;
  align-items: stretch;
  height: var(--h);
  width: 100%;
  max-width: calc(var(--board-width) + 1.2rem);
  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; }
.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;
  width: calc(var(--h) / 2);
  height: 100%;
  border: none;
  /* the cap is only --h/2 wide; 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; }
.act-cap.submit { background: var(--gold); color: #221a06; }
.act-cap.next { background: var(--panel-2); color: var(--gold); }

/* --- 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. */
.game-row {
  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);
}

.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; }
.dialog-action { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; }
.dialog-challenge { display: flex; flex-direction: column; gap: 0.3rem; }
.dialog-challenge label { margin: 0; }
.dialog-challenge-row { display: flex; gap: 0.5rem; }
.dialog-challenge-row input { flex: 1; min-width: 0; }
.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; }

.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(--border, #444);
  border-radius: 6px;
  color: var(--text);
  cursor: pointer;
  font: inherit;
  text-align: left;
}
.settings-list button:hover { border-color: var(--text); }
.settings-resign { color: var(--danger, #d9534f); }
.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; }

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

.dialog.dialog-wide { max-width: 30rem; }

.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: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.5rem;
  width: 100%;
  padding: 0.5rem 0.7rem;
  color: var(--text);
  border: 2px solid var(--line);
  border-radius: 8px;
  /* Slight skeuomorphic lift: a top-lit vertical gradient over the panel tone. */
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--panel-2) 92%, #fff 8%) 0%,
    var(--panel-2) 55%,
    color-mix(in srgb, var(--panel-2) 88%, #000 12%) 100%
  );
  cursor: pointer;
  text-align: left;
}
.army-option:hover { border-color: var(--gold); }
.army-option.selected {
  border-color: var(--accent, #6ea8fe);
  box-shadow: 0 0 0 1px var(--accent, #6ea8fe);
}
.army-option-name { font-weight: bold; font-size: 1rem; }
.army-option-lore { font-size: 0.8rem; color: var(--muted); }

/* Heroes on their own row; spells/items share the row below. Rows never wrap —
   tiles shrink to fit the button width (down to a floor) so a narrow phone gets
   smaller icons instead of an awkward third row. */
.army-preview { display: flex; flex-direction: column; gap: 0.4rem; width: 100%; }
.army-preview-row { display: flex; flex-wrap: nowrap; align-items: center; gap: 0.35rem; min-width: 0; }
.army-preview-support { gap: 0.35rem; }
.army-preview-group { display: flex; flex-wrap: nowrap; gap: 0.35rem; min-width: 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 .sprite { width: 100%; height: auto; max-width: 44px; aspect-ratio: 1; }
.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); }
.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-tabs {
  display: flex;
  gap: 0.25rem;
  border-bottom: 1px solid var(--line);
  margin: 0 0 0.75rem;
}
.codex-tab {
  flex: 1;
  padding: 0.5rem 0.25rem;
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  color: var(--muted);
  font: inherit;
  cursor: pointer;
}
.codex-tab:hover { color: var(--gold); }
.codex-tab.active {
  color: var(--gold);
  border-bottom-color: var(--gold);
}
.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-back {
  align-self: flex-start;
  background: none;
  border: none;
  color: var(--muted);
  cursor: pointer;
  padding: 0.25rem;
  margin-bottom: 0.1rem;
}
.codex-back:hover { color: var(--gold); }

/* 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,
.codex-army-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 {
  padding: 0.6rem 0.7rem;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--line);
  border-radius: 0.4rem;
}
.codex-army-passive { margin: 0.2rem 0; }
.codex-army-passive-name { font-weight: 600; color: var(--gold); margin-right: 0.35rem; }
.codex-army-lore { margin: 0.3rem 0 0; 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 board height is deterministic for the pan math:
     9 tiles * 44 + 8 gaps * 3 + 2 * 3 border = 426px */
  --tile-size: 44px;
  --pan-duration: 14s;
  width: max-content;
  max-width: 100%;
  height: 240px;
  margin: 1rem auto 1.2rem;
  overflow: hidden;
  border-radius: 6px;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent);
}

.board-snapshot .board {
  /* pans the full 426px board through the 240px window: 426 - 240 = 186 */
  animation: board-pan var(--pan-duration) ease-in-out infinite;
}

@keyframes board-pan {
  0% { transform: translateY(0); }
  50% { transform: translateY(-186px); }
  100% { transform: translateY(0); }
}

.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 .icon { color: var(--gold); }
.landing-support-text { text-align: center; }

@media (prefers-reduced-motion: reduce) {
  .board-snapshot .board {
    /* park at a mid slice showing units from both sides */
    animation: none;
    transform: translateY(-93px);
  }
}

/* --- 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;
}

/* --- 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 the board-themes spec's "Adding a
   theme"). */
.game {
  --backdrop-img: none;
  --backdrop-edge: var(--bg);
  /* 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);
}

.game.map-outdoor_solo {
  --tile: #4a4258;
  --tile-alt: #443d52;
  --line: #3a3450;
  --backdrop-img: url(/images/backdrop/forest-b3dcccbb4c46dfb85d07d0259a6d5a8b.png?vsn=d);
  --backdrop-edge: #161d28;
  /* 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-957167c92e60c29eb21fe754ffa526e0.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;
}

/* 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-de036424c9286479fcb27aef688e9c37.png?vsn=d);
  --backdrop-edge: #06101d;
  /* 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-bdc96e280008f8a9ee1a1b3fd036803d.png?vsn=d);
  --backdrop-edge: #08030e;
  /* 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-f11ec534ea7e225b29bf7be69e1b330e.png?vsn=d);
  --backdrop-edge: #1a1213;
  /* 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-zoom (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);
}

/* 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-back { justify-self: start; }
.game-header-info,
.game-header-settings { justify-self: end; }
.game-header-back,
.game-header-info,
.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-info:hover,
.game-header-settings:hover { color: var(--header-ink); }
.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));
}

/* 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-roster,
.dev-deck { list-style: none; margin: 0 0 0.75rem; padding: 0; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.dev-roster li a { color: var(--gold); text-decoration: none; }
.dev-roster li a:hover { text-decoration: underline; }
.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, #111);
  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-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; }

/* 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(--border, #444);
  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, mirrors .nav-bell.on */
.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, #888);
}

.fav-toggle {
  display: inline-flex;
  align-items: center;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  color: var(--muted, #888);
}
.fav-toggle.on {
  color: var(--accent, 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;
}
.profile-fav {
  margin-top: 1rem;
}

/* fantasy-tactics rebrand sprites (blue==red placeholder; team recolor pending, see JC-7) */
.sprite-bastion.side-red { background-image: url("/images/sprites/bastion_red-c7fbd3042e8454d7c156a5569d09bc33.png?vsn=d"); }
.sprite-bastion.side-blue { background-image: url("/images/sprites/bastion_blue-8805baab77795b156c7f2c0354d0a86c.png?vsn=d"); }
.sprite-cassian.side-red { background-image: url("/images/sprites/cassian_red-1fb5a1a95d9e17ba6e4e131fe7a3a317.png?vsn=d"); }
.sprite-cassian.side-blue { background-image: url("/images/sprites/cassian_blue-86900d5ff2ef5b28a36dc8c3ac00ffa5.png?vsn=d"); }
.sprite-fulmen.side-red { background-image: url("/images/sprites/fulmen_red-66d72fb896ec9c11059a12fe48279c52.png?vsn=d"); }
.sprite-fulmen.side-blue { background-image: url("/images/sprites/fulmen_blue-c5bb7f74d422e3b527ce19bd4a0449d3.png?vsn=d"); }
.sprite-mercy.side-red { background-image: url("/images/sprites/mercy_red-64a5a07080f82c4f60910eee4c930225.png?vsn=d"); }
.sprite-mercy.side-blue { background-image: url("/images/sprites/mercy_blue-99ab7cbea2f6ad6f63f8e6a3fad274c0.png?vsn=d"); }
.sprite-silas.side-red { background-image: url("/images/sprites/silas_red-3b058147c63292e2123117b2aeef3060.png?vsn=d"); }
.sprite-silas.side-blue { background-image: url("/images/sprites/silas_blue-44c8430a028c0307bb9b5c26ca056af2.png?vsn=d"); }
.sprite-vlad.side-red { background-image: url("/images/sprites/vlad_red-7de7ee21d5322c592c9aa3878bce474d.png?vsn=d"); }
.sprite-vlad.side-blue { background-image: url("/images/sprites/vlad_blue-7fda8149fce528572ff6b06eb23b2225.png?vsn=d"); }
.sprite-pandora.side-red { background-image: url("/images/sprites/pandora_red-8afdc2399b179484bcd19d41cc2d4964.png?vsn=d"); }
.sprite-pandora.side-blue { background-image: url("/images/sprites/pandora_blue-41440662350444a29283205e3175ff86.png?vsn=d"); }
.sprite-magnus.side-red { background-image: url("/images/sprites/magnus_red-79b41e817c7a72a90cf91c73730c64a7.png?vsn=d"); }
.sprite-magnus.side-blue { background-image: url("/images/sprites/magnus_blue-c14ec95690542a72506a4635fd1c3551.png?vsn=d"); }
.sprite-maharet.side-red { background-image: url("/images/sprites/maharet_red-14dc7605002bbfcfffdac027a8ce5747.png?vsn=d"); }
.sprite-maharet.side-blue { background-image: url("/images/sprites/maharet_blue-ec19e40b0b3e3b7f16754a987f97a933.png?vsn=d"); }
.sprite-claudia.side-red { background-image: url("/images/sprites/claudia_red-0fc4f8363972b17cee5f91768f4dd0a2.png?vsn=d"); }
.sprite-claudia.side-blue { background-image: url("/images/sprites/claudia_blue-5819277c38347b95352191342508c173.png?vsn=d"); }
.sprite-grokk.side-red { background-image: url("/images/sprites/grokk_red-928042a217cb15ca11ca9a0fbe7501c2.png?vsn=d"); }
.sprite-grokk.side-blue { background-image: url("/images/sprites/grokk_blue-890810c691a7f5d2aa265d3876c75195.png?vsn=d"); }
.sprite-zug.side-red { background-image: url("/images/sprites/zug_red-b9a03aed2f93498e908d21fd8eb0e439.png?vsn=d"); }
.sprite-zug.side-blue { background-image: url("/images/sprites/zug_blue-be085aaa5be3a77edd90809ebc015a38.png?vsn=d"); }
.sprite-mogra.side-red { background-image: url("/images/sprites/mogra_red-d3d80b25787389cb48a25702da9c172c.png?vsn=d"); }
.sprite-mogra.side-blue { background-image: url("/images/sprites/mogra_blue-ccfa6daeb61624bf93dad31922673794.png?vsn=d"); }
.sprite-bogrot.side-red { background-image: url("/images/sprites/bogrot_red-d5a253f54a7f5b122ba048c874b8708c.png?vsn=d"); }
.sprite-bogrot.side-blue { background-image: url("/images/sprites/bogrot_blue-40b37bf213d1f520ead7c228d8375f39.png?vsn=d"); }
.sprite-ugthar.side-red { background-image: url("/images/sprites/ugthar_red-fa7cfcb1972bb96750bde80475d6a149.png?vsn=d"); }
.sprite-ugthar.side-blue { background-image: url("/images/sprites/ugthar_blue-02789b1d5f7fb12475659ccadcb91ff9.png?vsn=d"); }
