* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  /* Single source of truth for "this is quest content," used across the
     checkpoint reveal (when quest-tied), item-pickup card, activation/
     completion card, and the quest-action side panel. A weathered
     verdigris/patina tone — aged copper-green rather than a bright
     saturated "leaf" green — to sit alongside the rest of the game's
     warm gold/aged-bronze palette instead of reading as a modern UI
     accent color dropped on top of it.
     --quest-green-glow is the same hue at lower alpha, for ambient glow
     use rather than hard edges. */
  --quest-green:      #6d8f76;
  --quest-green-glow: rgba(109,143,118,0.4);
  /* Same hue, higher alpha — for glow effects that need to read clearly
     at a glance (e.g. the tracked/active checkpoint aura) without
     changing the hue itself away from the established muted tone. */
  --quest-green-glow-strong: rgba(109,143,118,0.75);

  /* Same idea as --quest-green above, for the gate/threshold family —
     matches the violet already used for gate glyphs/status elsewhere
     (.sp-glyph--gate, .sp-status--gate) rather than introducing a new
     hue. Used on the threshold-crossing confirm card and the post-
     crossing reveal, both of which previously had no color identity
     of their own. */
  --gate-violet:      #9b5fd4;
  --gate-violet-glow: rgba(155,95,212,0.4);

  /* Locus/character-contact family — a touch softer/cooler than
     --gate-violet so the two read as related but distinct, matching
     the existing locus marker + side-panel palette (#9070b8/#7b4fd4). */
  --locus-violet:      #9070c8;
  --locus-violet-glow: rgba(144,112,200,0.4);
}

:root {
  --icon-quest: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0L9.7 6.3L16 8L9.7 9.7L8 16L6.3 9.7L0 8L6.3 6.3Z'/%3E%3C/svg%3E");
  --icon-trigger: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0L16 8L8 16L0 8Z'/%3E%3C/svg%3E");
  --icon-gate: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect x='1' y='1' width='14' height='14' rx='1.5' fill='none' stroke='black' stroke-width='1.7'/%3E%3Crect x='4.75' y='4.75' width='6.5' height='6.5'/%3E%3C/svg%3E");
  --icon-locus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0L15 4.2V11.8L8 16L1 11.8V4.2Z'/%3E%3C/svg%3E");
  --icon-resolved: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M2 8.3L6.2 12.5L14 3.8' fill='none' stroke='black' stroke-width='2.3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

/* Mask-based twins of the ICON_GLYPHS SVGs (utils.js), for the few
   places an icon has to come from a CSS ::before instead of markup
   (action-button prefixes). Painted with currentColor via mask-image,
   so they take the button's text color like the characters they
   replace. Each selector sets --icon-mask to pick its shape. */
.sp-action-btn[data-action="qa-action"]::before,
.sp-action-btn--locus::before,
.sp-action-btn--gate::before,
#quest-footer-cta.qf-cta--ready::before {
  content: '';
  display: inline-block;
  width: 1em;
  height: 1em;
  margin-right: 0.6em;
  vertical-align: -0.125em;
  background: currentColor;
  -webkit-mask: var(--icon-mask) center / contain no-repeat;
          mask: var(--icon-mask) center / contain no-repeat;
}

/* ── Popups/overlays: no pinch-zoom ──
   Every full-screen or docked popup, stated explicitly (with its
   descendants) rather than relying only on the html/body rule above —
   a scrolling or transformed popup is exactly where a browser can end
   up treating touch-action as a per-element decision. pan-x pan-y
   keeps scrolling working and leaves pinch/double-tap zoom off. The
   JS guard in index.html backs this up for browsers that ignore it. */
.reveal-overlay, .reveal-overlay *,
#side-panel, #side-panel *,
#bottom-sheet, #bottom-sheet *,
#confirm-modal, #confirm-modal *,
#confirm-modal-backdrop, #quest-action-backdrop, #sheet-backdrop,
#disclaimer-screen, #disclaimer-screen *,
#premise-screen, #premise-screen *,
#loading-screen, #gps-error-screen, #gps-error-screen *,
#update-banner, #update-block-screen, #update-block-screen *,
#quest-footer, #quest-footer *,
#debug-panel, #debug-panel * {
  touch-action: pan-x pan-y;
}

/* ── Type-icon glyphs ──
   Shared sizing for the inline-SVG icons in ICON_GLYPHS (utils.js),
   which replace the ◈ ▣ ⬡ ✦ ✓ ⚠ characters used as type-icons across
   journal badges, on-map log toasts, side-panel headers, quest/survey
   eyebrows, and reveal cinematics. Sized/positioned to sit inline
   with text exactly like the character it replaces did — 1em square,
   nudged down slightly to sit on the baseline instead of floating
   above it. No color of its own: fill: currentColor means it always
   picks up whatever color the surrounding text/badge already set. */
.icon-glyph {
  width: 1em;
  height: 1em;
  display: inline-block;
  vertical-align: -0.125em;
  flex-shrink: 0;
  fill: currentColor;
}

html, body {
  /* Disables native pinch-to-zoom and double-tap-to-zoom on the page
     content — the viewport meta tag's user-scalable=no alone isn't
     reliable across modern mobile browsers, which often ignore it for
     accessibility reasons. The map itself is the deliberate exception:
     MapLibre's own pinch-zoom/rotate (touchZoomRotate, map.js) is left
     enabled, and the JS-level guard in index.html explicitly skips
     touches landing on #map — same exception the double-tap guard
     already carved out below. Everything else (popups, menus, the rest
     of the page) stays fully blocked. */
  touch-action: pan-x pan-y;

  /* No text inputs exist anywhere in this app — auth goes through a
     Google Identity Services popup, not an in-app form — so blocking
     selection everywhere is safe with no exceptions to carve out.
     Without this, a long-press-drag on journal/quest/dossier text (or
     even a fast double-tap) highlights it like a webpage, which breaks
     the "this is an app, not a website" feel the rest of the UI is
     going for. -webkit-touch-callout additionally suppresses iOS's
     long-press context menu (copy/save/share) on text and images. */
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

body {
  background: #110e0a;
  font-family: 'Courier New', Courier, monospace;
  color: #c8b88a;
  height: 100dvh;
  overflow: hidden;
  -webkit-tap-highlight-color: transparent;
}

#map {
  width: 100vw;
  height: 100dvh;
}

/* Fallback/default (matches MAP_FILTER_DAY in map.js) — overridden
   live by _updateNightMode(), which interpolates this toward a
   day-for-night grade as getNightAmount() rises. This static value
   only ever shows before that first runs. */
#map canvas {
  filter: saturate(80%) brightness(85%) hue-rotate(15deg) contrast(1.08);
}

/* MapLibre's own stylesheet gives markers position:absolute but no
   z-index at all. Without an explicit value here, #fog-of-war (which
   DOES have an explicit z-index) would end up stacking above markers
   despite living outside #map's own DOM subtree — the opposite of the
   intent that checkpoints/avatar stay visible through the fog. This
   only needs to clear the fog's z-index (5), well below the HUD/sheet/
   FAB (10+), so it doesn't affect anything else's stacking. */
.maplibregl-marker {
  z-index: 6;
}

.maplibregl-ctrl-bottom-left {
  display: none !important;
}

/* ── HUD ── */
#hud {
  position: fixed;
  top: 0; left: 0; right: 0;
  pointer-events: none;
  z-index: 10;
}

#header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  padding: 18px 20px 0;
}

/* Status badge — warning-only now. Hidden whenever GPS is fine (no
   positive "live" state anymore — a working fix isn't worth a
   permanent on-screen confirmation); shows only for a GPS timeout,
   the one failure case with no other feedback (see onGPSError,
   map.js). Denied/unavailable get their own full-screen treatment
   instead and don't also need this. */
#status-badge {
  font-size: 13px;
  letter-spacing: 0.12em;
  color: #3a4a3a;
  padding: 4px 10px;
  background: transparent;
  border: none;
  opacity: 0;
  transition: opacity 0.4s;
}

#status-badge.active {
  opacity: 1;
  color: #c04a44;
}

/* ── Compass button ── */
#compass-btn {
  position: fixed;
  bottom: calc(92px + env(safe-area-inset-bottom));
  right: 20px;
  z-index: 10;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: rgba(16,12,8,0.92);
  border: 1px solid #3a2a1a;
  cursor: pointer;
  box-shadow: 0 2px 12px rgba(0,0,0,0.5);
  transition: border-color 0.2s, box-shadow 0.2s;
  user-select: none;
}

#compass-btn.heading {
  border-color: #c8a84e;
  box-shadow: 0 0 14px rgba(200,168,78,0.2);
}

#compass-btn svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

#compass-btn-arrow {
  transition: transform 0.4s ease;
}

#compass-btn-label {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 5px;
  text-align: center;
  pointer-events: none;
  font-family: 'Courier New', monospace;
  font-size: 9px;
  letter-spacing: 0.12em;
  color: #6a5a3a;
  line-height: 1;
}

/* ── FAB (opens bottom sheet) ── */
/* --fab-glow-rgb/-alpha/-blur are set live by setFabProximityGlow
   (map.js), called from the same runResolutionEngine proximity ramp
   (engine.js) that drives the map avatar's own lantern glow — so the
   FAB brightens and shifts amber → violet in exact sync with the
   in-game lantern, the same "something's close" signal in two places
   instead of one easy to miss on the map itself. Defaults below are
   the original static amber glow, so the button still looks right if
   JS hasn't run yet (e.g. this file loading before map.js executes). */
#fab {
  position: fixed;
  bottom: calc(24px + env(safe-area-inset-bottom));
  right: 20px;
  z-index: 20;
  width: 56px;
  height: 56px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: radial-gradient(circle at 38% 28%, #1c1610 0%, #0c0906 65%, #050403 100%);
  border: 1px solid #6a4a12;
  box-shadow: 0 2px 14px rgba(0,0,0,0.6),
              0 0 var(--fab-glow-blur, 18px) rgba(var(--fab-glow-rgb, 255,198,110), var(--fab-glow-alpha, 0.32)),
              inset 0 0 10px rgba(0,0,0,0.55);
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: border-color 0.2s, box-shadow 0.4s ease, transform 0.15s;
}

#fab-avatar-canvas {
  width: 32px;
  height: 32px;
  display: block;
  animation: fabBreathe 3.4s ease-in-out infinite;
}

@keyframes fabBreathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.05); }
}

#fab:active {
  transform: scale(0.94);
}

#fab-badge {
  position: absolute;
  top: 2px;
  right: 2px;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: #e0453f;
  border: 1.5px solid #110e0a;
  box-shadow: 0 0 6px rgba(224,69,63,0.7);
  opacity: 0;
  transform: scale(0.4);
  pointer-events: none;
  transition: opacity 0.25s ease, transform 0.25s ease;
}

#fab-badge.visible {
  opacity: 1;
  transform: scale(1);
}

/* ── Recenter button ── */
#recenter-btn {
  position: fixed;
  bottom: calc(156px + env(safe-area-inset-bottom));
  right: 20px;
  z-index: 10;
  width: 52px;
  height: 52px;
  border-radius: 50%;
  background: rgba(16,12,8,0.92);
  border: 1px solid #3a2a1a;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0,0,0,0.4);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease, border-color 0.2s;
  user-select: none;
}

#recenter-btn.visible {
  opacity: 1;
  pointer-events: all;
}

#recenter-icon {
  font-size: 18px;
  color: #6a5a3a;
  line-height: 1;
  transition: color 0.2s;
}

#recenter-btn.visible:hover #recenter-icon { color: #c8a84e; }

/* ── Log feed ── */
#log-feed {
  position: fixed;
  bottom: calc(88px + env(safe-area-inset-bottom));
  left: 0; right: 0;
  z-index: 10;
  padding: 0 16px;
  pointer-events: none;
  max-width: 100%;
}

.log-entry {
  background: rgba(16,12,8,0.93);
  border: 1px solid rgba(200,168,78,0.18);
  border-left: 3px solid rgba(200,168,78,0.35);
  border-radius: 0 10px 10px 0;
  padding: 10px 14px;
  margin-top: 8px;
  font-size: 13px;
  line-height: 1.6;
  color: #a09060;
  letter-spacing: 0.03em;
  animation: fadeSlideUp 0.4s ease forwards;
  max-width: 480px;
}

.log-entry.trigger {
  border-left-color: #c8a84e;
  color: #d4bc80;
}

.log-entry.gate {
  border-left-color: #9b5fd4;
  color: #c9a8e8;
  background: rgba(14,10,18,0.93);
}

.log-entry.locus {
  border-left-color: #7b4fd4;
  color: #b89ce0;
  background: rgba(14,10,18,0.93);
}

.log-icon {
  display: inline-flex;
  vertical-align: -4px;
  margin-right: 5px;
  width: 16px;
  height: 16px;
}
.log-icon svg { display: block; width: 100%; height: 100%; }

.log-entry .log-meta {
  font-size: 12px;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: #6a5030;
  margin-bottom: 4px;
}

.log-entry.trigger .log-meta { color: #a08040; }
.log-entry.gate    .log-meta { color: #8a5cc4; }
.log-entry.locus   .log-meta { color: #6f48b8; }

/* Tap-to-expand for long toasts (e.g. a checkpoint's full text on
   revisit) — #log-feed itself stays pointer-events:none so empty space
   in the feed never blocks map taps; only expandable entries opt back
   in. The chevron is a static hint rather than an animated disclosure
   triangle — this is a toast, not a settings panel, so the affordance
   should read as "there's more" without drawing much attention. */
.log-entry.log-expandable {
  cursor: pointer;
  pointer-events: auto;
}
.log-entry.log-expandable .log-text::after {
  content: ' ▾';
  font-size: 9px;
  opacity: 0.55;
}
.log-entry.log-expandable.log-expanded .log-text::after {
  content: ' ▴';
}

@keyframes fadeSlideUp {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

.log-entry.log-dying {
  opacity: 0;
  transform: translateX(-10px);
  max-height: 0;
  padding-top: 0;
  padding-bottom: 0;
  margin-top: 0;
  overflow: hidden;
  transition: opacity 0.5s ease, transform 0.5s ease,
              max-height 0.4s ease 0.15s, padding 0.3s ease 0.25s, margin 0.3s ease 0.25s;
}

/* ── Player dot ── */
.player-pulse {
  width: 30px;
  height: 42px;
  pointer-events: none;
}

.player-pulse svg {
  width: 100%;
  height: 100%;
  display: block;
  overflow: visible;
}

.shadow-figure-body {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation: shadowBreathe 3.6s ease-in-out infinite;
}

.shadow-figure-ground {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: shadowGroundPulse 3.6s ease-in-out infinite;
}

@keyframes shadowBreathe {
  0%, 100% { transform: scale(1); opacity: 0.92; }
  50%      { transform: scale(1.035); opacity: 1; }
}

@keyframes shadowGroundPulse {
  0%, 100% { transform: scale(1); opacity: 0.55; }
  50%      { transform: scale(1.12); opacity: 0.35; }
}

/* ─────────────────────────────────────────────
   CHECKPOINT MARKERS — gold rune wards (inline SVG)
   ───────────────────────────────────────────── */

@keyframes arcDriftCW   { from { transform: rotate(0deg); }   to { transform: rotate(360deg); } }
@keyframes arcDriftCCW  { from { transform: rotate(0deg); }   to { transform: rotate(-360deg); } }
@keyframes runePulse    { 0%, 100% { opacity: 0.85; transform: scale(1); }   50% { opacity: 1; transform: scale(1.25); } }
@keyframes runeGroupPulse { 0%, 100% { opacity: 0.82; } 50% { opacity: 1; } }
@keyframes markerFadeIn { from { opacity: 0; transform: scale(0.6); } to { opacity: 1; transform: scale(1); } }
@keyframes nearbyRipple {
  0%   { transform: scale(0.85); opacity: 0.7; }
  60%  { transform: scale(1.55); opacity: 0; }
  100% { transform: scale(1.55); opacity: 0; }
}

/* Distance/quest-based visibility for checkpoint/locus/gate markers.
   Applied as a class instead of ever calling marker.remove()/.addTo(map) —
   the Marker instance stays attached to the map permanently so MapLibre's
   own position-update listeners on it are never torn down and reattached.
   See syncMarkerVisibility() in map.js for why. */
.cp-marker.is-hidden,
.locus-marker.is-hidden,
.gate-marker.is-hidden {
  display: none;
}

.cp-marker { cursor: pointer; }

.cp-marker-inner {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: markerFadeIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}

/* Backdrop disc — dark radial vignette behind each rune for contrast on any tile */
.cp-marker-inner::before {
  content: '';
  position: absolute;
  bottom: 2px;
  left: 50%;
  transform: translateX(-50%);
  border-radius: 50%;
  background: radial-gradient(ellipse, rgba(10,7,4,0.76) 28%, rgba(10,7,4,0) 74%);
  pointer-events: none;
  z-index: 0;
}

/* Ripple ring — proximity/discovery states only (nearby, discovered-
   nearby, liminal-nearby, etc.) — NOT quest relevance. That used to
   also be layered on here via cp-marker--quest-tracked/active, which
   caused a real bug (see .cp-quest-ring's comment below for the full
   story); the quest ring now lives on its own dedicated element
   instead. Keep it that way — don't add quest-glow rules back onto
   this pseudo-element. */
.cp-marker-inner::after {
  content: '';
  position: absolute;
  border-radius: 50%;
  border: 1.5px solid rgba(200,168,78,0.55);
  pointer-events: none;
  z-index: 0;
  opacity: 0;
}

/* ── One-shot discovery/unlock ripple ──
   A genuinely separate, temporary element (_appendMarkerRipple, map.js)
   — NOT the ::after ring just above, which is an ongoing proximity
   indicator that stays as long as it applies. This one plays once and
   removes itself, at three different "just became available" moments:
   a checkpoint's first discovery, a locus unlocking, or a gate
   becoming openable. Centered via top/left 50% by default, since each
   marker's *-inner container is the nearest positioned ancestor
   (position: relative) — color and, for the gate, position are
   overridden per variant below. */
.cp-discovery-ripple {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 2.5px solid rgba(200,168,78,0.9);
  pointer-events: none;
  z-index: 2;
  animation: cpDiscoveryRipple 0.9s cubic-bezier(0.15, 0.7, 0.3, 1) forwards;
}
/* Liminal checkpoint — matches the lantern's own settled color for
   that plane. Gate — same violet family, one shade deeper, matching
   .sp-glyph--gate elsewhere. Locus — its own lighter purple, matching
   .sp-glyph--locus, kept visibly distinct from the other two. */
.cp-discovery-ripple--liminal { border-color: rgba(155,95,212,0.9); }
.cp-discovery-ripple--gate    { border-color: rgba(155,95,212,0.9); }
.cp-discovery-ripple--locus   { border-color: rgba(192,132,252,0.9); }
@keyframes cpDiscoveryRipple {
  0%   { transform: translate(-50%, -50%) scale(0.3); opacity: 0.95; border-width: 3.5px; }
  60%  { opacity: 0.45; }
  100% { transform: translate(-50%, -50%) scale(2.6); opacity: 0; border-width: 0.5px; }
}

/* .gate-marker-inner is bottom-anchored (align-items: flex-end) rather
   than centered like the checkpoint/locus containers, so centering
   this at top/left 50% would land on empty space above the actual
   tower graphic instead of on it. Anchors from the bottom instead,
   roughly matching .gate-backdrop's own vertical center (that ellipse
   is bottom:0, 30px tall). Worth a visual once-over on a real gate
   marker — this is a reasonable estimate, not a measured exact center. */
.cp-discovery-ripple--anchor-bottom {
  top: auto;
  bottom: 14px;
  animation-name: cpDiscoveryRippleBottom;
}
@keyframes cpDiscoveryRippleBottom {
  0%   { transform: translate(-50%, 50%) scale(0.3); opacity: 0.95; border-width: 3.5px; }
  60%  { opacity: 0.45; }
  100% { transform: translate(-50%, 50%) scale(2.6); opacity: 0; border-width: 0.5px; }
}

.cp-rune-svg {
  display: block;
  overflow: visible;
  position: relative;
  z-index: 1;
}

.cp-rune-ring-group { transform-origin: 22px 22px; }

.cp-rune-arcs circle { transform-origin: 22px 22px; }
.cp-rune-arcs circle:nth-child(1) { animation: arcDriftCW  36s linear infinite; }
.cp-rune-arcs circle:nth-child(2) { animation: arcDriftCCW 26s linear infinite; }
.cp-rune-arcs circle:nth-child(3) { animation: arcDriftCW  18s linear infinite; }
.cp-rune-arcs circle:nth-child(4) { animation: arcDriftCCW 13s linear infinite; }

.cp-rune-core {
  transform-origin: 22px 22px;
  animation: runePulse 2.8s ease-in-out infinite;
}

/* ── default ── */
.cp-marker--default .cp-rune-svg        { width: 44px; height: 44px; }
.cp-marker--default .cp-marker-inner::before { width: 54px; height: 20px; }
.cp-marker--default .cp-rune-ring-group {
  opacity: 0.88;
  animation: runeGroupPulse 4s ease-in-out infinite;
}
.cp-marker--default .cp-rune-arcs circle:nth-child(1) { animation-duration: 36s; }
.cp-marker--default .cp-rune-arcs circle:nth-child(2) { animation-duration: 26s; }

/* ── tracked ── */
.cp-marker--tracked .cp-rune-svg        { width: 44px; height: 44px; }
.cp-marker--tracked .cp-marker-inner::before { width: 54px; height: 20px; }
.cp-marker--tracked .cp-rune-ring-group { opacity: 1; }
.cp-marker--tracked .cp-rune-arcs circle:nth-child(1) { animation-duration: 20s; }
.cp-marker--tracked .cp-rune-arcs circle:nth-child(2) { animation-duration: 14s; }
.cp-marker--tracked .cp-rune-arcs circle:nth-child(3) { animation-duration: 9s; }
.cp-marker--tracked .cp-rune-arcs circle:nth-child(4) { animation-duration: 6s; }

/* ── active ── */
.cp-marker--active .cp-rune-svg         { width: 44px; height: 44px; }
.cp-marker--active .cp-marker-inner::before { width: 54px; height: 20px; }
.cp-marker--active .cp-rune-ring-group {
  opacity: 0.94;
  animation: runeGroupPulse 3.4s ease-in-out infinite;
}
.cp-marker--active .cp-rune-arcs circle:nth-child(1) { animation-duration: 28s; }
.cp-marker--active .cp-rune-arcs circle:nth-child(2) { animation-duration: 20s; }
.cp-marker--active .cp-rune-arcs circle:nth-child(3) { animation-duration: 14s; }

/* ── quest glow — jade, this checkpoint is the next step for an active
   quest ── independent of the base look above (platinum/gold/purple):
   applied via a separate --quest-tracked/--quest-active class so a
   checkpoint that's already been surveyed (platinum) still reads as
   platinum, just with this glow layered on top, instead of reverting
   to gold. A checkpoint used by two quests at once still only shows
   one glow tier — tracked outranks active, since that's the one thing
   the player is actually being pointed toward right now.
   No size growth, so it can't be confused with "nearby" (which does
   grow, because that one genuinely means "you're in range"). The jade
   glow carries all the signal instead: a 4-layer stacked drop-shadow
   halo, breathing (pulsing intensity) so it stays eye-catching rather
   than a static fringe, plus a jade ripple ring pulsing outward. */
@keyframes questGlowPulseStrong {
  0%, 100% {
    filter:
      drop-shadow(0 0 4px var(--quest-green))
      drop-shadow(0 0 10px var(--quest-green))
      drop-shadow(0 0 18px var(--quest-green-glow-strong))
      drop-shadow(0 0 30px var(--quest-green-glow));
  }
  50% {
    filter:
      drop-shadow(0 0 6px var(--quest-green))
      drop-shadow(0 0 16px var(--quest-green))
      drop-shadow(0 0 28px var(--quest-green-glow-strong))
      drop-shadow(0 0 42px var(--quest-green-glow));
  }
}
.cp-marker--quest-tracked .cp-rune-svg {
  animation: questGlowPulseStrong 2.2s ease-in-out infinite;
}

/* ── active — same jade glow, quieter (an active quest, but not
   the one currently tracked) ── */
@keyframes questGlowPulseSoft {
  0%, 100% {
    filter:
      drop-shadow(0 0 3px var(--quest-green))
      drop-shadow(0 0 9px var(--quest-green-glow-strong))
      drop-shadow(0 0 18px var(--quest-green-glow));
  }
  50% {
    filter:
      drop-shadow(0 0 4px var(--quest-green))
      drop-shadow(0 0 13px var(--quest-green-glow-strong))
      drop-shadow(0 0 24px var(--quest-green-glow));
  }
}
.cp-marker--quest-active .cp-rune-svg {
  animation: questGlowPulseSoft 2.6s ease-in-out infinite;
}
/* This tier used to be glow-only, with tracked as the only tier that
   also got a ring — but with nothing to contrast against, "glow only"
   read as an inconsistency rather than a deliberate lesser tier (it's
   the kind of thing that makes someone double-check whether it's a
   bug). A visible-but-clearly-quieter ring settles that: smaller than
   tracked's (66px vs 78px), noticeably dimmer border, and a gentle
   breathing pulse rather than tracked's outward-expanding ripple — so
   the two tiers read as "the same idea, different volume" rather than
   two unrelated states that happen to share a glow color. */
@keyframes questActiveRingPulse {
  0%, 100% { opacity: 0.4; transform: scale(0.97); }
  50%      { opacity: 0.75; transform: scale(1.04); }
}

/* ── The quest ring itself — a dedicated element, not a shared
   pseudo-element ──
   This used to live on .cp-marker-inner::after, the SAME pseudo-
   element the proximity/discovery ripple ring (nearby, discovered-
   nearby, liminal-nearby...) also uses. Both were layered on via
   independent classes at equal CSS specificity, so for any checkpoint
   carrying both at once, whichever rule happened to be declared LATER
   in this file silently won the whole property set — worst case,
   .cp-marker--discovered-nearby (declared far below, and the single
   most common real case: revisiting an already-surveyed checkpoint a
   quest still needs) fully overrides width, color, AND animation,
   erasing the jade ring — and the tracked/active distinction with it —
   completely. A dedicated element sidesteps the whole bug class rather
   than patching this one combination: see buildCpQuestRing (map.js).
   Explicitly centered (top/left/transform) rather than relying on the
   flex-parent implicit-centering trick .cp-marker-inner::after uses,
   so it doesn't depend on sibling order in .cp-marker-inner. */
.cp-quest-ring {
  position: absolute;
  top: 50%;
  left: 50%;
  border-radius: 50%;
  pointer-events: none;
  z-index: 0;
}
/* Centered via fixed-px margin (half of each tier's own width/height),
   NOT a transform:translate(-50%,-50%) — nearbyRipple and
   questActiveRingPulse below are shared animations (nearbyRipple
   especially, used all over the proximity/discovery states) that set
   `transform: scale(...)` directly in their own keyframes with no
   translate component. That's fine for THEIR original elements, which
   rely on implicit flex-parent centering with no base transform to
   lose — but a translate-based centering here would get silently
   overwritten the instant either animation started, decentering the
   ring for its entire animated life. Margin-based centering sidesteps
   that entirely since it doesn't touch `transform` at all — the scale
   animation then scales from the ring's own center (transform-origin
   defaults to 50% 50%), which is exactly the growing-ripple look
   either animation is going for. */
.cp-quest-ring--tracked {
  width: 78px; height: 78px;
  margin: -39px 0 0 -39px;
  border: 2.5px solid rgba(109,143,118,0.95);
  animation: nearbyRipple 2.2s ease-out infinite;
}
.cp-quest-ring--active {
  width: 66px; height: 66px;
  margin: -33px 0 0 -33px;
  border: 1.5px solid rgba(109,143,118,0.5);
  animation: questActiveRingPulse 3s ease-in-out infinite;
}

/* ── discovered — platinum, nearly still ── */
.cp-marker--discovered .cp-rune-svg     { width: 44px; height: 44px; }
.cp-marker--discovered .cp-marker-inner::before { width: 54px; height: 20px; }
.cp-marker--discovered .cp-rune-ring-group { opacity: 0.55; }
.cp-marker--discovered .cp-rune-arcs circle:nth-child(1) { animation-duration: 60s; }
.cp-marker--discovered .cp-rune-arcs circle:nth-child(2) { animation-duration: 90s; }
.cp-marker--discovered .cp-rune-core   { animation: none; opacity: 0.6; }

/* ── nearby — urgent, radiant ── */
.cp-marker--nearby .cp-rune-svg         { width: 62px; height: 62px; }
.cp-marker--nearby .cp-marker-inner::before { width: 76px; height: 26px; }
.cp-marker--nearby .cp-rune-ring-group  { opacity: 1; }
.cp-marker--nearby .cp-rune-arcs circle:nth-child(1) { animation-duration: 15s; }
.cp-marker--nearby .cp-rune-arcs circle:nth-child(2) { animation-duration: 10s; }
.cp-marker--nearby .cp-rune-arcs circle:nth-child(3) { animation-duration: 7s; }
.cp-marker--nearby .cp-rune-arcs circle:nth-child(4) { animation-duration: 5s; }
.cp-marker--nearby .cp-rune-core        { animation-duration: 1.2s; }
.cp-marker--nearby .cp-marker-inner::after {
  width: 62px; height: 62px;
  animation: nearbyRipple 2.2s ease-out infinite;
}

/* ─────────────────────────────────────────────
   LIMINAL CHECKPOINT MARKERS
   Same rune geometry AND same gold (undiscovered) /
   platinum (discovered) palettes as normal checkpoints —
   see buildRuneSVG's isDiscovered branch in map.js. The
   "wrongness" tells are layered on top instead of replacing
   the face: reversed/drifting rotation, a dark void-pulse
   core, a purple-tinted backdrop ellipse, and a full ring of
   purple fire jets around the rim (.cp-fire-ring, below).
   ───────────────────────────────────────────── */

@keyframes arcDriftCCWDrift {
  0%   { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.4,0,0.6,1); }
  50%  { transform: rotate(-188deg); animation-timing-function: cubic-bezier(0.4,0,0.6,1); }
  100% { transform: rotate(-360deg); }
}
@keyframes arcDriftCWDrift {
  0%   { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.4,0,0.6,1); }
  50%  { transform: rotate(172deg); animation-timing-function: cubic-bezier(0.4,0,0.6,1); }
  100% { transform: rotate(360deg); }
}
@keyframes liminalCorePulse {
  /* Pulses dark — inverted from gold core */
  0%, 100% { opacity: 0.9; transform: scale(1); }
  50%       { opacity: 0.3; transform: scale(0.85); }
}
@keyframes liminalRipple {
  0%   { transform: scale(0.85); opacity: 0.6; }
  60%  { transform: scale(1.6);  opacity: 0; }
  100% { transform: scale(1.6);  opacity: 0; }
}

/* Reversed, drifting rotation on liminal arc groups */
.cp-rune-arcs--liminal circle:nth-child(1) { animation: arcDriftCCWDrift 40s linear infinite; }
.cp-rune-arcs--liminal circle:nth-child(2) { animation: arcDriftCWDrift  30s linear infinite; }
.cp-rune-arcs--liminal circle:nth-child(3) { animation: arcDriftCCWDrift 20s linear infinite; }
.cp-rune-arcs--liminal circle:nth-child(4) { animation: arcDriftCWDrift  13s linear infinite; }

/* Liminal core — void pulse (dark instead of bright) */
.cp-rune-core--liminal {
  animation: liminalCorePulse 3.2s ease-in-out infinite !important;
}

/* Liminal backdrop — purple-tinted ellipse, deeper/more saturated than standard */
.cp-marker--liminal .cp-marker-inner::before,
.cp-marker--liminal-nearby .cp-marker-inner::before,
.cp-marker--liminal-discovered .cp-marker-inner::before,
.cp-marker--liminal-discovered-nearby .cp-marker-inner::before {
  display: block;
  background: radial-gradient(ellipse, rgba(14,4,28,0.82) 28%, rgba(14,4,28,0) 74%);
}

.cp-marker--liminal            .cp-marker-inner::before { width: 54px; height: 20px; }
.cp-marker--liminal-nearby     .cp-marker-inner::before { width: 76px; height: 26px; }
.cp-marker--liminal-discovered .cp-marker-inner::before { width: 54px; height: 20px; }
.cp-marker--liminal-discovered-nearby .cp-marker-inner::before { width: 76px; height: 26px; }

/* ── liminal (default undiscovered) ── */
.cp-marker--liminal .cp-rune-svg { width: 44px; height: 44px; }
.cp-marker--liminal .cp-rune-ring-group {
  opacity: 0.9;
  animation: runeGroupPulse 5s ease-in-out infinite;
}
.cp-marker--liminal .cp-rune-arcs--liminal circle:nth-child(1) { animation-duration: 40s; }
.cp-marker--liminal .cp-rune-arcs--liminal circle:nth-child(2) { animation-duration: 30s; }

/* ── liminal-nearby ── */
.cp-marker--liminal-nearby .cp-rune-svg { width: 62px; height: 62px; }
.cp-marker--liminal-nearby .cp-rune-ring-group { opacity: 1; }
.cp-marker--liminal-nearby .cp-rune-arcs--liminal circle:nth-child(1) { animation-duration: 18s; }
.cp-marker--liminal-nearby .cp-rune-arcs--liminal circle:nth-child(2) { animation-duration: 12s; }
.cp-marker--liminal-nearby .cp-rune-arcs--liminal circle:nth-child(3) { animation-duration: 8s; }
.cp-marker--liminal-nearby .cp-rune-arcs--liminal circle:nth-child(4) { animation-duration: 5s; }
.cp-marker--liminal-nearby .cp-marker-inner::after {
  width: 62px; height: 62px;
  border-color: rgba(192,132,252,0.5);
  animation: liminalRipple 2.4s ease-out infinite;
}

/* ── liminal-discovered ── */
.cp-marker--liminal-discovered .cp-rune-svg { width: 44px; height: 44px; }
.cp-marker--liminal-discovered .cp-rune-ring-group { opacity: 0.5; }
.cp-marker--liminal-discovered .cp-rune-arcs--liminal circle:nth-child(1) { animation-duration: 80s; }
.cp-marker--liminal-discovered .cp-rune-arcs--liminal circle:nth-child(2) { animation-duration: 110s; }
.cp-marker--liminal-discovered .cp-rune-core--liminal { animation: none !important; opacity: 0.45; }

/* ── liminal-discovered-nearby ──
   Same principle as the normal-checkpoint discovered-nearby: proximity
   always wins the size/speed/ripple treatment back from the calmer
   "discovered" look, even though the palette stays platinum. Mirrors
   liminal-nearby's geometry/ripple exactly — only the arc/core colour
   (handled in buildRuneSVG's isDiscovered branch) differs between the
   two nearby states. Deliberately does NOT get liminal-discovered's
   `animation: none` core override — nearby means active, not dormant. */
.cp-marker--liminal-discovered-nearby .cp-rune-svg { width: 62px; height: 62px; }
.cp-marker--liminal-discovered-nearby .cp-rune-ring-group { opacity: 1; }
.cp-marker--liminal-discovered-nearby .cp-rune-arcs--liminal circle:nth-child(1) { animation-duration: 18s; }
.cp-marker--liminal-discovered-nearby .cp-rune-arcs--liminal circle:nth-child(2) { animation-duration: 12s; }
.cp-marker--liminal-discovered-nearby .cp-rune-arcs--liminal circle:nth-child(3) { animation-duration: 8s; }
.cp-marker--liminal-discovered-nearby .cp-rune-arcs--liminal circle:nth-child(4) { animation-duration: 5s; }
.cp-marker--liminal-discovered-nearby .cp-marker-inner::after {
  width: 62px; height: 62px;
  border-color: rgba(192,132,252,0.5);
  animation: liminalRipple 2.4s ease-out infinite;
}

/* ─────────────────────────────────────────────
   LOCUS MARKERS
   Rotating triangle mandalas — angular, deliberate,
   much larger than checkpoints. Cold violet palette.
   No backdrop disc.
   ───────────────────────────────────────────── */

@keyframes locusTriCW  { from { transform: rotate(0deg); }   to { transform: rotate(360deg); } }
@keyframes locusTriCCW { from { transform: rotate(0deg); }   to { transform: rotate(-360deg); } }
@keyframes locusCorePulse {
  0%, 100% { opacity: 0.95; transform: scale(1) rotate(0deg); }
  33%       { opacity: 1;    transform: scale(1.12) rotate(15deg); }
  66%       { opacity: 0.8;  transform: scale(0.94) rotate(-8deg); }
}
@keyframes locusGroupPulse {
  0%, 100% { opacity: 0.88; }
  50%       { opacity: 1; }
}
@keyframes locusFadeIn {
  from { opacity: 0; transform: scale(0.4) rotate(-30deg); }
  to   { opacity: 1; transform: scale(1)   rotate(0deg); }
}

.locus-marker { cursor: pointer; }

.locus-marker-inner {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  animation: locusFadeIn 0.7s cubic-bezier(0.34, 1.4, 0.64, 1) forwards;
}

.locus-marker-inner::after {
  content: '';
  position: absolute;
  bottom: 2px;
  left: 50%;
  transform: translateX(-50%);
  width: 56px;
  height: 18px;
  border-radius: 50%;
  background: radial-gradient(ellipse,
    rgba(6,3,14,0.78) 30%,
    rgba(6,3,14,0.0)  76%);
  pointer-events: none;
  z-index: 0;
}

.locus-rune-svg {
  display: block;
  overflow: visible;
  position: relative;
  z-index: 1;
}

.locus-rune-group { transform-origin: 36px 36px; animation: locusGroupPulse 4.5s ease-in-out infinite; }

/* CW and CCW triangle layers */
.locus-tri-cw  { animation: locusTriCW  22s linear infinite; }
.locus-tri-ccw { animation: locusTriCCW 16s linear infinite; }

/* Adjust individual layer speeds for depth */
.locus-rune-group .locus-tri-cw:nth-of-type(1)  { animation-duration: 28s; }
.locus-rune-group .locus-tri-ccw:nth-of-type(2) { animation-duration: 19s; }
.locus-rune-group .locus-tri-cw:nth-of-type(3)  { animation-duration: 12s; }
.locus-rune-group .locus-tri-ccw:nth-of-type(4) { animation-duration: 8s; }

/* Outer accent ring — slow CCW */
.locus-rune-group circle.locus-tri-ccw { animation-duration: 45s; }

/* Core diamond — rotates and pulses */
.locus-core {
  transform-origin: 36px 36px;
  animation: locusCorePulse 3s ease-in-out infinite;
}

/* ── nearby — player is inside triggerRadius ──
   Same idea as .cp-marker--liminal-nearby: bigger, faster, brighter.
   Everything keeps its own animation-name, just runs quicker + more
   intensely, so it reads as "waking up" rather than a jarring swap.
   Scale bump alone (1.14x) was too subtle to read as a state change at
   typical marker size — paired here with a constant glow (filter,
   always-on, not just the transient ripple) so the change is legible
   even at a glance / small zoom level. */
.locus-marker--nearby .locus-rune-svg {
  transform: scale(1.32);
  filter: drop-shadow(0 0 6px rgba(192,132,252,0.85)) drop-shadow(0 0 14px rgba(168,85,247,0.55));
}
.locus-marker--nearby .locus-rune-group { animation-duration: 1.6s; opacity: 1; }
.locus-marker--nearby .locus-tri-cw  { animation-duration: 6s; }
.locus-marker--nearby .locus-tri-ccw { animation-duration: 4.5s; }
.locus-marker--nearby .locus-core    { animation-duration: 1s; }
.locus-marker--nearby .locus-marker-inner::after {
  /* ground shadow deepens slightly so the "lift" reads against it */
  background: radial-gradient(ellipse, rgba(6,3,14,0.9) 30%, rgba(6,3,14,0) 76%);
}

@keyframes locusNearbyRipple {
  0%   { transform: scale(0.5); opacity: 0.85; }
  100% { transform: scale(2.1);  opacity: 0; }
}
.locus-marker--nearby .locus-marker-inner::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 62px; height: 62px;
  margin: -31px 0 0 -31px;
  border-radius: 50%;
  border: 2px solid rgba(192,132,252,0.8);
  animation: locusNearbyRipple 1.3s ease-out infinite;
  pointer-events: none;
  z-index: 0;
}

/* ── The crystal (.locus-core) — three states, plus a whole-marker
   treatment for the last one ──
   Dormant and the default in-progress look stay core-only — only the
   crystal changes, so it reads as "this one piece tells you the
   relationship with this character," not a wholesale different marker.
   Complete is the deliberate exception: it gets the core treatment
   below AND a whole-rune glow further down (.locus-marker--complete
   .locus-rune-svg etc.), because a core-only change wasn't legible
   enough for the one state that's meant to be unmistakable at a glance.
     dormant  — never contacted (bioProgress 0): dark, unlit obsidian,
                slow pulse. Replaces an earlier version of this that
                dimmed the WHOLE rune — deliberately narrow, unlike
                complete below.
     (default) — contacted, still delivering fragments: today's normal
                bright gradient + pulse, completely unchanged.
     complete — every fragment delivered: brighter core pulse here,
                plus a permanent whole-rune glow and faster spin further
                down — the one state meant to outshine everything else
                this marker can look like.
   Flat fill colours rather than swapped gradient references — each
   marker's core gradient has a unique per-instance id (gradId, from
   _runeIdCounter), so a shared CSS rule can't target "the OTHER
   gradient" by url() the way the default state's own inline fill
   attribute does. A flat fill (dormant) or a CSS drop-shadow layered
   on top of the existing fill (complete) both sidestep that entirely. */
.locus-marker--dormant .locus-core {
  fill: #140b1f;
  animation-duration: 6s;
}

.locus-marker--complete .locus-core {
  filter: drop-shadow(0 0 6px #ffffff) drop-shadow(0 0 13px #f0e0ff) drop-shadow(0 0 22px #c084fc);
  animation: locusCoreComplete 1.8s ease-in-out infinite;
}
@keyframes locusCoreComplete {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.38); }
}

/* ── complete, whole-marker treatment ──
   The core-only glow above used to be the entire "completed" signal,
   which got lost for two reasons: it's one small piece inside a rune
   that's already busy with spinning triangles/rings, and its purple
   tone is the same family as --nearby's proximity glow, so the two
   blended together right when you're close enough to actually look at
   it. This makes completion legible on the whole rune, permanently
   (not proximity-gated like --nearby), and whiter/brighter than
   --nearby's glow so "done" reads as distinct from "close" even when
   both are true at once. Spin speeds bump the same way --nearby's do,
   just further — completion is meant to be the more energetic of the
   two states, not a tie. */
.locus-marker--complete .locus-rune-svg {
  transform: scale(1.18);
  filter: drop-shadow(0 0 7px rgba(255,255,255,0.85)) drop-shadow(0 0 16px rgba(240,224,255,0.75)) drop-shadow(0 0 28px rgba(192,132,252,0.55));
}
.locus-marker--complete .locus-rune-group { animation-duration: 1s; opacity: 1; }
.locus-marker--complete .locus-tri-cw  { animation-duration: 4s; }
.locus-marker--complete .locus-tri-ccw { animation-duration: 3s; }

/* ── nearby + complete, both true at once ──
   Explicit combo rule (higher specificity than either state alone via
   the double class) so standing next to a completed locus stacks the
   two effects into one bigger/faster look instead of --nearby's later
   declaration silently overwriting --complete's transform/filter (or
   vice versa, depending on source order) on the same .locus-rune-svg
   property. */
.locus-marker--nearby.locus-marker--complete .locus-rune-svg {
  transform: scale(1.42);
  filter: drop-shadow(0 0 8px rgba(255,255,255,0.9)) drop-shadow(0 0 18px rgba(240,224,255,0.8)) drop-shadow(0 0 32px rgba(192,132,252,0.65));
}
.locus-marker--nearby.locus-marker--complete .locus-rune-group { animation-duration: 0.85s; }
.locus-marker--nearby.locus-marker--complete .locus-tri-cw  { animation-duration: 3s; }
.locus-marker--nearby.locus-marker--complete .locus-tri-ccw { animation-duration: 2.2s; }

/* ── pending — a fragment is armed and waiting to be delivered ──
   Small gold "new signal" ping badge, distinct from the marker's own
   ambient purple pulse so it reads as a notification, not just motion.
   A dedicated element (.locus-pending-badge, added in map.js) rather
   than another ::after — .locus-marker-inner::after is already the
   ground shadow, and reusing it here would replace that shadow
   wholesale instead of layering on top of it. Composable with
   --nearby: both classes can be present on the marker at once. */
@keyframes locusPendingPing {
  0%, 100% { transform: scale(1);   opacity: 1; }
  50%      { transform: scale(1.35); opacity: 0.7; }
}
.locus-pending-badge {
  display: none;
  position: absolute;
  top: -2px;
  right: -2px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: radial-gradient(circle, #fffdf6 0%, #f4c840 55%, #a07010 100%);
  box-shadow: 0 0 8px 2px rgba(244,200,64,0.75);
  pointer-events: none;
  z-index: 2;
}
.locus-marker--pending .locus-pending-badge {
  display: block;
  animation: locusPendingPing 1.4s ease-in-out infinite;
}

/* ─────────────────────────────────────────────
   PURPLE FIRE — shared by gate spires, locus
   markers, and liminal checkpoints.
   Three layers for depth: body, wisp, tip.
   ───────────────────────────────────────────── */

@keyframes locusFireBody {
  0%   { transform: translateX(-50%) scaleY(1)    scaleX(1)    translateY(0);    opacity: 0.65; }
  30%  { transform: translateX(-53%) scaleY(1.22) scaleX(0.84) translateY(-5px); opacity: 0.85; }
  60%  { transform: translateX(-47%) scaleY(1.38) scaleX(0.72) translateY(-9px); opacity: 0.7; }
  100% { transform: translateX(-50%) scaleY(1)    scaleX(1)    translateY(0);    opacity: 0.65; }
}
@keyframes locusFireWisp {
  0%   { transform: translateX(-50%) scaleY(1.1)  scaleX(0.7)  translateY(-6px); opacity: 0.45; }
  40%  { transform: translateX(-54%) scaleY(1.35) scaleX(0.58) translateY(-14px);opacity: 0.65; }
  75%  { transform: translateX(-46%) scaleY(1.5)  scaleX(0.5)  translateY(-19px);opacity: 0.4; }
  100% { transform: translateX(-50%) scaleY(1.1)  scaleX(0.7)  translateY(-6px); opacity: 0.45; }
}

/* ── Locus marker fire — floats above the mandala ── */
.locus-fire {
  position: absolute;
  top: -8px;
  left: 50%;
  width: 14px;
  height: 36px;
  pointer-events: none;
  transform: translateX(-50%);
}

.locus-fire::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 11px;
  height: 28px;
  background: linear-gradient(to top,
    rgba(124, 58, 237, 0.85) 0%,
    rgba(167, 139, 250, 0.65) 42%,
    rgba(216, 180, 254, 0.35) 72%,
    transparent 100%);
  border-radius: 50% 50% 28% 28% / 60% 60% 40% 40%;
  filter: blur(3px);
  animation: locusFireBody 2.1s ease-in-out infinite;
  transform-origin: bottom center;
}

.locus-fire::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 6px;
  height: 22px;
  background: linear-gradient(to top,
    rgba(167, 139, 250, 0.75) 0%,
    rgba(196, 181, 253, 0.5)  50%,
    rgba(237, 233, 254, 0.2)  82%,
    transparent 100%);
  border-radius: 50% 50% 22% 22% / 68% 68% 32% 32%;
  filter: blur(2px);
  animation: locusFireWisp 2.7s ease-in-out infinite;
  transform-origin: bottom center;
}

/* ── Liminal checkpoint fire — vertical cluster, gate-fire family ──
   Three jets (CP_FIRE_JETS in map.js) sitting side by side above the
   coin, each one just a plain upward .locus-fire like a locus/gate's —
   no rotation, nothing radiates outward. A rotating-ring version of
   this shipped first and read as chaotic ("shooting out the sides")
   rather than as fire; gates read clearly as on-fire specifically
   because their flame only ever goes up, so this matches that. The
   center jet is full-size, the two flanking it are scaled down and
   offset a few px left/right (via --jet-offset/--jet-scale) so the
   three merge into one thicker, fuller-looking mass instead of just
   duplicating a single jet three times in the same spot. */
.cp-fire-cluster {
  position: absolute;
  top: -4px;
  left: 50%;
  width: 0;
  height: 0;
  pointer-events: none;
  z-index: 2; /* above the rune — flame licks up over the rim */
}

.cp-fire-cluster .locus-fire {
  left: 0;
  transform-origin: bottom center; /* scale shrinks toward the rooted base, not the middle, so all three jets still line up at the same baseline */
  transform: translateX(calc(-50% + var(--jet-offset, 0px))) scale(var(--jet-scale, 1));
}

/* Base cluster flame — bumped again, ~25% larger overall and
   deliberately weighted toward WIDTH rather than a uniform scale-up:
   width grows more (+33%) than height (+15%), and the three jets'
   horizontal spread (CP_FIRE_JETS' offsetX, map.js) widened by the
   same ~33% alongside it — so the extra size reads as the whole
   corona spreading out sideways, not just one blob getting fatter. */
.locus-fire--cp {
  width: 24px;
  height: 46px;
}

.locus-fire--cp::before {
  width: 20px;
  height: 38px;
  filter: blur(2.8px);
  animation-duration: 1.7s;
  animation-delay: var(--jet-delay, 0s);
}

.locus-fire--cp::after {
  width: 11px;
  height: 30px;
  filter: blur(2.1px);
  animation-duration: 2.3s;
  animation-delay: var(--jet-delay, 0s);
}

/* Nearby states get a bigger cluster, same as gates get bigger fire the
   closer/more urgent their state (locked-nearby, unlockable-nearby,
   opened) — the cluster's own top offset moves out slightly too, to
   clear the bigger 62px rune. Same +33%/+15% width/height bias as the
   base size above. */
.cp-marker--liminal-nearby .cp-fire-cluster,
.cp-marker--liminal-discovered-nearby .cp-fire-cluster {
  top: -6px;
}
.cp-marker--liminal-nearby .locus-fire--cp,
.cp-marker--liminal-discovered-nearby .locus-fire--cp {
  width: 31px;
  height: 58px;
}
.cp-marker--liminal-nearby .locus-fire--cp::before,
.cp-marker--liminal-discovered-nearby .locus-fire--cp::before {
  width: 25px;
  height: 47px;
}
.cp-marker--liminal-nearby .locus-fire--cp::after,
.cp-marker--liminal-discovered-nearby .locus-fire--cp::after {
  width: 13px;
  height: 37px;
}

/* ── discovered — slightly dimmed core, never fully still ── */
/* (No separate state needed — loci don't have a discovered/undiscovered
   split the way checkpoints do. They're just present once unlocked.) */

/* ── Locus inspect card — violet accent ── */
/* Locus / gate panel accent colours are handled in #side-panel .sp--locus / .sp--gate rules */


/* ─────────────────────────────────────────────
   GATE MARKERS
   Heavy diamond geometry. Amber-red when locked,
   violet-gold when opened. Four states.
   ───────────────────────────────────────────── */

/* ─────────────────────────────────────────────
   GATE MARKERS — TOWER STYLE
   32×80 SVG, base-anchored via translate(-50%,-100%).
   Stroke weights + glow match checkpoint rune arcs.
   Purple fire is the ambient "shadow" — same family
   as liminal CP fire so all three marker types read
   as one visual system.
   ───────────────────────────────────────────── */

@keyframes gateDiamondCW {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
@keyframes gateCorePulse {
  0%, 100% { opacity: 0.9; transform: scale(1); }
  50%       { opacity: 0.35; transform: scale(0.55); }
}
@keyframes gateCoreUnlockable {
  0%, 100% { opacity: 1; transform: scale(1); }
  50%       { opacity: 0.65; transform: scale(1.55); }
}
@keyframes gateFadeIn {
  from { opacity: 0; transform: translateY(14px) scale(0.8); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes gateOpenedPulse {
  0%, 100% { opacity: 0.82; }
  50%       { opacity: 1; }
}
@keyframes gateRingMid {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
@keyframes gateRingUpper {
  from { transform: rotate(0deg); }
  to   { transform: rotate(-360deg); }
}
@keyframes gatePortalGlowPulse {
  0%, 100% { transform: scale(0.85); opacity: 0.55; }
  50%       { transform: scale(1.35); opacity: 1; }
}
/* Asymmetric scaleX/scaleY (not a uniform scale()) so the blob stretches
   taller than it does wider at its peak — a licking-upward flame silhouette
   instead of a shape that just balloons in place. Same bias the family's
   other flame keyframes (gateFireBody/Wisp) already use. */
@keyframes gatePortalFirePulse {
  0%   { transform: translate(-50%,-50%) scaleX(0.82) scaleY(0.88) rotate(-3deg); opacity: 0.55; }
  35%  { transform: translate(-46%,-58%) scaleX(1.02) scaleY(1.34) rotate(4deg);  opacity: 0.9; }
  68%  { transform: translate(-55%,-45%) scaleX(0.90) scaleY(1.14) rotate(-4deg); opacity: 0.7; }
  100% { transform: translate(-50%,-50%) scaleX(0.82) scaleY(0.88) rotate(-3deg); opacity: 0.55; }
}

/* ── Purple fire — matches locusFireBody/Wisp keyframes in palette ── */
@keyframes gateFireBody {
  0%   { transform: translateX(-50%) scaleY(1)    scaleX(1)    translateY(0);    opacity: 0.75; }
  28%  { transform: translateX(-52%) scaleY(1.25) scaleX(0.80) translateY(-4px); opacity: 0.95; }
  58%  { transform: translateX(-48%) scaleY(1.44) scaleX(0.68) translateY(-9px); opacity: 0.8; }
  100% { transform: translateX(-50%) scaleY(1)    scaleX(1)    translateY(0);    opacity: 0.75; }
}
@keyframes gateFireWisp {
  0%   { transform: translateX(-50%) scaleY(1.1)  scaleX(0.62) translateY(-5px); opacity: 0.55; }
  38%  { transform: translateX(-54%) scaleY(1.40) scaleX(0.50) translateY(-15px);opacity: 0.75; }
  72%  { transform: translateX(-46%) scaleY(1.58) scaleX(0.42) translateY(-21px);opacity: 0.45; }
  100% { transform: translateX(-50%) scaleY(1.1)  scaleX(0.62) translateY(-5px); opacity: 0.55; }
}

.gate-marker { cursor: pointer; }

.gate-marker-inner {
  position: relative;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  animation: gateFadeIn 0.65s cubic-bezier(0.34,1.2,0.64,1) forwards;
}

/* Dark radial backdrop disc — same as cp-marker-inner::before.
   Centred on the tower body so the SVG reads against any tile colour. */
.gate-backdrop {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 56px;
  height: 30px;
  border-radius: 50%;
  background: radial-gradient(ellipse,
    rgba(6,3,14,0.82) 28%,
    rgba(6,3,14,0.0)  74%);
  pointer-events: none;
  z-index: 0;
  /* Grows with the tower rather than snapping tier-to-tier — see the
     matching note on .gate-rune-svg's transition list above. */
  transition: width 2.2s cubic-bezier(0.19, 1.8, 0.32, 1),
              height 2.2s cubic-bezier(0.19, 1.8, 0.32, 1);
}

.gate-rune-svg {
  display: block;
  overflow: visible;
  position: relative;
  z-index: 1;
}

.gate-ring-mid   { animation: gateRingMid   20s linear infinite; }
.gate-ring-upper { animation: gateRingUpper 14s linear infinite; }

/* ── Purple fire at spire tip ──
   Every layer here was missing the translateX(-50%) that actually
   centers a `left: 50%` box — without it, an element's LEFT EDGE sits
   at center, so the whole box extends further right than left. That's
   the reported asymmetry, fixed by adding the compensating transform
   to all four layers (this one, its own ::before/::after, and the
   portal-fire pair below) — same pattern already used correctly
   elsewhere in this file for locus/checkpoint fire. */
.gate-fire {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 26px;
  height: 61px;
  pointer-events: none;
  z-index: 2;
  /* Grows with the tower rather than snapping tier-to-tier — see the
     matching note on .gate-rune-svg's transition list above. */
  transition: width 2.2s cubic-bezier(0.19, 1.8, 0.32, 1),
              height 2.2s cubic-bezier(0.19, 1.8, 0.32, 1);
}

.gate-fire::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 22px;
  height: 52px;
  background: linear-gradient(to top,
    rgba(124, 58, 237, 0.96)  0%,
    rgba(167, 139, 250, 0.80) 40%,
    rgba(216, 180, 254, 0.48) 70%,
    transparent 100%);
  border-radius: 50% 50% 30% 30% / 62% 62% 38% 38%;
  filter: blur(4.5px);
  animation: gateFireBody 1.9s ease-in-out infinite;
  transform-origin: bottom center;
}

.gate-fire::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 12px;
  height: 41px;
  background: linear-gradient(to top,
    rgba(167, 139, 250, 0.92) 0%,
    rgba(196, 181, 253, 0.66) 48%,
    rgba(237, 233, 254, 0.30) 80%,
    transparent 100%);
  border-radius: 50% 50% 22% 22% / 70% 70% 30% 30%;
  filter: blur(2.8px);
  animation: gateFireWisp 2.6s ease-in-out infinite;
  transform-origin: bottom center;
}

/* ── Portal void fire — leaks from the dark diamond itself ── */
.gate-portal-fire {
  position: absolute;
  top: 41%;
  left: 50%;
  transform: translateX(-50%);
  width: 21px;
  height: 35px;
  pointer-events: none;
  z-index: 2;
  /* Grows with the tower rather than snapping tier-to-tier — see the
     matching note on .gate-rune-svg's transition list above. */
  transition: width 2.2s cubic-bezier(0.19, 1.8, 0.32, 1),
              height 2.2s cubic-bezier(0.19, 1.8, 0.32, 1);
}
.gate-portal-fire::before {
  content: '';
  position: absolute;
  /* top/left 50% + translate(-50%,-50%) in the animation below, matching
     the ::after sibling's convention — NOT `inset: 0`. inset:0 has no
     percentage center-point of its own, so the shared keyframes'
     translate(-50%,-50%) baseline was shifting this element up-left by
     half its own size on every frame instead of holding it centered. */
  top: 50%;
  left: 50%;
  width: 100%;
  height: 100%;
  background: radial-gradient(circle,
    rgba(235, 218, 255, 0.98) 0%,
    rgba(167, 139, 250, 0.78) 42%,
    rgba(124, 58, 237, 0.46)  72%,
    transparent 100%);
  /* Flame teardrop, same family convention as .gate-fire::before/::after —
     without this it was a hard-edged rectangle with a circular gradient
     painted inside it, which is exactly what read as "square" once this
     got scaled up in the opened state and the flat blur (3.4px) stopped
     hiding the box outline. */
  border-radius: 50% 50% 35% 35% / 60% 60% 40% 40%;
  filter: blur(3.4px);
  animation: gatePortalFirePulse 1.7s ease-in-out infinite;
}
.gate-portal-fire::after {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 10px;
  height: 19px;
  background: linear-gradient(to top,
    rgba(216, 180, 254, 0.95) 0%,
    rgba(237, 233, 254, 0.5)  60%,
    transparent 100%);
  border-radius: 50% 50% 30% 30% / 65% 65% 35% 35%;
  filter: blur(2px);
  animation: gatePortalFirePulse 1.1s ease-in-out infinite reverse;
}

/* Portal void ember pulse */
.gate-portal-glow {
  animation: gatePortalGlowPulse 2.2s ease-in-out infinite;
}

/* ── State variants ── */

/* ── Buried / emerged ──
   Pre-activation, the tower is clipped so only its upper ~80% shows —
   like it's still sunk in the ground, BOTW/TOTK-tower style. The clip
   depth is NOT arbitrary: buildGateMarkerSVG (map.js) draws the tower
   on a 0 0 32 80 viewBox, and the "plinth" (the broken-base geometry
   meant to read as a buried foundation) only occupies roughly y=61–76
   of that — everything below y=76 is empty canvas, and everything
   above y=61 is the mid-chamber rectangle, which has no grounded look
   to it at all. 20% hidden puts the clip line at y=64, right inside
   the plinth's jagged peaks (61–65.5) — the cut lines up with art
   that's actually drawn to look broken/buried. Anything much past
   ~23% moves the clip line above y=61 entirely, past the plinth and
   into the mid-chamber's clean rectangle edge, with the ground-shadow
   ellipse sitting far below it — a flat, ungrounded-looking cut
   floating above its own shadow, not a buried tower. (This was
   overshot to 38% in an earlier pass aiming for a bigger rise
   animation, which is exactly what caused that "floating" look.)
   Activating it with a sigil (state flips to --opened) lifts the clip
   away entirely with a transition, so the whole base visibly rises
   into view rather than just popping to full size. Applied to the SVG
   itself rather than needing any change to how the tower is drawn.

   Duration/easing (1.4s → 2.2s, bigger overshoot on the settle) still
   applies regardless of clip depth — the "grand" feel comes from the
   slower, weightier transition plus the flash/burst below, not from
   how deep the bury is. engine.js's GATE_RISE_MS (the delay before
   the "threshold opened" dialogue appears) is kept in sync with this
   duration — see its comment. */
.gate-rune-svg {
  clip-path: inset(0 0 20% 0);
  transition: clip-path 2.2s cubic-bezier(0.19, 1.8, 0.32, 1),
              width     2.2s cubic-bezier(0.19, 1.8, 0.32, 1),
              height    2.2s cubic-bezier(0.19, 1.8, 0.32, 1);
}
.gate-marker--opened .gate-rune-svg { clip-path: inset(0 0 0% 0); }

/* One-shot flash across the tower itself as it finishes rising — plays
   once whenever an element starts matching --opened (a genuine sigil
   activation, or a marker built already-opened on load), then settles
   back to normal brightness and stays there. Timed to peak right as
   the clip-path rise is finishing rather than at the start, so it
   reads as the tower "catching light" at the top of its rise instead
   of a flash-then-rise. Placed on .gate-rune-svg directly rather than
   .gate-marker-inner, since .gate-marker-inner's own `animation`
   property is already spoken for by gateFadeIn (the marker's ordinary
   entrance fade) — a second value here would replace that outright
   rather than layer with it. */
@keyframes gateOpenFlash {
  0%, 60%  { filter: brightness(1); }
  78%      { filter: brightness(2.1) saturate(1.4); }
  100%     { filter: brightness(1); }
}
.gate-marker--opened .gate-rune-svg {
  animation: gateOpenFlash 2.2s ease-out 1;
}

/* One-shot expanding shockwave ring from the portal core, same moment
   the flash peaks — the "grand payoff" cue once the threshold is
   actually open, distinct from the continuous spinning diamond ring
   the locked/nearby tiers use (gateRingSpin, above): this one fires
   once and dissipates rather than looping, so it reads as a release of
   energy rather than more ambient motion. Positioned with the same
   top:41% anchor .gate-portal-fire uses, so it expands from the same
   point the void-fire leaks from. */
@keyframes gateOpenBurstRing {
  0%   { transform: translate(-50%,-50%) scale(0.25); opacity: 0; border-width: 5px; }
  15%  { opacity: 0.95; }
  100% { transform: translate(-50%,-50%) scale(3.2); opacity: 0; border-width: 0.5px; }
}
.gate-marker--opened .gate-marker-inner::before {
  content: '';
  position: absolute;
  top: 41%;
  left: 50%;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  border: 5px solid rgba(233, 213, 255, 0.9);
  box-shadow: 0 0 26px 8px rgba(192,132,252,0.55);
  pointer-events: none;
  z-index: 3;
  animation: gateOpenBurstRing 1.3s cubic-bezier(0.16, 1, 0.3, 1) 1.4s 1 both;
}

/* ── In-range — a slowly spinning diamond ring ──
   Deliberately different from the locus's expanding ripple circle
   (continuous rotation instead of an outward pulse, and a diamond
   instead of a circle) so the two marker families read as distinct
   systems at a glance rather than the same effect recoloured. Speeds
   up between locked-nearby and unlockable-nearby, same "getting more
   urgent" language the rest of this ladder already uses.

   Centering: `left: 50%` puts the box's left edge at the marker's
   center, and translateX(-50%) — baked into every frame of
   gateRingSpin below, since rotate() needs to animate alongside it —
   pulls it back by half its own width to actually center it. That's
   the only centering this element should have: do NOT also add a
   margin-left half-width offset per tier (52px/64px did, previously)
   — the two stack and push the square off-center by an extra half
   its width in the direction translateX() shifts it. */
@keyframes gateRingSpin {
  from { transform: translateX(-50%) rotate(45deg); }
  to   { transform: translateX(-50%) rotate(405deg); }
}
.gate-marker--locked-nearby .gate-marker-inner::after,
.gate-marker--unlockable-nearby .gate-marker-inner::after {
  content: '';
  position: absolute;
  bottom: 10px;
  left: 50%;
  border-radius: 3px;
  animation: gateRingSpin linear infinite;
  pointer-events: none;
}

/* locked */
.gate-marker--locked .gate-rune-svg           { width: 38px; height: 95px; }
.gate-marker--locked .gate-backdrop           { width: 57px; height: 31px; }
.gate-marker--locked .gate-core               { animation: gateCorePulse 5s ease-in-out infinite; }
.gate-marker--locked .gate-fire               { opacity: 0.75; }
.gate-marker--locked .gate-portal-fire        { opacity: 0.75; }
.gate-marker--locked .gate-ring-mid           { animation-duration: 50s; }
.gate-marker--locked .gate-ring-upper         { animation-duration: 33s; }

/* locked-nearby — everything else in this tier still escalates with
   proximity (rune size, ring speed, the spin ring) — only the fire
   itself stays flat pre-activation (see the header comment on
   .gate-fire above): nothing about the flame previews "you're getting
   close, almost there" anymore, it only actually changes once the
   sigil genuinely activates the gate. */
.gate-marker--locked-nearby .gate-rune-svg    { width: 43px; height: 107px; }
.gate-marker--locked-nearby .gate-backdrop    { width: 69px; height: 36px; }
.gate-marker--locked-nearby .gate-core        { animation: gateCorePulse 2.8s ease-in-out infinite; }
.gate-marker--locked-nearby .gate-fire        { opacity: 0.75; }
.gate-marker--locked-nearby .gate-portal-fire { opacity: 0.75; }
.gate-marker--locked-nearby .gate-ring-mid    { animation-duration: 28s; }
.gate-marker--locked-nearby .gate-ring-upper  { animation-duration: 18s; }
.gate-marker--locked-nearby .gate-marker-inner::after {
  width: 52px; height: 52px;
  border: 2px solid rgba(155, 63, 212, 0.42);
  animation-duration: 7s;
}

/* unlockable-nearby — same fire-stays-flat reasoning as locked-nearby
   above; this tier's rune/ring/spin escalation is untouched, since the
   ask was specifically about the fire, not the whole state ladder. */
.gate-marker--unlockable-nearby .gate-rune-svg    { width: 45px; height: 113px; }
.gate-marker--unlockable-nearby .gate-backdrop    { width: 76px; height: 40px; }
.gate-marker--unlockable-nearby .gate-core        { animation: gateCoreUnlockable 1.2s ease-in-out infinite; }
.gate-marker--unlockable-nearby .gate-fire        { opacity: 0.75; }
.gate-marker--unlockable-nearby .gate-portal-fire { opacity: 0.75; }
.gate-marker--unlockable-nearby .gate-ring-mid    { animation-duration: 18s; }
.gate-marker--unlockable-nearby .gate-ring-upper  { animation-duration: 11s; }
.gate-marker--unlockable-nearby .gate-marker-inner::after {
  width: 64px; height: 64px;
  border: 2.5px solid rgba(192, 132, 252, 0.62);
  animation-duration: 4s;
}

/* opened — the biggest tier now (it wasn't before: it used to render
   SMALLER than unlockable-nearby, which never made sense for the final
   state — fixed alongside the general size increase). This is also the
   ONLY fire tier that actually changes size/opacity, since sigil
   activation is the one real transition that's supposed to matter for
   fire specifically, and the tower's own clip-path lifts away here too
   (see .gate-marker--opened .gate-rune-svg above) — it visibly rises
   out of the ground at the same moment the fire grows. */
.gate-marker--opened .gate-rune-svg           { width: 50px; height: 125px; }
.gate-marker--opened .gate-backdrop           { width: 90px; height: 48px; }
.gate-marker--opened .gate-rune-svg g         { animation: gateOpenedPulse 4s ease-in-out infinite; }
.gate-marker--opened .gate-fire               { opacity: 1; height: 118px; width: 45px; }
.gate-marker--opened .gate-portal-fire        { opacity: 1; width: 38px; height: 60px; }
/* Blur held constant elsewhere in the opened tier too (it just wasn't
   needed there — .gate-fire::before/::after already redeclare their own
   filter-free box at the new size). Bumped in proportion to the ~1.7x
   size jump so the edge softness reads the same as it does at every
   other tier instead of suddenly looking crisper. */
.gate-marker--opened .gate-portal-fire::before { filter: blur(5.8px); }
.gate-marker--opened .gate-portal-fire::after  { filter: blur(3.4px); }
.gate-marker--opened .gate-fire::before {
  width: 37px; height: 95px;
  background: linear-gradient(to top,
    rgba(124, 58, 237, 1)     0%,
    rgba(192, 132, 252, 0.92) 35%,
    rgba(233, 213, 255, 0.56) 65%,
    transparent 100%);
  filter: blur(4.8px);
}
.gate-marker--opened .gate-fire::after {
  width: 18px; height: 72px;
  background: linear-gradient(to top,
    rgba(167, 139, 250, 0.98) 0%,
    rgba(216, 180, 254, 0.72) 45%,
    transparent 90%);
}
.gate-marker--opened .gate-ring-mid           { animation-duration: 9s; }
.gate-marker--opened .gate-ring-upper         { animation-duration: 6s; }

/* SVG gradient is already platinum (built by buildRuneSVG).
   CSS only needs to handle size, animation speeds, and the ripple.
   The ripple border is silver-toned to match the platinum palette. */
.cp-marker--discovered-nearby .cp-rune-svg         { width: 62px; height: 62px; }
.cp-marker--discovered-nearby .cp-marker-inner::before { width: 76px; height: 26px; }
.cp-marker--discovered-nearby .cp-rune-ring-group  { opacity: 1; }
.cp-marker--discovered-nearby .cp-rune-arcs circle:nth-child(1) { animation-duration: 15s; }
.cp-marker--discovered-nearby .cp-rune-arcs circle:nth-child(2) { animation-duration: 10s; }
.cp-marker--discovered-nearby .cp-rune-arcs circle:nth-child(3) { animation-duration: 7s; }
.cp-marker--discovered-nearby .cp-rune-arcs circle:nth-child(4) { animation-duration: 5s; }
.cp-marker--discovered-nearby .cp-rune-core        { animation-duration: 1.2s; }
.cp-marker--discovered-nearby .cp-marker-inner::after {
  width: 62px; height: 62px;
  border-color: rgba(176, 184, 200, 0.55); /* platinum ripple vs gold */
  animation: nearbyRipple 2.2s ease-out infinite;
}

/* ─────────────────────────────────────────────
   LOADING SCREEN
   ───────────────────────────────────────────── */

@keyframes ld-spin    { from { transform: rotate(0deg); }   to { transform: rotate(360deg); } }
@keyframes ld-spinR   { from { transform: rotate(0deg); }   to { transform: rotate(-360deg); } }
@keyframes ld-fadeIn  { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes ld-pulse   { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
@keyframes ld-blink   { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
@keyframes ld-scan    { 0% { top: 0%; } 100% { top: 100%; } }
@keyframes ld-icon-glow {
  0%, 100% { filter: drop-shadow(0 0 12px rgba(200,168,78,0.35)) drop-shadow(0 0 28px rgba(200,168,78,0.15)); }
  50%       { filter: drop-shadow(0 0 22px rgba(200,168,78,0.6))  drop-shadow(0 0 48px rgba(200,168,78,0.25)); }
}
@keyframes ld-border-glow {
  0%, 100% { box-shadow: 0 0 18px rgba(200,168,78,0.2), inset 0 0 18px rgba(200,168,78,0.04); }
  50%       { box-shadow: 0 0 36px rgba(200,168,78,0.4), inset 0 0 28px rgba(200,168,78,0.08); }
}

/* ─────────────────────────────────────────────
   REVEAL OVERLAY SYSTEM
   Full-screen cinematic cards — checkpoint discovery
   (showReveal) and quest activation (showQuestActivated)
   in engine.js. Base styling here is shared by both;
   quest activation gets a distinctly bigger treatment
   layered on top below (.reveal-quest-activated /
   .reveal-qa-card), since getting a new investigation is
   meant to feel like a bigger deal than a routine ping.
   z-index sits above the bottom sheet/FAB/confirm-modal
   (31/20/40) since exploration can surface these at any
   time, but below the disclaimer/loading screens (100/200)
   which are pre-game gates this never coexists with.
   ───────────────────────────────────────────── */

.reveal-overlay {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background: rgba(6,4,2,0.86);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
  cursor: pointer;
}
.reveal-overlay.reveal-visible { opacity: 1; pointer-events: all; }
.reveal-overlay.reveal-hiding  { opacity: 0; }

.reveal-cp-card {
  position: relative;
  width: min(360px, 100%);
  background: #110e0a;
  border: 1px solid #4a3a1e;
  border-radius: 14px;
  padding: 24px 22px 18px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 30px rgba(200,168,78,0.1);
  transform: scale(0.92) translateY(10px);
  opacity: 0;
  transition: transform 0.45s cubic-bezier(0.22,1,0.36,1), opacity 0.4s ease;
  cursor: default;
}
.reveal-overlay.reveal-visible .reveal-cp-card {
  transform: scale(1) translateY(0);
  opacity: 1;
}

/* Carved-tablet accent — a thin inset line with its own corners chamfered
   (cut at an angle) rather than rounded, echoing the sigil art's angular
   geometric shapes. Sits inset from the card's real edge rather than on
   it, so it reads as an engraved facet inside a stone/metal panel instead
   of a second border, and so it doesn't interfere with the card's own
   rounded corners or box-shadow glow (clip-path on the card itself would
   cut that glow off — this avoids that by living on its own layer). Same
   inset shape reused by anything sharing this card family; only the line
   color changes (gold by default here, green where a card sets its own
   border-color to var(--quest-green) — see .reveal-qa-card etc.). */
.reveal-cp-card::before {
  content: '';
  position: absolute;
  inset: 6px;
  border: 1px solid rgba(200,168,78,0.22);
  clip-path: polygon(
    10px 0, calc(100% - 10px) 0, 100% 10px,
    100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%,
    0 calc(100% - 10px), 0 10px
  );
  pointer-events: none;
}

/* ── Lantern (reveal card) ──
   See lanternHtml() in utils.js. Amber and violet are the same two
   colors the map avatar's lantern blends between (avatar.js). Every
   lantern now RESTS on violet, not amber — amber is only the flame's
   original color, glimpsed for a moment as the flare catches, then it
   commits to violet and stays there for good (first discovery or a
   return visit alike). That reflects the fiction: the lantern doesn't
   go back to being an ordinary light once it's found something.
   First discovery (.reveal-lantern--flare): brief amber flicker →
   violet takes over completely, then settles into a steady burn.
   Return visits: already-settled violet from the very first frame,
   no flare. Reduced-motion: same violet resting state, no animation. */
.reveal-lantern {
  position: relative;
  z-index: 0;
  width: 52px;
  height: 70px;
  margin: -4px auto 10px;
  pointer-events: none;
}
.reveal-cp-card > .reveal-eyebrow,
.reveal-cp-card > .reveal-cp-name,
.reveal-cp-card > .reveal-cp-text { position: relative; z-index: 1; }
.reveal-cp-card > .reveal-eyebrow,
.reveal-cp-card > .reveal-cp-name { text-align: center; }

.rl-glow {
  position: absolute;
  left: 50%;
  top: 56%;
  width: 170px;
  height: 170px;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}
.rl-glow--amber  { background: radial-gradient(circle, rgba(255,198,110,0.5) 0%, rgba(255,198,110,0) 62%); opacity: 0; }
.rl-glow--violet { background: radial-gradient(circle, rgba(176,110,224,0.7) 0%, rgba(176,110,224,0) 62%); opacity: 0.6; }
/* Liminal-only — flashes cyan during the mirror-flame's big takeover
   beat, standing in for the violet glow for that instant. Inert
   (opacity 0, unanimated) on every normal lantern. */
.rl-glow--echo { background: radial-gradient(circle, rgba(90,210,255,0.75) 0%, rgba(90,210,255,0) 62%); opacity: 0; }

.rl-svg { position: relative; width: 100%; height: 100%; overflow: visible; }
.rl-frame { fill: none; stroke: #8a7440; stroke-width: 1.6; stroke-linecap: round; }
.rl-metal { fill: #8a7440; }
.rl-glass { fill: rgba(255,198,110,0.07); stroke: #8a7440; stroke-width: 1.2; stroke-linejoin: round; }
.rl-flame { transform-box: fill-box; transform-origin: 50% 100%; }
.rl-flame--amber  { fill: rgb(255,198,110); opacity: 0; }
.rl-flame--violet { fill: rgb(176,110,224); opacity: 1; }
.rl-core  { fill: rgba(228,212,250,0.95); transform-box: fill-box; transform-origin: 50% 100%; }
/* Ghost of the flame — inert on a normal lantern (opacity 0, no
   animation; fill is irrelevant since it's never shown). Only the
   liminal variant below gives it color, motion, and a mirrored shape.
   Present in every lantern's shared markup (utils.js) so it costs
   nothing on a normal checkpoint. */
.rl-flame-echo { fill: rgb(176,110,224); opacity: 0; transform-box: fill-box; transform-origin: 50% 100%; }

/* Steady burn — the resting state every lantern settles into */
.reveal-lantern .rl-flame--violet,
.reveal-lantern .rl-core { animation: rlFlicker 2.4s ease-in-out infinite; }

/* First-discovery flare: amber flickers in and immediately loses out
   to violet, which takes over and holds. Delayed slightly so it
   starts as the card finishes fading in. The steady flicker picks up
   right where the flare-in leaves off. */
.reveal-lantern--flare .rl-flame--amber {
  animation: rlAmberFlareIn 0.5s ease-out both;
}
.reveal-lantern--flare .rl-flame--violet {
  animation: rlVioletFlareIn 1.5s ease-out 0.3s both, rlFlicker 2.4s ease-in-out 1.8s infinite;
}
.reveal-lantern--flare .rl-core {
  animation: rlFlicker 2.4s ease-in-out 1.8s infinite;
}
.reveal-lantern--flare .rl-glow--amber { animation: rlGlowAmberIn 1.5s ease-out 0.3s both; }
.reveal-lantern--flare .rl-glow--violet {
  opacity: 0;
  animation: rlGlowVioletIn 1.5s ease-out 0.3s both;
}

@keyframes rlFlicker {
  0%, 100% { transform: scale(1, 1);     opacity: 1; }
  30%      { transform: scale(0.96, 1.05); opacity: 0.94; }
  60%      { transform: scale(1.03, 0.97); opacity: 1; }
}
@keyframes rlAmberFlareIn {
  0%   { transform: scale(0.8);  opacity: 0.55; }
  30%  { transform: scale(1.2);  opacity: 0; }
  100% { transform: scale(1);    opacity: 0; }
}
@keyframes rlVioletFlareIn {
  0%   { transform: scale(0.8);  opacity: 0; }
  30%  { transform: scale(1.35); opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}
@keyframes rlGlowAmberIn {
  0%   { opacity: 0.2; }
  30%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes rlGlowVioletIn {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
  30%  { opacity: 1; transform: translate(-50%, -50%) scale(1.2); }
  100% { opacity: 0.6; transform: translate(-50%, -50%) scale(1); }
}

/* ── Liminal variant — smooth, until it accidentally isn't ──
   Liminal checkpoints (cp.plane === 'liminal', see engine.js) burn a
   completely calm, continuous violet flame — same gentle rlFlicker
   breathing every other lantern uses, no constant snapping — so the
   resting state actually reads as smooth. Layered on top, independent
   of that breathing: two brief, involuntary-looking "accidents" per
   3.2s cycle, where a differently-colored version of the flame
   (.rl-flame-echo) appears in the exact same place and size — not
   bigger, not mirrored, just wrong — before the real flame reasserts.
   The two accidents are deliberately different from each other so it
   doesn't read as a metronome:
     1. ~0.4s into the cycle — a solid cyan swap, held just long enough
        to register (~130ms), gone.
     2. ~2s in — a rapid triple-blink of a sickly pale green, reading
        as a genuine malfunction rather than a color change.
   Their spacing (worst gap ≈1.45s, well under 2s) is deliberate: even
   the shortest realistic glance at this card (as little as ~2s) is
   guaranteed to land on part of at least one of them, without the
   cycle being so tight that it feels scheduled rather than accidental.
   A CSS custom property (--glitch-delay, set inline per-lantern in
   engine.js as a random 0-3.2s offset) desyncs multiple liminal
   lanterns from each other, in case more than one is ever visible at
   once — so they don't all glitch in unison.
   The main flame's opacity dip and the echo's appearance share the
   exact same keyframe percentage stops (each repeated at two adjacent
   points, e.g. "12.5%, 16.5%", for a hard cut rather than a fade), so
   the two always swap in perfect lockstep — it reads as one flame
   being briefly replaced by another, not two flames overlapping.
   Independent of all of this, on their own separate cycles: the
   void-gutter (flame and core periodically dropping toward black) and
   the core's reverse-direction breathing both continue exactly as
   before, entirely untouched by the glitch swap. */
.reveal-lantern--liminal .rl-flame--amber,
.reveal-lantern--liminal .rl-glow--amber,
.reveal-lantern--liminal.reveal-lantern--flare .rl-flame--amber,
.reveal-lantern--liminal.reveal-lantern--flare .rl-glow--amber {
  opacity: 0 !important;
  animation: none !important;
}

.reveal-lantern--liminal .rl-core {
  animation: rlFlicker 2.4s ease-in-out infinite reverse,
             rlVoidGutter 6s ease-in-out infinite;
}
.reveal-lantern--liminal .rl-flame--violet {
  animation: rlFlicker 2.4s ease-in-out infinite,
             rlVoidGutter 6s ease-in-out infinite,
             rlGlitchMainDip 3.2s ease-in-out infinite;
  animation-delay: 0s, 0s, var(--glitch-delay, 0s);
}
.reveal-lantern--liminal .rl-flame-echo {
  animation: rlGlitchEcho 3.2s ease-in-out infinite;
  animation-delay: var(--glitch-delay, 0s);
}
.reveal-lantern--liminal .rl-glow--violet {
  animation: rlGlowDipEcho 3.2s ease-in-out infinite;
  animation-delay: var(--glitch-delay, 0s);
}
.reveal-lantern--liminal .rl-glow--echo {
  animation: rlGlowFlashEcho 3.2s ease-in-out infinite;
  animation-delay: var(--glitch-delay, 0s);
}

/* Same stop points throughout (main's dip / echo's appearance / the
   glow's matching dip+flash) so all four elements swap in lockstep.
   Each stop repeats its value at two adjacent percentages — e.g.
   "12.5%, 16.5%" — leaving almost no time for the browser to
   interpolate, so it jumps rather than fades. */
@keyframes rlGlitchMainDip {
  0%,    11.5% { opacity: 1; }
  12.5%, 16.5% { opacity: 0.08; }
  17.5%, 61%   { opacity: 1; }
  62%,   63%   { opacity: 0.15; }
  63.5%, 64.5% { opacity: 1; }
  65%,   66%   { opacity: 0.15; }
  66.5%, 67.5% { opacity: 1; }
  68%,   69%   { opacity: 0.15; }
  70%,   100%  { opacity: 1; }
}
@keyframes rlGlitchEcho {
  0%,    11.5% { opacity: 0;    fill: rgb(90,210,255); }
  12.5%, 16.5% { opacity: 0.9;  fill: rgb(90,210,255); }
  17.5%, 61%   { opacity: 0;    fill: rgb(90,210,255); }
  62%,   63%   { opacity: 0.85; fill: rgb(150,220,120); }
  63.5%, 64.5% { opacity: 0;    fill: rgb(150,220,120); }
  65%,   66%   { opacity: 0.85; fill: rgb(150,220,120); }
  66.5%, 67.5% { opacity: 0;    fill: rgb(150,220,120); }
  68%,   69%   { opacity: 0.85; fill: rgb(150,220,120); }
  70%,   100%  { opacity: 0;    fill: rgb(90,210,255); }
}
@keyframes rlGlowDipEcho {
  0%,    11.5% { opacity: 0.6; }
  12.5%, 16.5% { opacity: 0.15; }
  17.5%, 100%  { opacity: 0.6; }
}
@keyframes rlGlowFlashEcho {
  0%,    11.5% { opacity: 0; }
  12.5%, 16.5% { opacity: 0.85; }
  17.5%, 100%  { opacity: 0; }
}

@keyframes rlVoidGutter {
  0%, 74%, 100% { filter: brightness(1)    saturate(1); }
  82%           { filter: brightness(0.15) saturate(0.4); }
  88%           { filter: brightness(1.3)  saturate(1); }
}


@media (prefers-reduced-motion: reduce) {
  .reveal-lantern .rl-flame--amber,
  .reveal-lantern .rl-flame--violet,
  .reveal-lantern .rl-flame-echo,
  .reveal-lantern .rl-core,
  .reveal-lantern .rl-glow { animation: none !important; filter: none !important; }
  .reveal-lantern .rl-flame--amber { opacity: 0; }
  .reveal-lantern .rl-flame--violet { opacity: 1; }
  .reveal-lantern .rl-flame-echo { opacity: 0; }
  .reveal-lantern .rl-glow--amber { opacity: 0; }
  .reveal-lantern .rl-glow--violet { opacity: 0.6; }
}

/* ── Unlit variant — empty states ──
   Nothing found yet, so nothing for the lantern to have caught: no
   flame, no glow, no animation of any kind, at reduced overall
   opacity — a dark lantern sitting unlit rather than a lit one edited
   down. Overrides win by two classes vs. the base rules' one; !important
   isn't needed. Metal and glass stay as-is (a lantern is still a
   lantern), only the fire itself is gone. */
.reveal-lantern--unlit {
  opacity: 0.5;
}
.reveal-lantern--unlit .rl-flame--amber,
.reveal-lantern--unlit .rl-flame--violet,
.reveal-lantern--unlit .rl-flame-echo,
.reveal-lantern--unlit .rl-core,
.reveal-lantern--unlit .rl-glow {
  opacity: 0 !important;
  animation: none !important;
}


.reveal-eyebrow {
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #c8a84e;
  margin-bottom: 8px;
}

.reveal-cp-name {
  font-size: 20px;
  color: #f4e8c8;
  margin-bottom: 12px;
  line-height: 1.3;
}

.reveal-cp-text {
  font-size: 14px;
  line-height: 1.7;
  color: #c8b88a;
  margin-bottom: 4px;
}

/* Locus/gate bio fragments — same reasoning as .je-memory-text
   (sheet.css): a recalled memory reads differently than field-report
   text, so it gets a distinct serif italic voice rather than the
   reveal cards' usual look. */
.reveal-memory-text {
  font-family: Georgia, 'Times New Roman', serif;
  font-style: italic;
  color: #d4c49a;
  font-size: 15px;
  line-height: 1.8;
}

.reveal-footer {
  display: flex;
  justify-content: space-between;
  width: min(360px, 100%);
  margin-top: 14px;
  font-size: 11px;
  color: #8a7a50;
  letter-spacing: 0.04em;
}
.reveal-tap-hint { opacity: 0.75; }

.reveal-progress {
  height: 2px;
  background: rgba(255,255,255,0.06);
  border-radius: 2px;
  overflow: hidden;
  margin-top: 16px;
}
.reveal-progress-fill {
  height: 100%;
  width: 100%;
  background: #c8a84e;
  transform-origin: left;
  animation: reveal-progress-shrink linear forwards;
}
@keyframes reveal-progress-shrink {
  from { transform: scaleX(1); }
  to   { transform: scaleX(0); }
}

/* Revisit variant — quieter, less ceremony than a first discovery */
.reveal-overlay.reveal-return { background: rgba(6,4,2,0.72); }

/* ── Quest activation — extra ceremony ──
   A new investigation is a bigger deal than a routine
   checkpoint ping: a breathing gold-glow border, an
   animated "incoming transmission" signal header, a
   bold typewriter name, and a real CTA button instead
   of just a tap-to-dismiss card. */

.reveal-overlay.reveal-quest-activated,
.reveal-overlay.reveal-quest-completed { background: rgba(4,3,1,0.92); }

.reveal-qa-card {
  width: min(380px, 100%);
  padding: 26px 24px 20px;
  border-color: var(--quest-green);
  border-width: 2px;
  animation: qa-card-glow 3.4s ease-in-out infinite;
  /* Quest-family tag — a real border, not a faint ring, so this reads
     as green at a glance rather than needing a close look. The warm
     gold glow stays as ambient texture (this app's established "magic
     is happening" signature) but green is the thing your eye actually
     identifies as "this is quest content." */
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 26px rgba(244,200,64,0.18),
    0 0 20px var(--quest-green-glow);
}
.reveal-qa-card::before { border-color: rgba(109,143,118,0.32); }
@keyframes qa-card-glow {
  0%, 100% { box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 24px rgba(244,200,64,0.15), inset 0 0 18px rgba(244,200,64,0.025), 0 0 18px var(--quest-green-glow); }
  50%      { box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 32px rgba(244,200,64,0.22), inset 0 0 22px rgba(244,200,64,0.05), 0 0 26px var(--quest-green-glow); }
}

/* ── Atlas Chapter Complete — the whole-game closing cinematic ──
   Deliberately the biggest, brightest card in this whole family: pure
   warm gold/white rather than any single sub-system's accent colour
   (quest-green, locus-violet, gate-violet), since this is the one
   moment meant to read as bigger than any of those individual systems —
   it's the sum of all of them at once. Wider than .reveal-qa-card too,
   to comfortably hold the stats grid beneath the title. */
.reveal-atlas-complete { background: rgba(6,4,2,0.55); }

.reveal-atlas-card {
  width: min(420px, 100%);
  padding: 28px 26px 22px;
  border-color: #f0d488;
  border-width: 2px;
  text-align: center;
  animation: atlas-card-glow 3s ease-in-out infinite;
  box-shadow: 0 24px 70px rgba(0,0,0,0.65), 0 0 40px rgba(240,212,136,0.28),
    0 0 70px rgba(240,212,136,0.12);
}
.reveal-atlas-card::before { border-color: rgba(240,212,136,0.4); }
@keyframes atlas-card-glow {
  0%, 100% { box-shadow: 0 24px 70px rgba(0,0,0,0.65), 0 0 34px rgba(240,212,136,0.22), inset 0 0 22px rgba(240,212,136,0.04), 0 0 56px rgba(240,212,136,0.1); }
  50%      { box-shadow: 0 24px 70px rgba(0,0,0,0.65), 0 0 48px rgba(240,212,136,0.34), inset 0 0 28px rgba(240,212,136,0.07), 0 0 80px rgba(240,212,136,0.16); }
}

.reveal-atlas-card .reveal-qa-header,
.reveal-atlas-card .reveal-qa-signal { justify-content: center; }
.reveal-atlas-card .reveal-qa-name-wrap,
.reveal-atlas-card .reveal-qa-eyebrow { text-align: center; }

.reveal-atlas-title {
  font-size: 24px;
  color: #f8ecc4;
  margin-bottom: 4px;
}

.reveal-atlas-rule {
  width: 60px;
  height: 1px;
  margin: 14px auto 18px;
  background: linear-gradient(90deg, transparent, rgba(240,212,136,0.6), transparent);
}

.reveal-atlas-stats {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 16px 22px;
  margin: 4px 0 6px;
}
.atlas-stat {
  width: 92px;
}
.atlas-stat-value {
  font-size: 22px;
  color: #f0d488;
  line-height: 1.1;
}
.atlas-stat-label {
  font-size: 9.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #9c8352;
  margin-top: 4px;
  line-height: 1.3;
}

.reveal-atlas-complete .reveal-footer { justify-content: center; }

/* ── Item pickup — mid-quest step completion ──
   Same card family as the checkpoint reveal (reveal-cp-card base),
   sized down and paced faster: this fires far more often per quest
   than activation/completion, so it needs to read as a satisfying
   beat, not a second reading assignment. Icon-led rather than
   text-led — reuses the icon vocabulary from journal.js (key/log/
   scrap/photo/object/safe/radio/oscilloscope), shown large (76px)
   instead of its usual ~13-28px in the journal/dossier. Carries the
   same quest-family accent ring as .reveal-qa-card above. */

.reveal-overlay.reveal-item-pickup { background: rgba(6,4,2,0.32); }

.reveal-item-card {
  width: min(340px, 100%);
  padding: 22px 20px 18px;
  text-align: center;
  border-color: var(--quest-green);
  border-width: 2px;
  transition: transform 0.4s cubic-bezier(0.34,1.56,0.64,1), opacity 0.3s ease;
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 24px rgba(200,168,78,0.1),
    0 0 20px var(--quest-green-glow);
}
.reveal-item-card::before { border-color: rgba(109,143,118,0.32); }

.ri-icon {
  width: 76px;
  height: 76px;
  margin: 0 auto 16px;
  color: var(--quest-green);
  filter: drop-shadow(0 0 10px var(--quest-green-glow));
}
.ri-icon svg { width: 100%; height: 100%; }

.reveal-item-card .reveal-eyebrow { text-align: center; margin-bottom: 6px; }
.reveal-item-card .reveal-cp-name { font-size: 18px; text-align: center; margin-bottom: 4px; }
.reveal-item-card .reveal-cp-text {
  text-align: left;
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid rgba(109,143,118,0.18);
}

/* ── Sigil acquired — variant of the item-pickup card above ──
   Violet accent instead of quest-green, matching the gate/threshold
   family this reward actually belongs to (a sigil's whole purpose is
   opening gates). The icon itself is real generated wax-seal art
   (buildSigilSVG) with its own baked-in gold gradient, not one of the
   stroke=currentColor icons from journal.js's vocabulary — so unlike
   .ri-icon, this variant doesn't tint it; it only adds the glow. */
.reveal-sigil-card {
  border-color: var(--gate-violet);
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 24px rgba(200,168,78,0.15),
    0 0 22px var(--gate-violet-glow);
}
.reveal-sigil-card::before { border-color: rgba(155,95,212,0.32); }
.ri-icon--sigil { color: transparent; filter: drop-shadow(0 0 14px var(--gate-violet-glow)); }

/* ── Locus reveal — bio fragment delivery ──
   Was previously unstyled (fell back to the plain gold checkpoint
   card) despite already having its own class hook. Same family-tag
   pattern as the quest cards above: recolor the border/glow, leave
   the base card's type + spacing alone. */
.reveal-locus-card {
  border-color: var(--locus-violet);
  border-width: 2px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 26px rgba(244,200,64,0.08),
    0 0 20px var(--locus-violet-glow);
}
.reveal-locus-card::before { border-color: rgba(144,112,200,0.3); }

/* "2 of 3" — see showLocusReveal's comment (engine.js) for why this
   exists: without it, a chained next-fragment card just appeared with
   no warning on the one currently on screen. Small and left-aligned
   like the rest of this card's text (unlike .reveal-milestone-count,
   which lives inside a centered card family) — a quiet status line,
   not something competing with the eyebrow or the character's name. */
.reveal-fragment-counter {
  font-size: 12px;
  color: #8a7a9a;
  letter-spacing: 0.03em;
  margin-top: -4px;
  margin-bottom: 10px;
}

/* ── Biographical Survey Complete — per-character closing moment ──
   Same violet family as the fragment-reveal card above (it's still
   this character's thread), just a hair brighter/more deliberate —
   this is the one reveal in that family meant to feel like a
   conclusion rather than another chapter. */
.reveal-bio-complete { background: rgba(6,4,2,0.4); }

.reveal-bio-card {
  text-align: center;
  border-width: 2px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 22px rgba(244,200,64,0.1),
    0 0 30px var(--locus-violet-glow);
}

.reveal-bio-title {
  font-size: 19px;
  color: #e8dcf4;
}

.reveal-bio-subject {
  font-size: 13px;
  color: #b89ce0;
  letter-spacing: 0.04em;
  margin-bottom: 10px;
}

.reveal-bio-count {
  font-size: 12px;
  color: #8a7a50;
  letter-spacing: 0.03em;
}

.reveal-bio-complete .reveal-footer { justify-content: space-between; }
.reveal-logged { opacity: 0.7; }

/* ── Three milestone mini-endings ──
   Same weight/tier as .reveal-bio-card above (a real beat, not a
   toast, but no typewriter/animation/stats-grid ceremony — that's
   reserved for the finale) — checkQuestsMilestone/checkLociMilestone/
   checkGatesMilestone in engine.js, one for each of the three things
   that used to just be lumped into "finish every quest" as the whole
   game's ending. Shared layout here; each gets its own family
   recolor below, matching every other card in this file's "family
   tag" convention. */
.reveal-milestone-card {
  text-align: center;
  border-width: 2px;
}
.reveal-milestone-title {
  font-size: 19px;
}
.reveal-milestone-count {
  font-size: 12px;
  color: #8a7a50;
  letter-spacing: 0.03em;
}

/* Quests — quest-green, same family as .reveal-qa-card/.reveal-item-card
   but without their animated glow or bigger sizing: this is the quiet
   version of that color, not another "new investigation" moment. */
.reveal-overlay.reveal-quests-complete { background: rgba(4,3,1,0.92); }
.reveal-quests-complete-card {
  border-color: var(--quest-green);
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 22px rgba(200,168,78,0.1),
    0 0 26px var(--quest-green-glow);
}
.reveal-quests-complete-card::before { border-color: rgba(109,143,118,0.32); }
.reveal-quests-complete-card .reveal-milestone-title { color: #e4f2e6; }

/* Loci — .reveal-locus-card (already applied alongside this class in
   engine.js) supplies the violet border/::before tint; this only
   brightens the glow a step further, same "biggest version of this
   system's own color" relationship .reveal-atlas-card has to
   .reveal-qa-card. */
.reveal-overlay.reveal-loci-complete { background: rgba(6,4,2,0.4); }
.reveal-loci-complete-card {
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 24px rgba(244,200,64,0.1),
    0 0 34px var(--locus-violet-glow);
}
.reveal-loci-complete-card .reveal-milestone-title { color: #e8dcf4; }

/* Gates — .reveal-gate-card supplies the violet border/::before tint;
   same brighten-the-glow relationship as loci above. */
.reveal-overlay.reveal-gates-complete { background: rgba(10,4,18,0.88); }
.reveal-gates-complete-card {
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 26px rgba(244,200,64,0.08),
    0 0 34px var(--gate-violet-glow);
}
.reveal-gates-complete-card .reveal-milestone-title { color: #ecd8fa; }

/* ── Gate — threshold activation confirm ──
   The "Cross the threshold?" prompt. Previously had zero CSS at all —
   Cross/Step back were rendering as unstyled native browser buttons
   against this app's fully custom UI. Recolored to the same violet
   family as the rest of the gate UI (.sp-glyph--gate, .sp-status--gate). */
.reveal-overlay.reveal-gate-activation,
.reveal-overlay.reveal-gate { background: rgba(10,4,18,0.88); }

.reveal-gate-card {
  border-color: var(--gate-violet);
  border-width: 2px;
  box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 26px rgba(244,200,64,0.08),
    0 0 20px var(--gate-violet-glow);
}
.reveal-gate-card::before { border-color: rgba(155,95,212,0.32); }

.reveal-gate-sub {
  color: #b8a0d8;
  margin-bottom: 20px;
}

.reveal-gate-btns {
  display: flex;
  gap: 10px;
  margin-top: 4px;
}

.reveal-gate-confirm,
.reveal-gate-cancel {
  flex: 1;
  padding: 12px 14px;
  border-radius: 8px;
  font: inherit;
  font-size: 12px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  cursor: pointer;
  transition: transform 0.15s ease, box-shadow 0.15s ease,
    background 0.15s ease, border-color 0.15s ease;
}
.reveal-gate-confirm:active,
.reveal-gate-cancel:active { transform: scale(0.96); }

.reveal-gate-confirm {
  border: 1px solid var(--gate-violet);
  background: linear-gradient(180deg, rgba(155,95,212,0.3), rgba(107,63,168,0.16));
  color: #f2e8ff;
  box-shadow: 0 0 16px rgba(155,95,212,0.28);
}
.reveal-gate-confirm:hover {
  background: linear-gradient(180deg, rgba(155,95,212,0.4), rgba(107,63,168,0.22));
  box-shadow: 0 0 22px rgba(155,95,212,0.42);
}

.reveal-gate-cancel {
  border: 1px solid rgba(184,160,216,0.22);
  background: transparent;
  color: #9a8ab8;
}
.reveal-gate-cancel:hover {
  background: rgba(184,160,216,0.08);
  border-color: rgba(184,160,216,0.4);
  color: #c8b8e8;
}

.reveal-item-source {
  font-size: 12px;
  color: #8a7a50;
  letter-spacing: 0.02em;
}

.reveal-qa-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 14px;
}

.reveal-qa-signal { display: flex; align-items: center; gap: 6px; }
.qa-dot {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: #f4c840;
  animation: qa-dot-pulse 1.2s ease-in-out infinite;
}
.qa-dot-2 { animation-delay: 0.15s; }
.qa-dot-3 { animation-delay: 0.3s; }
@keyframes qa-dot-pulse {
  0%, 100% { opacity: 0.25; transform: scale(0.8); }
  50%      { opacity: 1;    transform: scale(1.15); }
}
.qa-signal-label {
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: #8a7a50;
  margin-left: 4px;
}

.reveal-qa-from { font-size: 11px; color: #a084d8; font-style: italic; }

.reveal-qa-rule {
  height: 1px;
  background: linear-gradient(90deg, transparent, rgba(244,200,64,0.4), transparent);
  margin-bottom: 16px;
}

.reveal-qa-name-wrap { margin-bottom: 10px; }
.reveal-qa-eyebrow { color: #f4c840; }

.reveal-qa-name {
  font-size: 24px;
  font-weight: bold;
  color: #fff4c0;
  line-height: 1.3;
  min-height: 1.3em;
}
.reveal-qa-name::after {
  content: '';
  display: inline-block;
  width: 2px;
  height: 0.9em;
  background: #f4c840;
  margin-left: 2px;
  vertical-align: -0.1em;
  animation: qa-cursor-blink 0.8s step-end infinite;
}
.reveal-qa-name.qa-typed::after { animation: none; opacity: 0; }
@keyframes qa-cursor-blink { 50% { opacity: 0; } }

.reveal-qa-desc {
  font-size: 14px;
  line-height: 1.6;
  color: #c8b88a;
  margin-bottom: 10px;
}

.reveal-qa-steps { font-size: 12px; color: #9a8455; margin-bottom: 18px; }

.reveal-qa-actions { margin-bottom: 6px; }
.reveal-qa-open-btn {
  width: 100%;
  padding: 12px;
  background: linear-gradient(180deg, #ffe27a, #f4c840 60%, #d8a020);
  color: #241a08;
  font-weight: bold;
  font-size: 14px;
  letter-spacing: 0.02em;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-family: inherit;
  box-shadow: 0 4px 14px rgba(244,200,64,0.32);
  transition: transform 0.15s;
}
.reveal-qa-open-btn:active { transform: scale(0.97); }

/* ─────────────────────────────────────────────
   CONFIRM MODAL
   Generic on-brand confirmation dialog (see showConfirm()
   in ui.js) — used for Unlink, Log Out, and any future
   destructive-ish action instead of the browser's native
   confirm(). Sits above the bottom sheet (31) since it's
   triggered from inside it, but below the disclaimer/
   loading screens (100/200) since those are pre-game gates
   this will never coexist with.
   ───────────────────────────────────────────── */

#confirm-modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(4,3,2,0.7);
  z-index: 40;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
#confirm-modal-backdrop.open { opacity: 1; pointer-events: all; }

#confirm-modal {
  position: fixed;
  left: 50%;
  top: 50%;
  z-index: 41;
  width: min(320px, calc(100vw - 48px));
  background: #110e0a;
  border: 1px solid #4a3a1e;
  border-radius: 12px;
  box-shadow: 0 12px 40px rgba(0,0,0,0.6);
  padding: 20px;
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, -50%) scale(0.94);
  transition: opacity 0.25s ease, transform 0.25s cubic-bezier(0.22,1,0.36,1);
}
#confirm-modal.open {
  opacity: 1;
  pointer-events: all;
  transform: translate(-50%, -50%) scale(1);
}

/* The location explainer has to appear during boot, on top of the
   loading screen (100) and disclaimer (200) — see showConfirm(overScreens). */
#confirm-modal-backdrop.cm--over-screens { z-index: 210; }
#confirm-modal.cm--over-screens          { z-index: 211; }
.cm-btn[hidden] { display: none; }

.cm-title {
  font-size: 15px;
  color: #e8dcb8;
  margin-bottom: 8px;
}

.cm-message {
  font-size: 12px;
  line-height: 1.6;
  color: #8a7a50;
  margin-bottom: 18px;
}

.cm-actions {
  display: flex;
  gap: 10px;
  justify-content: flex-end;
}

.cm-btn {
  padding: 8px 16px;
  border-radius: 6px;
  font-size: 12px;
  cursor: pointer;
  font-family: inherit;
  border: 1px solid transparent;
}

.cm-btn-cancel {
  background: none;
  border-color: #3a2a1a;
  color: #8a7a50;
}

.cm-btn-confirm {
  background: #f4c840;
  color: #0c0a07;
  font-weight: bold;
  border-color: #f4c840;
}

.cm-btn-confirm--danger {
  background: #c04a44;
  color: #fff4ee;
  border-color: #c04a44;
}

/* ─────────────────────────────────────────────
   DISCLAIMER SCREEN
   Field briefing — shown once per version.
   Same dark atlas aesthetic as the loading screen.
   Sits above loading screen (z-index: 100).
   ───────────────────────────────────────────── */

#disclaimer-screen {
  position: fixed;
  inset: 0;
  background: #0c0a07;
  z-index: 200;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  padding: 48px 28px 32px;
  overflow-y: auto;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}
#disclaimer-screen[hidden] { display: none; }
#disclaimer-screen.ds-visible  { opacity: 1; pointer-events: all; }
#disclaimer-screen.ds-hiding   { opacity: 0; pointer-events: none; }

.ds-corner {
  position: fixed;
  width: 16px;
  height: 16px;
  border-color: rgba(200,168,78,0.3);
  border-style: solid;
  pointer-events: none;
}
.ds-corner.tl { top: 16px; left: 16px;   border-width: 1px 0 0 1px; }
.ds-corner.tr { top: 16px; right: 16px;  border-width: 1px 1px 0 0; }
.ds-corner.bl { bottom: 16px; left: 16px;  border-width: 0 0 1px 1px; }
.ds-corner.br { bottom: 16px; right: 16px; border-width: 0 1px 1px 0; }

.ds-icon {
  width: 52px;
  height: 52px;
  border-radius: 12px;
  opacity: 0.9;
  margin-bottom: 20px;
}

.ds-wordmark {
  text-align: center;
  margin-bottom: 8px;
}
.ds-sub {
  font-size: 11px;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: #6a5030;
  margin-bottom: 4px;
}
.ds-main {
  font-size: 22px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: #c8a84e;
}

.ds-divider {
  width: 48px;
  height: 1px;
  background: rgba(200,168,78,0.25);
  margin: 20px auto;
}

.ds-body {
  width: 100%;
  max-width: 380px;
  display: flex;
  flex-direction: column;
  gap: 16px;
  margin-bottom: 32px;
}

.ds-key {
  padding: 14px 16px;
  border: 1px solid rgba(200,168,78,0.4);
  border-radius: 10px;
  background: rgba(200,168,78,0.07);
}
.ds-key-head {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #e0bd5a;
  margin-bottom: 8px;
}
.ds-key-head .ds-glyph { margin-top: 0; opacity: 1; }
.ds-key-text {
  margin: 0 0 8px;
  font-size: 14px;
  line-height: 1.65;
  letter-spacing: 0.02em;
  color: #d9c58f;
}
.ds-key-text strong { color: #f0d488; font-weight: 700; }
.ds-key-sub {
  margin: 0;
  font-size: 12px;
  line-height: 1.6;
  letter-spacing: 0.03em;
  color: #8a7448;
}

.ds-point {
  display: flex;
  gap: 12px;
  align-items: flex-start;
}

.ds-glyph {
  color: #c8a84e;
  font-size: 12px;
  flex-shrink: 0;
  margin-top: 3px;
  opacity: 0.7;
}

.ds-point span:last-child {
  font-size: 14px;
  line-height: 1.7;
  color: #a0894f;
  letter-spacing: 0.03em;
}

.ds-accept-btn {
  width: 100%;
  max-width: 380px;
  padding: 16px;
  background: rgba(200,168,78,0.1);
  border: 1px solid rgba(200,168,78,0.45);
  border-radius: 10px;
  color: #c8a84e;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  cursor: pointer;
  transition: background 0.2s, border-color 0.2s;
  -webkit-tap-highlight-color: transparent;
}
.ds-accept-btn:hover  { background: rgba(200,168,78,0.18); border-color: rgba(200,168,78,0.7); }
.ds-accept-btn:active { background: rgba(200,168,78,0.24); }

.ds-version {
  margin-top: 16px;
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: #3a2e18;
}

/* ── Premise screen ──
   Narrative framing, not a legal form — no corner brackets, no bullet
   glyphs, no wordmark. Same background/z-index-tier logic as the
   disclaimer and loading screens (#0c0a07, fixed, full-bleed) so the
   three screens read as one continuous sequence rather than three
   visually distinct interruptions. Lines fade/rise in staggered,
   deliberately slower and calmer than this app's in-game typewriter
   reveals — those are the signature for something HAPPENING during
   play; this is a screen for reading, before anything has started. */
#premise-screen {
  position: fixed;
  inset: 0;
  background: #0c0a07;
  z-index: 150;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 32px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.6s ease;
}
#premise-screen[hidden]      { display: none; }
#premise-screen.ps-visible   { opacity: 1; pointer-events: all; }
#premise-screen.ps-hiding    { opacity: 0; pointer-events: none; }

.ps-lines {
  width: 100%;
  max-width: 400px;
  display: flex;
  flex-direction: column;
  gap: 22px;
  margin-bottom: 44px;
}

.ps-line {
  margin: 0;
  font-size: 16px;
  line-height: 1.75;
  color: #c8b88a;
  letter-spacing: 0.02em;
  text-align: center;
  opacity: 0;
  transform: translateY(8px);
}
/* Staggered fade/rise, only once the screen itself is visible —
   nth-child delays keep this to pure CSS, no JS timing to keep in
   sync with the copy if a line is ever added or removed. */
#premise-screen.ps-visible .ps-line {
  animation: ps-line-in 1s ease forwards;
}
#premise-screen.ps-visible .ps-line:nth-child(1) { animation-delay: 0.3s; }
#premise-screen.ps-visible .ps-line:nth-child(2) { animation-delay: 1.5s; }
#premise-screen.ps-visible .ps-line:nth-child(3) { animation-delay: 3.1s; }
@keyframes ps-line-in {
  to { opacity: 1; transform: translateY(0); }
}

.ps-continue-btn {
  width: 100%;
  max-width: 320px;
  padding: 16px;
  background: rgba(200,168,78,0.1);
  border: 1px solid rgba(200,168,78,0.45);
  border-radius: 10px;
  color: #c8a84e;
  font-family: 'Courier New', monospace;
  font-size: 15px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  cursor: pointer;
  opacity: 0;
  transition: background 0.2s, border-color 0.2s, opacity 0.6s ease;
  -webkit-tap-highlight-color: transparent;
}
/* Held back until the last line has had time to land — appearing any
   earlier would invite tapping through before the third line is even
   readable. */
#premise-screen.ps-visible .ps-continue-btn {
  opacity: 1;
  transition-delay: 3.6s;
}
.ps-continue-btn:hover  { background: rgba(200,168,78,0.18); border-color: rgba(200,168,78,0.7); }
.ps-continue-btn:active { background: rgba(200,168,78,0.24); }

#loading-screen {
  position: fixed;
  inset: 0;
  background: #0c0a07;
  z-index: 100;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-family: 'Courier New', Courier, monospace;
  padding: 40px 20px;
  overflow: hidden;
  transition: opacity 0.8s ease;
}

#loading-screen.hidden {
  opacity: 0;
  pointer-events: none;
  display: none;
}

.ld-grid {
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(rgba(200,168,78,0.03) 1px, transparent 1px),
    linear-gradient(90deg, rgba(200,168,78,0.03) 1px, transparent 1px);
  background-size: 32px 32px;
}

.ld-scanline {
  position: absolute;
  left: 0; right: 0;
  height: 2px;
  background: rgba(200,168,78,0.04);
  animation: ld-scan 4s linear infinite;
  pointer-events: none;
}

.ld-corner {
  position: absolute;
  width: 16px;
  height: 16px;
  border-color: #3a2a1a;
  border-style: solid;
}
.ld-corner.tl { top: 16px; left: 16px;    border-width: 1px 0 0 1px; }
.ld-corner.tr { top: 16px; right: 16px;   border-width: 1px 1px 0 0; }
.ld-corner.bl { bottom: 16px; left: 16px;  border-width: 0 0 1px 1px; }
.ld-corner.br { bottom: 16px; right: 16px; border-width: 0 1px 1px 0; }

/* ── Icon logo — real app icon with orbital rings ── */
.ld-logo {
  position: relative;
  width: 140px;
  height: 140px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 32px;
  animation: ld-fadeIn 0.8s ease both;
}

/* Orbital arc rings — spin around the icon */
.ld-ring {
  position: absolute;
  border-radius: 50%;
  border-style: solid;
  border-color: transparent;
  pointer-events: none;
}

.ld-ring-outer {
  width: 140px; height: 140px;
  border-width: 1px;
  border-top-color: rgba(200,168,78,0.55);
  border-right-color: rgba(200,168,78,0.2);
  animation: ld-spin 9s linear infinite;
}

.ld-ring-mid {
  width: 116px; height: 116px;
  border-width: 1px;
  border-bottom-color: rgba(106,218,188,0.5);
  border-left-color: rgba(106,218,188,0.2);
  animation: ld-spinR 6s linear infinite;
}

.ld-ring-inner {
  width: 94px; height: 94px;
  border-width: 1px;
  border-top-color: rgba(200,168,78,0.35);
  border-left-color: rgba(200,168,78,0.15);
  animation: ld-spin 3.5s linear infinite;
}

/* The real icon — black bg + gold border, sits in the orbital centre */
.ld-icon-wrap {
  position: relative;
  width: 80px;
  height: 80px;
  border-radius: 50%;
  overflow: hidden;
  border: 2px solid rgba(200,168,78,0.8);
  background: #000;
  z-index: 1;
  animation: ld-border-glow 3.5s ease-in-out infinite;
  flex-shrink: 0;
}

.ld-icon-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  animation: ld-icon-glow 3.5s ease-in-out infinite;
  display: block;
}

/* Legacy glyph — hidden now; kept so no JS needs changing */
.ld-glyph { display: none; }

.ld-wordmark {
  text-align: center;
  animation: ld-fadeIn 0.8s 0.2s ease both;
  margin-bottom: 6px;
}

.ld-sub {
  font-size: 13px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: #6a5030;
  margin-bottom: 4px;
}

.ld-main {
  font-size: 22px;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  color: #c8a84e;
  font-weight: 400;
  text-shadow: 0 0 24px rgba(200,168,78,0.3);
}

.ld-divider {
  width: 1px;
  height: 32px;
  background: linear-gradient(to bottom, transparent, #3a2a1a, transparent);
  margin: 20px auto;
  animation: ld-fadeIn 0.8s 0.4s ease both;
}

.ld-status-block {
  text-align: center;
  animation: ld-fadeIn 0.8s 0.6s ease both;
  margin-bottom: 32px;
}

.ld-status-label {
  font-size: 12px;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: #4a3a20;
  margin-bottom: 8px;
}

.ld-status-text {
  font-size: 12px;
  letter-spacing: 0.08em;
  color: #8a7040;
}

.ld-cursor {
  animation: ld-blink 1s step-end infinite;
  color: #c8a84e;
}

.ld-progress-track {
  width: 180px;
  height: 1px;
  background: #2a1e0e;
  margin: 16px auto 0;
  position: relative;
  overflow: hidden;
}

.ld-progress-fill {
  position: absolute;
  left: 0; top: 0; bottom: 0;
  background: #c8a84e;
  width: 0%;
  transition: width 0.6s ease;
}

.ld-steps {
  display: flex;
  flex-direction: column;
  gap: 8px;
  animation: ld-fadeIn 0.8s 0.8s ease both;
}

.ld-step {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #2a1e0e;
  transition: color 0.4s ease;
}

.ld-step.ld-done   { color: #6a5030; }
.ld-step.ld-active { color: #c8a84e; }

.ld-dot {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  border: 1px solid #2a1e0e;
  flex-shrink: 0;
  transition: background 0.4s, border-color 0.4s;
}

.ld-step.ld-done .ld-dot   { background: #6a5030; border-color: #6a5030; }
.ld-step.ld-active .ld-dot { background: #c8a84e; border-color: #c8a84e; animation: ld-pulse 1s ease-in-out infinite; }

.ld-region {
  position: absolute;
  bottom: 20px;
  right: 20px;
  font-size: 12px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #3a2a1a;
  animation: ld-fadeIn 1s 1s ease both;
}

/* ─────────────────────────────────────────────
   GPS ERROR SCREEN
   ───────────────────────────────────────────── */

@keyframes gps-fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes gps-ping   { 0% { transform: scale(0.8); opacity: 0.8; } 100% { transform: scale(2.4); opacity: 0; } }

#gps-error-screen {
  position: fixed;
  inset: 0;
  background: #0c0a07;
  z-index: 90;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-family: 'Courier New', Courier, monospace;
  padding: 40px 24px;
  text-align: center;
}

#gps-error-screen.visible {
  display: flex;
  animation: gps-fadeIn 0.5s ease both;
}

.gps-corner {
  position: absolute;
  width: 16px;
  height: 16px;
  border-color: #3a2a1a;
  border-style: solid;
}
.gps-corner.tl { top: 16px; left: 16px;    border-width: 1px 0 0 1px; }
.gps-corner.tr { top: 16px; right: 16px;   border-width: 1px 1px 0 0; }
.gps-corner.bl { bottom: 16px; left: 16px;  border-width: 0 0 1px 1px; }
.gps-corner.br { bottom: 16px; right: 16px; border-width: 0 1px 1px 0; }

.gps-icon-wrap {
  position: relative;
  width: 72px;
  height: 72px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 32px;
  animation: gps-fadeIn 0.6s 0.1s ease both;
}

.gps-ping {
  position: absolute;
  width: 72px;
  height: 72px;
  border-radius: 50%;
  border: 1px solid rgba(200,168,78,0.4);
  animation: gps-ping 2.4s ease-out infinite;
}

.gps-ping:nth-child(2) { animation-delay: 0.8s; }
.gps-ping:nth-child(3) { animation-delay: 1.6s; }

.gps-icon-circle {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  border: 1px solid #4a3a1a;
  background: #110e0a;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  color: #c8a84e;
  z-index: 1;
}

.gps-icon-circle.denied {
  border-color: #4a2a28;
  color: #8a4a48;
}

.gps-eyebrow {
  font-size: 12px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: #4a3a20;
  margin-bottom: 10px;
  animation: gps-fadeIn 0.6s 0.2s ease both;
}

.gps-title {
  font-size: 18px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #c8a84e;
  font-weight: 400;
  margin-bottom: 16px;
  animation: gps-fadeIn 0.6s 0.3s ease both;
}

.gps-body {
  font-size: 13px;
  line-height: 1.8;
  color: #a3874f;
  letter-spacing: 0.06em;
  max-width: 280px;
  margin-bottom: 36px;
  animation: gps-fadeIn 0.6s 0.4s ease both;
}

.gps-body em { color: #c8a84e; font-style: normal; }

.gps-actions {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  max-width: 260px;
  animation: gps-fadeIn 0.6s 0.5s ease both;
}

.gps-btn-primary {
  background: #1e1608;
  border: 1px solid #c8a84e;
  color: #c8a84e;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  padding: 14px 20px;
  cursor: pointer;
  transition: background 0.2s;
}

.gps-btn-primary:hover { background: #2e2010; }

.gps-btn-secondary {
  background: transparent;
  border: 1px solid #3a2a1a;
  color: #6a5030;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  padding: 12px 20px;
  cursor: pointer;
  transition: border-color 0.2s, color 0.2s;
}

.gps-btn-secondary:hover { border-color: #6a5030; color: #a08050; }

.gps-footer {
  position: absolute;
  bottom: 20px;
  font-size: 12px;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: #3a2a1a;
  animation: gps-fadeIn 0.6s 0.7s ease both;
}

/* ─────────────────────────────────────────────
   MAP OVERLAYS
   ───────────────────────────────────────────── */

/* Night-dimming tint — opacity set from JS (_updateNightMode in
   map.js) via getNightAmount() (engine.js), continuous 0..1 mapped to
   0..NIGHT_OVERLAY_MAX_OPACITY. Cool dark blue rather than flat black/
   gray — pairs naturally against the lantern's warm amber glow, same
   "warm light in the dark" language used everywhere else. The long
   transition duration is what makes periodic (every 5 min) JS updates
   read as a smooth fade rather than visible steps. */
#map-night-overlay {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: #050814;
  mix-blend-mode: multiply;
  opacity: 0;
  transition: opacity 45s linear;
}

/* Fog of war — background (the actual radial gradient, with its live
   center/radius) is set entirely from _updateFogOfWar() in map.js, not
   here; this rule only handles positioning/z-index/pointer-events, and
   defaults to fully transparent so there's no flash of solid black
   before the player's first position is known. z-index 5 sits above
   night/vignette/grain (1/2/4) but below markers — see the
   .maplibregl-marker z-index override above for why that needs to be
   explicit. */
#fog-of-war {
  position: fixed;
  inset: 0;
  z-index: 5;
  pointer-events: none;
  background: none;
  transition: opacity 0.6s ease;
}

#map-vignette {
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: radial-gradient(
    ellipse at center,
    transparent 50%,
    rgba(8, 6, 3, 0.35) 80%,
    rgba(5, 4, 2, 0.6) 100%
  );
}

#map-grain {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100dvh;
  z-index: 4;
  pointer-events: none;
  opacity: 0.022;
  mix-blend-mode: screen;
}

/* ── Gate-crossing vignette ──
   A separate element from the permanent #map-vignette above (that one
   is always-on, warm/dark, pure mood lighting — this one is normally
   fully invisible and exists only to pulse once). Fired by
   triggerGateCrossVignette() (map.js) right at the instant openGate()
   begins (engine.js) — BEFORE the GATE_RISE_MS delay that the tower's
   rise animation and the "threshold opened" card wait out — so the
   screen itself reacts to the crossing immediately, with the tower
   rising on the map and the full reveal card both following a beat
   later. z-index 6 sits above #map-night-overlay (5) and #map-vignette
   (2) so the pulse isn't dulled by the night tint, but well below the
   HUD (10) and FAB (20) — it washes over the map/background, never
   the interface. */
#gate-cross-vignette {
  position: fixed;
  inset: 0;
  z-index: 6;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(
    ellipse at center,
    transparent 40%,
    rgba(155, 95, 212, 0.35) 100%
  );
}
#gate-cross-vignette.pulse {
  animation: gateCrossVignettePulse 1.1s ease-out;
}
@keyframes gateCrossVignettePulse {
  0%   { opacity: 0; }
  15%  { opacity: 1; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  #gate-cross-vignette.pulse { animation: none; }
}

/* ─────────────────────────────────────────────
   QUEST FOOTER
   Ambient indicator above the FAB: quest name on
   its own row, plus (only when something is
   actually actionable right now) a completion CTA
   row underneath it — see _questFooterGateInfo in
   quests.js / updateQuestFooter in ui.js.
   Right-padded to clear the FAB (56px + 20px + gap).
   ───────────────────────────────────────────── */

#quest-footer {
  position: fixed;
  bottom: calc(32px + env(safe-area-inset-bottom));
  left: 16px;
  right: 92px;
  z-index: 19;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 8px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease, transform 0.4s ease;
  transform: translateY(6px);
  max-width: 360px;
}

#quest-footer.visible {
  opacity: 1;
  pointer-events: all;
  transform: translateY(0);
}

.qf-name-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Legibility: this text sits straight on the map with no backing, so on
   iPhone the regular-weight Courier strokes (hairline-thin) in a dim
   brown-gold (#7a6030) all but vanished over light/busy map colours.
   Now bold, in the same bright gold as the completion CTA, with a dark
   halo that keeps it readable over any map colour (light or dark). */
#quest-footer-glyph {
  font-size: 14px;
  color: #d9b43c;
  flex-shrink: 0;
  line-height: 1;
  filter: drop-shadow(0 0 2px rgba(10,8,5,0.95)) drop-shadow(0 1px 2px rgba(10,8,5,0.8));
}

#quest-footer-name {
  font-family: 'Courier New', Courier, monospace;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: #f0d488;
  text-shadow:
    0 0 2px rgba(10,8,5,0.95),
    0 1px 2px rgba(10,8,5,0.9),
    0 0 5px rgba(10,8,5,0.65);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  flex: 1;
  min-width: 0;
}

#quest-footer-dismiss {
  flex-shrink: 0;
  background: none;
  border: none;
  color: #a68a4a;
  font-size: 16px;
  line-height: 1;
  padding: 2px 6px;
  text-shadow: 0 0 2px rgba(10,8,5,0.95), 0 1px 2px rgba(10,8,5,0.8);
  cursor: pointer;
  transition: color 0.15s;
  -webkit-tap-highlight-color: transparent;
}

#quest-footer-dismiss:hover { color: #f0d488; }

/* ── Collapsed state ──
   Set by dismissQuestFooter() (ui.js) — the × collapses the footer
   down to just the glyph, as a small round chip, instead of hiding it
   outright. Tapping the chip (onQuestFooterRowClick) expands it back.
   #quest-footer normally has `align-items: stretch`, which would
   otherwise force .qf-name-row to the full footer width even once
   everything but the glyph inside it is hidden — override to
   flex-start so the row shrinks to the pill's own size. */
#quest-footer.qf-collapsed {
  align-items: flex-start;
}

#quest-footer.qf-collapsed .qf-name-row {
  width: 36px;
  height: 36px;
  justify-content: center;
  border-radius: 50%;
  background: rgba(17,14,10,0.82);
  border: 1px solid rgba(200,168,78,0.4);
  cursor: pointer;
}

#quest-footer.qf-collapsed #quest-footer-name,
#quest-footer.qf-collapsed #quest-footer-dismiss {
  display: none;
}

/* CTA is a sibling of .qf-name-row, not inside it, so it needs its own
   override — otherwise a ready/actionable CTA would render below a
   collapsed chip with nothing visually connecting the two. */
#quest-footer.qf-collapsed #quest-footer-cta {
  display: none;
}

/* ── Attention pulse ──
   Toggled both by engine.js (briefly, on fresh quest activation) and
   by updateQuestFooter (ui.js), when the tracked quest's completion
   gate becomes actionable while the footer was collapsed. Animates
   just the glyph rather than a box/border, since that's the one
   element present in both the expanded (boxless, ambient) and
   collapsed (pill) states. */
#quest-footer.quest-footer--pulse #quest-footer-glyph {
  animation: qfGlyphPulse 2s ease-in-out;
}

@keyframes qfGlyphPulse {
  0%, 100% { transform: scale(1);   color: #d9b43c; text-shadow: none; filter: drop-shadow(0 0 2px rgba(10,8,5,0.95)) drop-shadow(0 1px 2px rgba(10,8,5,0.8)); }
  30%, 70% { transform: scale(1.5); color: #f0d488; text-shadow: 0 0 8px rgba(240,212,136,0.75); filter: drop-shadow(0 0 4px rgba(240,212,136,0.8)); }
}

/* ── Completion CTA ──
   Two states, same shared shell:
   --ready   the gate can fire right now (in range, or no location
             requirement at all) — solid gold, gently pulsing so it
             reads as "something wants your attention", checkmark icon.
   --locate  the gate exists but its checkpoint is out of range —
             quieter/dimmer version of the same gold (deliberately NOT
             a different hue: it's still a quest-completion action, just
             not fireable from here yet), no pulse, arrow icon. Tapping
             it flies the map to the checkpoint instead of completing.
   The label text itself is identical either way — only the icon and
   emphasis change — since the wording always comes verbatim from the
   quest/step JSON regardless of which state it's in. */
#quest-footer-cta {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  padding: 11px 16px;
  border-radius: 8px;
  font-family: 'Courier New', Courier, monospace;
  font-size: 13px;
  font-weight: bold;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background 0.18s, border-color 0.18s, transform 0.12s;
}

#quest-footer-cta[hidden] { display: none; }

#quest-footer-cta:active { transform: scale(0.97); }

#quest-footer-cta-label {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

#quest-footer-cta.qf-cta--ready {
  background: rgba(200,168,78,0.14);
  border: 1px solid rgba(200,168,78,0.55);
  color: #f0d488;
  animation: qfCtaPulse 2.4s ease-in-out infinite;
}
#quest-footer-cta.qf-cta--ready:hover  { background: rgba(200,168,78,0.22); border-color: rgba(200,168,78,0.8); }
#quest-footer-cta.qf-cta--ready::before { --icon-mask: var(--icon-quest); }

#quest-footer-cta.qf-cta--locate {
  background: rgba(200,168,78,0.05);
  border: 1px solid rgba(200,168,78,0.28);
  color: #9c8352;
  animation: none;
}
#quest-footer-cta.qf-cta--locate:hover  { background: rgba(200,168,78,0.1); border-color: rgba(200,168,78,0.42); }
#quest-footer-cta.qf-cta--locate::before { content: '→  '; }

@keyframes qfCtaPulse {
  0%, 100% { box-shadow: 0 0 0 rgba(200,168,78,0); }
  50%      { box-shadow: 0 0 12px rgba(200,168,78,0.4); }
}

/* ─────────────────────────────────────────────
   UPDATE CHECK — soft banner + blocking screen
   See the UPDATE CHECK section in ui.js for the check/decide logic; this
   is purely presentation for the two outcomes.
   ───────────────────────────────────────────── */

/* ── Soft banner — top of screen, dismissible, routine releases ── */
#update-banner {
  position: fixed;
  top: calc(16px + env(safe-area-inset-top));
  left: 16px;
  right: 16px;
  z-index: 60; /* above map UI, below the hard-block screen (500) */
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  background: rgba(16,12,8,0.96);
  border: 1px solid rgba(200,168,78,0.4);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.5);
  opacity: 0;
  transform: translateY(-8px);
  pointer-events: none;
  transition: opacity 0.3s ease, transform 0.3s ease;
}
#update-banner[hidden] { display: none; }
#update-banner.visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: all;
}

#update-banner-message {
  flex: 1;
  min-width: 0;
  font-family: 'Courier New', Courier, monospace;
  font-size: 12.5px;
  color: #c8b88a;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

#update-banner-reload {
  flex-shrink: 0;
  padding: 6px 12px;
  background: rgba(200,168,78,0.16);
  border: 1px solid rgba(200,168,78,0.55);
  border-radius: 6px;
  color: #f0d488;
  font-family: 'Courier New', Courier, monospace;
  font-size: 12px;
  font-weight: bold;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
#update-banner-reload:hover { background: rgba(200,168,78,0.26); }

#update-banner-dismiss {
  flex-shrink: 0;
  background: none;
  border: none;
  color: #5a4a2a;
  font-size: 16px;
  line-height: 1;
  padding: 2px 4px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
#update-banner-dismiss:hover { color: #9a8455; }

/* ── Hard block — full-bleed, undismissable, forced releases only ──
   Same background/full-bleed convention as the disclaimer/loading/
   premise screens (#0c0a07, position:fixed, inset:0) so it reads as
   the same family of "screen you have to get through" rather than an
   unfamiliar new visual language, even though what's ON it is new. */
#update-block-screen {
  position: fixed;
  inset: 0;
  background: #0c0a07;
  /* Highest z-index in the app, deliberately — this has to be able to
     interrupt EVERYTHING, including the disclaimer (200) and premise
     (150) screens, since the check starts immediately at launch and
     doesn't wait for onboarding to finish first (see firebase.js). A
     stale returning player could hit a forced-update mismatch while
     still sitting on any of those. */
  z-index: 500;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}
#update-block-screen[hidden] { display: none; }
#update-block-screen.visible { opacity: 1; pointer-events: all; }

.ub-card {
  width: 100%;
  max-width: 360px;
  text-align: center;
  padding: 32px 26px;
  border: 1px solid rgba(200,168,78,0.4);
  border-radius: 12px;
  background: rgba(20,16,10,0.6);
}

.ub-title {
  font-family: 'Courier New', Courier, monospace;
  font-size: 18px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #f0d488;
  margin-bottom: 14px;
}

.ub-message {
  font-size: 14px;
  line-height: 1.7;
  color: #c8b88a;
  margin-bottom: 26px;
}

.ub-reload-btn {
  width: 100%;
  padding: 14px;
  background: rgba(200,168,78,0.14);
  border: 1px solid rgba(200,168,78,0.55);
  border-radius: 8px;
  color: #f0d488;
  font-family: 'Courier New', Courier, monospace;
  font-size: 14px;
  font-weight: bold;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  animation: qfCtaPulse 2.4s ease-in-out infinite;
}
.ub-reload-btn:hover  { background: rgba(200,168,78,0.22); }
.ub-reload-btn:active { transform: scale(0.98); }

/* ── Portrait lock ──
   Hidden by `display: none` always — the ONLY thing that ever shows
   this is the `orientation: landscape` media query below, flipping it
   to flex. No JS toggles this, on purpose: a pure-CSS condition shows
   up the instant the device rotates, even if the app hasn't finished
   booting yet (compare #disclaimer-screen/#premise-screen, which both
   wait on JS and a version check before they can show anything).
   z-index 600 is deliberately the single highest in the app — one
   above #update-block-screen's 500, which itself documents needing to
   beat everything else. A phone rotated to landscape has to block
   literally every other screen, including a forced update prompt. */
#landscape-lock-screen {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 600;
  background: #0c0a07;
  align-items: center;
  justify-content: center;
  padding: 32px;
}
/* hover: none and pointer: coarse together reliably mean "this is a
   touch-primary device" (phone/tablet) — a mouse gives hover and a fine
   pointer, so a normal desktop/laptop window never matches this, no
   matter how wide it is. orientation: landscape ALONE isn't enough on
   its own, since most desktop windows are already wider than tall by
   default — without this extra check, the lock screen showed constantly
   on a computer, which was never the intent. */
@media (orientation: landscape) and (hover: none) and (pointer: coarse) {
  #landscape-lock-screen { display: flex; }
}

.ll-card {
  width: 100%;
  max-width: 360px;
  text-align: center;
  padding: 32px 26px;
  border: 1px solid rgba(200,168,78,0.4);
  border-radius: 12px;
  background: rgba(20,16,10,0.6);
}
.ll-icon {
  font-size: 40px;
  margin-bottom: 14px;
  /* CSS rotate, not an actual animated icon swap — simplest possible
     way to show "turn your phone" without new art. transform-origin
     stays centered so it spins in place rather than swinging an arc. */
  display: inline-block;
  animation: llIconTurn 2.4s ease-in-out infinite;
}
@keyframes llIconTurn {
  0%, 100% { transform: rotate(0deg); }
  50%      { transform: rotate(90deg); }
}
.ll-title {
  font-family: 'Courier New', Courier, monospace;
  font-size: 18px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #f0d488;
  margin-bottom: 14px;
}
.ll-message {
  font-size: 14px;
  line-height: 1.7;
  color: #c8b88a;
}
@media (prefers-reduced-motion: reduce) {
  .ll-icon { animation: none; }
}

/* ─────────────────────────────────────────────
   SIDE PANEL — unified marker info + quest action
   Single persistent element, content states:
     .sp--checkpoint-unknown  (undiscovered checkpoints only —
                               discovered ones skip this panel entirely,
                               see showInspectCard() in map.js)
     .sp--locus
     .sp--gate / .sp--gate-opened
     .sp--quest-action
   ───────────────────────────────────────────── */

#side-panel {
  position: fixed;
  bottom: 96px;
  left: 16px;
  right: 80px;
  max-width: 400px;
  z-index: 22;
  opacity: 0;
  transform: translateY(14px);
  pointer-events: none;
  transition: opacity 0.26s ease, transform 0.26s cubic-bezier(0.34, 1.3, 0.64, 1);
}

#side-panel.sp-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: all;
}

#side-panel.sp-hiding {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.22s ease, transform 0.22s ease;
  pointer-events: none;
}

/* ── Base panel card shell ── */
.sp-panel {
  background: rgba(11,8,5,0.96);
  border: 1px solid rgba(200,168,78,0.18);
  border-left: 3px solid rgba(200,168,78,0.35);
  border-radius: 0 12px 12px 0;
  padding: 14px 16px 16px;
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}

/* ── State: undiscovered ── */
.sp--checkpoint-unknown {
  border-left-color: rgba(200,168,78,0.28);
}

/* ── State: checkpoint-survey ──
   The moment a checkpoint is actually actionable (in range, tap to
   survey) — promoted from the docked ambient-info panel to the same
   centered/backdropped modal treatment as quest-action, just in gold
   to match the checkpoint family rather than green. See _renderCheckpoint
   in map.js. */
#side-panel.sp--checkpoint-survey {
  position: fixed;
  top: 50%;
  left: 50%;
  right: auto;
  bottom: auto;
  width: min(300px, calc(100vw - 48px));
  max-width: min(300px, calc(100vw - 48px));
  z-index: 91;
  transform: translate(-50%, calc(-50% + 8px)) scale(0.96);
  transition: opacity 0.2s ease, transform 0.22s cubic-bezier(0.34,1.56,0.64,1);
  border: 2px solid rgba(200,168,78,0.65);
  border-radius: 14px;
  padding: 20px 20px 22px;
  text-align: center;
  box-shadow: 0 12px 36px rgba(0,0,0,0.4), 0 0 18px rgba(200,168,78,0.28);
}
#side-panel.sp--checkpoint-survey::before {
  content: '';
  position: absolute;
  inset: 6px;
  border: 1px solid rgba(200,168,78,0.24);
  clip-path: polygon(
    10px 0, calc(100% - 10px) 0, 100% 10px,
    100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%,
    0 calc(100% - 10px), 0 10px
  );
  pointer-events: none;
}
#side-panel.sp--checkpoint-survey.sp-visible {
  transform: translate(-50%, -50%) scale(1);
}
#side-panel.sp--checkpoint-survey.sp-hiding {
  transform: translate(-50%, calc(-50% + 8px)) scale(0.96);
  transition: opacity 0.16s ease, transform 0.16s ease;
}

.sp-close--survey {
  position: absolute;
  top: 10px;
  right: 10px;
}
.sp-survey-eyebrow {
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #c8a84e;
  opacity: 0.85;
  margin-bottom: 8px;
}
.sp-name--survey {
  font-size: 19px;
  margin-bottom: 6px;
}
.sp-dist--survey {
  display: block;
  font-size: 13px;
  color: #c8a84e;
  opacity: 0.8;
  margin-bottom: 18px;
}
.sp-action-btn--survey {
  width: 100%;
  padding: 13px 16px;
  font-size: 13px;
}

/* ── State: locus ── */
.sp--locus {
  border-left-color: rgba(192,132,252,0.6);
  border-color: rgba(192,132,252,0.18);
}

/* ── State: gate ── */
.sp--gate {
  border-left-color: rgba(155,95,212,0.55);
  border-color: rgba(155,95,212,0.16);
}
.sp--gate-opened {
  border-left-color: rgba(192,132,252,0.7);
}

/* ── State: quest-action ──
   This is the actual interaction — "Collect", a gate's gate_prompt like
   "open the safe?" — not an ambient ping, so unlike every other panel
   state above (which stay bottom-docked, no backdrop), this one gets
   its own centered, dimmed, modal-style presentation: same green as
   the other quest surfaces, but positioned and animated independently
   of #side-panel's base bottom-docked rules via extra specificity
   (id + two classes) rather than editing those rules, since checkpoint-
   found/locus/gate all still need the original docked behavior. */
#side-panel.sp--quest-action {
  position: fixed;
  top: 50%;
  left: 50%;
  right: auto;
  bottom: auto;
  width: min(320px, calc(100vw - 48px));
  max-width: min(320px, calc(100vw - 48px));
  z-index: 91; /* above reveal-overlay's 90 — never buried by a leftover reveal card */
  transform: translate(-50%, calc(-50% + 8px)) scale(0.96);
  transition: opacity 0.2s ease, transform 0.22s cubic-bezier(0.34,1.56,0.64,1);
  /* Uniform border + radius, not the base .sp-panel docked-notification
     look (border-left accent stripe, corners rounded only on the right
     as if flush against a screen edge) — this card is centered, not
     docked, and every other centered card in the game (.reveal-cp-card
     family) is framed evenly on all sides. */
  border: 2px solid var(--quest-green);
  border-radius: 14px;
  box-shadow: 0 12px 36px rgba(0,0,0,0.4), 0 0 18px var(--quest-green-glow);
}
#side-panel.sp--quest-action::before {
  content: '';
  position: absolute;
  inset: 6px;
  border: 1px solid rgba(109,143,118,0.32);
  clip-path: polygon(
    10px 0, calc(100% - 10px) 0, 100% 10px,
    100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%,
    0 calc(100% - 10px), 0 10px
  );
  pointer-events: none;
}
#side-panel.sp--quest-action.sp-visible {
  transform: translate(-50%, -50%) scale(1);
}
#side-panel.sp--quest-action.sp-hiding {
  transform: translate(-50%, calc(-50% + 8px)) scale(0.96);
  transition: opacity 0.16s ease, transform 0.16s ease;
}

/* Dedicated backdrop, active only while the quest-action panel is
   showing — see _getBackdropEl() in map.js. A separate element rather
   than a ::before on #side-panel itself: that panel needs its own
   `transform` for centering, and any transform on an element makes
   position:fixed descendants size themselves against IT instead of
   the viewport, which would break a full-screen backdrop specifically.
   Kept deliberately light (0.22, vs. 0.86-0.92 on the activation/
   completion cinematics) — this is a decision point in an otherwise
   uninterrupted chain of centered cards, not a payoff moment, so the
   map/checkpoint should stay clearly visible underneath it rather than
   the screen going fully dark again. */
#quest-action-backdrop {
  position: fixed;
  inset: 0;
  z-index: 21; /* just under #side-panel's 22/91, above the map */
  background: rgba(6,4,2,0.22);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
#quest-action-backdrop.qab-active {
  opacity: 1;
  pointer-events: all;
}

/* ── Header ── */
.sp-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 8px;
}

.sp-name-wrap {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.sp-glyph {
  font-size: 13px;
  flex-shrink: 0;
  line-height: 1;
}
.sp-glyph--locus { color: #c084fc; text-shadow: 0 0 10px rgba(192,132,252,0.5); }
.sp-glyph--locus .icon-glyph { filter: drop-shadow(0 0 4px rgba(192,132,252,0.6)); }
.sp-glyph--gate  { color: #9b5fd4; }
.sp-glyph--quest { color: var(--quest-green); text-shadow: 0 0 10px var(--quest-green-glow); }
.sp-glyph--quest .icon-glyph { filter: drop-shadow(0 0 4px var(--quest-green-glow-strong)); }

.sp-name {
  font-size: 13px;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: #c8a84e;
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.sp-name--locus                { color: #c084fc; }
.sp-name--gate                 { color: #9b5fd4; }

.sp-close {
  flex-shrink: 0;
  background: none;
  border: none;
  color: #4a3a1a;
  font-size: 18px;
  line-height: 1;
  padding: 0 0 0 8px;
  cursor: pointer;
  transition: color 0.15s;
  -webkit-tap-highlight-color: transparent;
}
.sp-close:hover { color: #c8a84e; }

/* ── Meta row (distance + status) ── */
.sp-meta {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 0;
}

.sp-dist {
  font-size: 14px;
  letter-spacing: 0.12em;
  color: #a08040;
}
.sp--locus .sp-dist      { color: #9070b8; }
.sp--gate .sp-dist       { color: #7b4fd4; }

.sp-status {
  font-size: 12px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #7a6a48;
}
.sp-status--locus                { color: #7b4fd4; }
.sp-status--gate                 { color: #6b3fa8; }
.sp--gate-opened .sp-status      { color: #9070c8; }

/* ── Flavour text for undiscovered checkpoints ── */
.sp-flavour {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(200,168,78,0.06);
  font-size: 14px;
  line-height: 1.6;
  color: #9a8455;
  letter-spacing: 0.08em;
}

/* ── Signal hint ── */
.sp-signal-hint {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(200,168,78,0.07);
  font-size: 13px;
  letter-spacing: 0.13em;
  color: #a08040;
  opacity: 0.8;
}
.sp-signal-hint--locus {
  color: #9070b8;
  border-top-color: rgba(192,132,252,0.1);
}

/* ── Locus memories gallery — every fragment delivered so far ──
   Each one styled as an actual recalled memory (serif italic, quoted —
   see formatMemoryQuote/utils.js, the same treatment the reveal
   cinematic and Journal use) rather than more field-report UI copy.
   Scrolls internally past a handful of entries so a well-contacted
   character's panel doesn't just keep growing indefinitely. */
.sp-locus-memories {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(192,132,252,0.12);
}
.sp-locus-memories-label {
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: #7b4fd4;
  opacity: 0.75;
  margin-bottom: 8px;
}
.sp-locus-memories-list {
  max-height: 260px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding-right: 4px;
}
.sp-locus-memories-list::-webkit-scrollbar { width: 4px; }
.sp-locus-memories-list::-webkit-scrollbar-thumb {
  background: rgba(192,132,252,0.25);
  border-radius: 2px;
}

.sp-locus-memory {
  position: relative;
  display: flex;
  gap: 10px;
  padding: 10px 12px 10px 14px;
  background: rgba(123,79,212,0.05);
  border: 1px solid rgba(192,132,252,0.14);
  border-left: 2px solid rgba(192,132,252,0.4);
  border-radius: 0 8px 8px 0;
}

.sp-locus-memory-index {
  flex-shrink: 0;
  font-family: 'Courier New', Courier, monospace;
  font-size: 10px;
  letter-spacing: 0.05em;
  color: #9070b8;
  opacity: 0.7;
  padding-top: 2px;
}

.sp-locus-memory-text {
  font-family: Georgia, 'Times New Roman', serif;
  font-style: italic;
  font-size: 13.5px;
  line-height: 1.7;
  color: #d0c0ec;
}

/* ── Action block (cp interact button) ── */
.sp-action-block {
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid rgba(200,168,78,0.1);
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* ─────────────────────────────────────────────
   DEBUG MODE — dev-only teleport tool (debug.js)
   Deliberately plain/utilitarian styling, not the game's aged-gold
   aesthetic — this should never be mistaken for player-facing UI by
   anyone who somehow ends up seeing it.
   ───────────────────────────────────────────── */
#debug-toggle {
  position: fixed;
  /* Clear the iPhone status bar / Dynamic Island: in the Home Screen app the
     page draws under it (viewport-fit=cover) and anything there can't be tapped. */
  top: calc(14px + env(safe-area-inset-top));
  right: 14px;
  z-index: 200;
  width: 36px;
  height: 36px;
  border-radius: 8px;
  background: #1a1a1a;
  border: 1px solid #444;
  color: #fff;
  font-size: 16px;
  cursor: pointer;
}

#debug-panel {
  position: fixed;
  top: calc(56px + env(safe-area-inset-top));
  right: 14px;
  z-index: 200;
  width: 240px;
  max-height: calc(70vh - env(safe-area-inset-top));
  overflow-y: auto;
  background: #161616;
  border: 1px solid #444;
  border-radius: 8px;
  padding: 10px;
  font-family: monospace;
  font-size: 12px;
  color: #ddd;
  box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}

.debug-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-weight: bold;
  margin-bottom: 8px;
  color: #fff;
}
.debug-header button {
  background: none;
  border: none;
  color: #999;
  font-size: 16px;
  cursor: pointer;
  line-height: 1;
}

.debug-click-toggle {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 8px;
  padding-bottom: 8px;
  border-bottom: 1px solid #333;
  cursor: pointer;
  user-select: none;
}

.debug-gps-lock {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
  padding: 6px 8px;
  background: rgba(200,168,78,0.12);
  border: 1px solid rgba(200,168,78,0.35);
  border-radius: 6px;
  font-size: 11px;
  color: #d4bc80;
}

/* Same row shape as .debug-gps-lock above, but neutral rather than
   amber-warning — this is routine info (how old the cached region
   content is), not "something's overriding normal behavior" the way
   a locked debug position is. */
.debug-cache-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 8px;
  padding: 6px 8px;
  background: #222;
  border: 1px solid #3a3a3a;
  border-radius: 6px;
}
.debug-cache-row .debug-mini-hint { font-style: normal; }

.debug-cp-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.debug-section-label {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #777;
  margin: 10px 0 4px;
}
.debug-section-label:first-of-type { margin-top: 0; }

.debug-cp-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: #222;
  border: 1px solid #3a3a3a;
  border-radius: 4px;
  padding: 6px 8px;
  color: #ddd;
  cursor: pointer;
  text-align: left;
  font-family: inherit;
  font-size: inherit;
}
.debug-cp-row:hover { background: #2a2a2a; border-color: #666; }
.debug-cp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.debug-cp-dist { color: #888; flex-shrink: 0; margin-left: 8px; }
.debug-empty { color: #777; font-style: italic; }

.debug-locus-row {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.debug-locus-actions {
  display: flex;
  justify-content: flex-end;
  padding: 0 2px;
}
.debug-mini-btn {
  background: #2a2a1a;
  border: 1px solid #665;
  color: #dd8;
  border-radius: 3px;
  padding: 2px 6px;
  font-size: 10px;
  font-family: inherit;
  cursor: pointer;
}
.debug-mini-btn:hover { background: #3a3a24; }
.debug-mini-hint {
  font-size: 10px;
  color: #666;
  font-style: italic;
}

/* ── Shared action button ── */
.sp-action-btn {
  display: block;
  width: 100%;
  padding: 12px 14px;
  background: rgba(200,168,78,0.09);
  border: 1px solid rgba(200,168,78,0.42);
  border-radius: 7px;
  color: #c8a84e;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
  text-align: center;
  transition: background 0.18s, border-color 0.18s;
  -webkit-tap-highlight-color: transparent;
}
.sp-action-btn:hover  { background: rgba(200,168,78,0.17); border-color: rgba(200,168,78,0.68); }
.sp-action-btn:active { background: rgba(200,168,78,0.24); }

/* Quest step buttons (pickup / collect / complete / anything else) —
   every one of them carries the quest star ✦, the same glyph that
   marks quests everywhere else (side-panel header, journal badge,
   quest-list eyebrow). They used to differ by sub-type — a diamond for
   pickup, a plain dot for collect, a checkmark for complete, nothing
   at all for an unrecognised action — which read as inconsistent, since
   they're all "a quest thing you tap." Keyed off data-action rather
   than the sub-type classes, so a new action type gets the star too.
   The sub-type classes (--pickup/--collect/--complete) are still on
   the buttons and free for styling if they ever need to differ. */
.sp-action-btn[data-action="qa-action"]::before { --icon-mask: var(--icon-quest); font-size: 12px; }

/* Locus/gate activate buttons — same shared .sp-action-btn shell, but
   tinted to match each panel's own violet accent (.sp--locus/.sp--gate
   above) rather than the gold used for checkpoint/quest actions, and
   prefixed with the same glyph each panel already uses in its header
   (⬡ locus, ▣ gate) for consistency with the pickup/collect/complete
   prefixes above. */
.sp-action-btn--locus {
  margin-top: 12px;
  color: #b48cf0;
  border-color: rgba(192,132,252,0.45);
  background: rgba(192,132,252,0.08);
}
.sp-action-btn--locus:hover  { background: rgba(192,132,252,0.16); border-color: rgba(192,132,252,0.7); }
.sp-action-btn--locus:active { background: rgba(192,132,252,0.22); }
.sp-action-btn--locus::before { --icon-mask: var(--icon-locus); font-size: 12px; }

.sp-action-btn--gate {
  margin-top: 12px;
  color: #a878e0;
  border-color: rgba(155,95,212,0.45);
  background: rgba(155,95,212,0.08);
}
.sp-action-btn--gate:hover  { background: rgba(155,95,212,0.16); border-color: rgba(155,95,212,0.7); }
.sp-action-btn--gate:active { background: rgba(155,95,212,0.22); }
.sp-action-btn--gate::before { --icon-mask: var(--icon-gate); font-size: 12px; }

/* ── Quest-action panel ── */

/* Pager — only rendered when a checkpoint serves more than one quest at
   once (expected to be common, up to 2-3). Ties to the same green as
   the rest of this panel rather than introducing a new accent. */
.sp-qa-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  margin-bottom: 12px;
}
.sp-qa-pager-btn {
  background: none;
  border: 1px solid rgba(109,143,118,0.4);
  color: var(--quest-green);
  width: 26px;
  height: 26px;
  border-radius: 50%;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: background 0.15s ease, opacity 0.15s ease;
}
.sp-qa-pager-btn:hover:not(:disabled) { background: rgba(109,143,118,0.16); }
.sp-qa-pager-btn:disabled { opacity: 0.25; cursor: default; }
.sp-qa-pager-count {
  font-size: 12px;
  letter-spacing: 0.06em;
  color: #8a7a50;
  min-width: 40px;
  text-align: center;
}

.sp-qa-match {
  padding-top: 14px;
  margin-top: 14px;
  border-top: 1px solid rgba(200,168,78,0.08);
}
.sp-qa-match:first-child {
  padding-top: 0;
  margin-top: 0;
  border-top: none;
}

/* The step's short event label ("Discovered old photo") — this is now
   the panel's primary message (previously this class held the quest
   name, a stable subtitle; see _renderQuestActionPage in map.js for
   why that got swapped), so it gets real visual weight instead of a
   small muted eyebrow treatment: centered, larger, warmer gold — closer
   to how the reveal card treats this same label than to a caption. */
.sp-qa-event {
  font-size: 17px;
  text-align: center;
  color: #d4b876;
  letter-spacing: 0.01em;
  margin-bottom: 10px;
  line-height: 1.35;
}

/* Reuses .ri-icon's sizing/color/glow — just a touch less bottom
   margin, since this panel is more compact than the full-screen
   reveal cards .ri-icon was originally sized for. */
.sp-qa-icon {
  margin-bottom: 12px;
}

/* Locked state (an incomplete complete_quest match, riding along in the
   same panel/pager as actionable ones — see runQuestEngine). Reuses the
   quest tab's own progress-bar markup (qd-gate-progress, sheet.css) for
   a consistent "X/Y prerequisites" look app-wide, just re-tinted green
   to match this panel's own accent instead of the quest tab's purple. */
.sp-qa-match .qd-gate-progress-fill { background: var(--quest-green); }
.sp-qa-locked-hint {
  font-size: 11px;
  font-style: italic;
  color: #6a7a6e;
  text-align: center;
  margin-top: 2px;
}

.sp-dismiss-btn {
  display: block;
  width: 100%;
  margin-top: 8px;
  padding: 10px 14px;
  background: transparent;
  border: 1px solid #3a2c18;
  border-radius: 7px;
  color: #6a5030;
  font-family: 'Courier New', monospace;
  font-size: 13px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color 0.18s, color 0.18s;
  -webkit-tap-highlight-color: transparent;
}
.sp-dismiss-btn:hover { border-color: #6a5030; color: #a08050; }