/* Self-hosted Fraunces/Inter, not loaded from Google Fonts - a visitor's
   browser never has to talk to fonts.gstatic.com this way: no third-party
   request, no IP address leaving this site's own domain. Mirrors Google
   Fonts' own generated CSS structure exactly (same file per subset reused
   across all three font-weight faces - both are variable fonts, so the
   browser renders each face's declared weight correctly off the one
   file), just with local paths and only the two subsets this site's
   actual content needs (Spanish + English - see index.html's own
   "es"/"en" - not Google's default bundle of six, which also ships
   Cyrillic/Greek/Vietnamese glyphs nothing here ever uses). To refresh
   either family (a new weight, a style addition): fetch
   https://fonts.googleapis.com/css2?family=... with a real browser
   User-Agent (curl's default gets woff/ttf, not woff2), keep just the
   "latin"/"latin-ext" blocks, download each unique src url into
   assets/fonts/, and update the paths below to match. */
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('assets/fonts/fraunces-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('assets/fonts/fraunces-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('assets/fonts/fraunces-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('assets/fonts/fraunces-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('assets/fonts/fraunces-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('assets/fonts/fraunces-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('assets/fonts/inter-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('assets/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('assets/fonts/inter-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('assets/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('assets/fonts/inter-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('assets/fonts/inter-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* CyL purple sourced from a flag-color reference (flagcolorcodes.com),
     not an official Junta style guide - used only as a UI accent (topbar,
     headings), never for the linked/missing marker semantics below. */
  --cyl-purple: #742c64;
  --cyl-purple-dark: #4f1e44;

  /* Not Bootstrap's default danger-red/success-green - "missing" isn't an
     error, it's an invitation to help, and the alarm-red read wrong before
     it even read dated. Teal/terracotta carries the same signal more
     honestly and sits better next to photography. */
  /* One covered/gap axis, used for two different questions: the map and
     its lists color by "has a free photo", the Stats chart by "linked to
     Wikidata". Named for the role rather than either meaning, so neither
     use has to read a variable that describes the other one. */
  /* One marker color for every monument. The photo state is carried by the
     border - solid when a free photo exists, dashed when none does - and
     not by hue, so the map stops running two colors against a basemap that
     already has its own. Teal rather than green on purpose: OpenStreetMap's
     render is a third landcover green by area, and a green marker sinks
     into it. This is blue enough to stay separate. */
  --marker: #0e7490;

  --bg: #faf8f6;
  --ink: #2b2521;
  --ink-soft: #6b625c;
  --card: #ffffff;
  --border: #e8e2db;

  --font-display: 'Fraunces', Georgia, serif;
  --font-body: 'Inter', system-ui, sans-serif;
}

* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { display: flex; flex-direction: column; font-family: var(--font-body); background: var(--bg); color: var(--ink); }

/* Standard "skip link" pattern - see the HTML comment on
   #skip-to-list-link. Off-screen (not display:none/visibility:hidden,
   which would also pull it out of the tab order) until it actually
   receives keyboard focus, at which point it snaps into view as the very
   first thing on the page. */
.skip-link {
  position: absolute;
  top: 0;
  left: -9999px;
  z-index: 2000;
  padding: 14px 20px;
  background: var(--cyl-purple);
  color: white;
  font-weight: 600;
  font-size: 14px;
  text-decoration: none;
  border-radius: 0 0 8px 0;
}
.skip-link:focus { left: 0; }

#topbar {
  flex: 0 0 auto;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  background: var(--cyl-purple);
  color: white;
  box-shadow: 0 1px 8px rgba(0,0,0,0.25);
  z-index: 600;
}
#topbar .brand {
  display: flex;
  align-items: center;
  gap: 9px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 20px;
  letter-spacing: 0.2px;
  color: inherit; /* now a real <a> (see index.html) - undo the default link blue/underline, it's still just header text */
  text-decoration: none;
  opacity: 1;
  transition: opacity 0.15s ease;
}
#topbar .brand:hover { opacity: 0.85; }
#logo-mark { height: 42px; width: auto; display: block; }

/* --- Loading screen (first paint - see index.html comment) ------------ */
#loading-screen {
  position: fixed;
  inset: 0;
  z-index: 3000;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  background: var(--cyl-purple);
  color: white;
  transition: opacity 0.4s ease;
}
#loading-screen.hidden { opacity: 0; pointer-events: none; }
#loading-logo { height: 64px; width: auto; }
#loading-spinner {
  width: 30px;
  height: 30px;
  border: 3px solid rgba(255,255,255,0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: loading-spin 0.8s linear infinite;
}
@keyframes loading-spin { to { transform: rotate(360deg); } }
#loading-screen p { font-size: 14px; color: rgba(255,255,255,0.85); margin: 0; }

/* --- Welcome modal (shown once, first visit - see app.js) ------------- */
#welcome-modal {
  position: fixed;
  inset: 0;
  z-index: 2900;
  display: none;
  align-items: center;
  justify-content: center;
  background: rgba(43, 37, 33, 0.55);
  padding: 20px;
}
#welcome-modal.open { display: flex; }
#welcome-card {
  background: var(--card);
  border-radius: 16px;
  max-width: 380px;
  padding: 28px 26px;
  text-align: center;
  box-shadow: 0 12px 40px rgba(0,0,0,0.3);
}
/* The logo's linework is white/light-gold, drawn for the purple topbar -
   invisible on this card's white background without a matching backing. */
#welcome-logo-badge {
  width: 68px;
  height: 68px;
  border-radius: 50%;
  background: var(--cyl-purple);
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 14px;
}
#welcome-logo { height: 40px; width: auto; }
#welcome-card h2 { font-family: var(--font-display); font-size: 20px; font-weight: 600; margin: 0 0 10px; color: var(--cyl-purple-dark); }
#welcome-card p { font-size: 14px; line-height: 1.5; color: var(--ink-soft); margin: 0 0 20px; }
#welcome-close-btn {
  background: var(--cyl-purple);
  color: white;
  border: none;
  padding: 10px 22px;
  border-radius: 22px;
  font-size: 14px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
}
#welcome-close-btn:hover { background: var(--cyl-purple-dark); }
#welcome-about-link { display: block; margin-top: 14px; font-size: 12.5px; color: var(--ink-soft); }

#topbar button {
  background: rgba(255,255,255,0.14);
  border: none;
  color: white;
  padding: 8px 14px;
  border-radius: 18px;
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.15s ease;
}
#topbar button:hover { background: rgba(255,255,255,0.26); }

/* Always visible now, not just on mobile - Estadísticas/Acerca de/Cómo
   contribuir/language all live behind this + showMenuPanel() on every
   screen size, so the header stays down to logo + search everywhere. */
#menu-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  padding: 0;
  flex-shrink: 0;
  font-size: 17px;
}

#search-wrap { position: relative; flex: 1 1 auto; max-width: 420px; margin: 0 20px; }
#search-input {
  width: 100%;
  padding: 8px 14px;
  border-radius: 18px;
  border: none;
  background: rgba(255,255,255,0.16);
  color: white;
  font-family: var(--font-body);
  font-size: 13.5px;
  outline: none;
  transition: background 0.15s ease;
}
#search-input::placeholder { color: rgba(255,255,255,0.7); }
#search-input:focus { background: rgba(255,255,255,0.28); }

/* Both hidden on desktop by default - #search-wrap is always-expanded
   there, so neither the collapsed-icon trigger nor the collapse-back
   button is relevant. Mobile CSS below turns both on. */
#search-toggle-btn, #search-close-btn { display: none; }

#search-results {
  display: none;
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  right: 0;
  max-height: 60vh;
  overflow-y: auto;
  background: var(--card);
  border-radius: 12px;
  padding: 6px;
  box-shadow: 0 8px 28px rgba(0,0,0,0.25);
  z-index: 700;
}
#search-results.open { display: block; animation: search-results-in 0.14s ease; }
@keyframes search-results-in {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: translateY(0); }
}

.search-section-label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--ink-soft);
  padding: 10px 10px 4px;
}
/* A rounded "card" per row, not an edge-to-edge banner - #search-results'
   own 6px padding is what leaves room for this to read as a floating
   highlight rather than butting against the dropdown's own corners. */
.search-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 8px;
  border-radius: 9px;
  cursor: pointer;
  color: var(--ink);
  font-size: 13.5px;
}
.search-row:hover, .search-row.highlighted { background: var(--bg); }

/* Thumbnail and emoji-fallback tile share this wrapper so both sit in the
   exact same slot regardless of which one a given row uses - mixed
   results (some monuments with a photo, some without, plus municipalities/
   provinces that never have one) still line up into one clean grid. */
.search-row-media { position: relative; flex-shrink: 0; }
.search-row-thumb,
.search-row-icon {
  width: 40px;
  height: 40px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.search-row-thumb { object-fit: cover; }
.search-row-icon { font-size: 18px; }
/* Tinted, not solid - a full-saturation background behind an emoji reads
   as a warning badge, not a neutral "no photo yet" placeholder. Same
   green/terracotta as the map's own marker colors and .list-row-icon,
   just at low opacity as a tile background instead of a small solid dot -
   consistent color language, calmer at this larger size. */
.search-row-icon.has-photo,
.search-row-icon.no-photo { background: rgba(14,116,144,0.13); }
.search-row-icon.neutral { background: rgba(116,44,100,0.1); }
/* Small corner status dot on the media tile, monuments only (statusClass
   is null for province/municipality rows - see searchRowHtml()) - same
   colors as everywhere else this signal shows (map markers, list rows),
   just echoed at a glance for anyone who already knows that color
   language. A white ring (border, var(--card)) keeps it legible over a
   busy photo, not just a flat colored circle merging into the thumbnail. */
.search-row-dot {
  position: absolute;
  bottom: -2px;
  right: -2px;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  border: 2px solid var(--card);
}
/* 13px is too small for a dashed border to read as anything but fuzz, so
   here the same distinction is filled versus hollow. Same idea - the ring
   is the signal, the color stays constant. */
.search-row-dot.has-photo { background: var(--marker); }
.search-row-dot.no-photo { background: var(--card); box-shadow: inset 0 0 0 2px var(--marker); }

.search-row-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.search-row-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.search-row-sub { color: var(--ink-soft); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.search-empty { padding: 14px; color: var(--ink-soft); font-size: 13px; }

@media (max-width: 768px) {
  /* Single compact row by default: logo, search icon, menu icon - giving
     search a permanent full-width second row (an earlier attempt) made
     the header taller than one row, eating map space for no benefit most
     of the time search isn't even in use. Instead #search-wrap stays
     collapsed until #search-toggle-btn is tapped (app.js toggles
     .search-active on #topbar), at which point it's the only thing
     visible in the row - full width for that one moment, not always. */
  #topbar { height: 52px; padding: 0 12px; }
  #logo-mark { height: 32px; }
  #topbar .brand { font-size: 17px; }
  /* 44px, not the 36px these used to be - the usual mobile touch-target
     floor (Apple HIG/Material both land on ~44-48px), and #search-toggle-
     btn is the one tap that gets you into search at all on this
     breakpoint, worth getting right. #menu-btn matches it, not left at
     the old size, since the two sit right next to each other in the same
     row - one bigger than the other would read as a mistake, not a fix. */
  #menu-btn { width: 44px; height: 44px; font-size: 18px; }
  #search-toggle-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    padding: 0;
  }

  #search-wrap { display: none; }
  #topbar.search-active .brand,
  #topbar.search-active #menu-btn,
  #topbar.search-active #search-toggle-btn { display: none; }
  #topbar.search-active #search-wrap {
    display: block;
    flex: 1 1 auto;
    max-width: none;
    margin: 0;
  }
  #search-close-btn {
    display: block;
    position: absolute;
    right: 4px;
    top: 50%;
    transform: translateY(-50%);
    width: 28px;
    height: 28px;
    border-radius: 50%;
    border: none;
    background: rgba(255,255,255,0.25);
    color: white;
    font-size: 13px;
  }
  #search-input { padding-right: 38px; } /* room for #search-close-btn */
}

#app { display: flex; flex: 1 1 auto; min-height: 0; width: 100%; }

/* position: relative so #shuffle-btn/#locate-btn (position: absolute)
   anchor to the map's own visible area specifically - NOT #app, which
   spans the map AND the panel side-by-side, so anchoring there would
   measure from the panel's edge once it's open instead of the map's. */
#map-wrap { flex: 1 1 auto; height: 100%; min-width: 0; position: relative; }
#map { width: 100%; height: 100%; background: var(--bg); }

/* Detail panel: a real sibling of the map, not a floating overlay on top
   of it - the map shrinks to make room (desktop), or stacks below it as a
   bottom sheet (mobile). Either way it's part of the page layout. */
#panel {
  width: 0;
  height: 100%;
  overflow: hidden;
  transition: width 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
  background: var(--card);
  box-shadow: -4px 0 24px rgba(0,0,0,0.12);
  flex-shrink: 0;
  position: relative;
}
#panel.open { width: 420px; }

#panel-content {
  width: 420px;
  height: 100%;
  overflow-y: auto;
  animation: fade-in 0.35s ease;
}
@keyframes fade-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: translateY(0); }
}

#panel-handle { display: none; }

#panel-close {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 10;
  border: none;
  background: rgba(255,255,255,0.85);
  backdrop-filter: blur(6px);
  border-radius: 50%;
  width: 32px;
  height: 32px;
  font-family: inherit;
  font-size: 15px;
  cursor: pointer;
  box-shadow: 0 1px 6px rgba(0,0,0,0.25);
}
#panel-close:hover { background: white; }

/* "Written pages" - About/Contribute/Stats/Privacy/Imprint - replace the
   map entirely instead of sharing the screen with it, on both desktop and
   mobile. Same #panel element as the regular map-side panel/bottom sheet,
   just stretched to fill #app completely; openPanel()/closePanel() in
   app.js add/remove .page-mode on #app based on whether
   currentPanelState.type is one of PAGE_PANEL_TYPES. Specificity (an #app
   ancestor plus its own class, on top of #panel/#panel-content's own ID)
   is what lets this win over the plain #panel.open{width:420px} above and
   every wider-breakpoint variant of it further down, without needing
   !important anywhere. */
#app.page-mode #map-wrap { display: none; }
#app.page-mode #panel.open { width: 100%; border-radius: 0; box-shadow: none; }
#app.page-mode #panel-content { width: 100%; }
#app.page-mode #panel-handle { display: none; }
/* Full viewport width still reads badly as a single text column - cap it
   like a normal article/docs page and center it, independent of #panel's
   own (now 100%) width. .hero gets the same cap so its icon/title sits
   above the text column it belongs to instead of spanning the full page
   width above a much narrower column - it's a sibling of .panel-body, not
   a descendant, so needs its own copy of the rule. */
#app.page-mode .panel-body,
#app.page-mode .hero { max-width: 720px; margin: 0 auto; }

@media (max-width: 768px) {
  /* Mobile's #panel.open is normally a 68vh bottom sheet (see below) -
     page-mode wants the full height instead, same as desktop already gets
     for free (#panel is 100% tall at rest; only its *width* toggles there). */
  #app.page-mode #panel.open { height: 100%; }
}

@media (max-width: 768px) {
  #app { flex-direction: column; }
  #map-wrap { flex: 1 1 auto; min-height: 0; width: 100%; }
  #panel {
    width: 100% !important;
    height: 0;
    transition: height 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
    border-radius: 18px 18px 0 0;
  }
  #panel.open { height: 68vh; }
  #panel-content { width: 100%; }
  #panel-handle {
    display: block;
    width: 40px;
    height: 4px;
    border-radius: 2px;
    background: var(--border);
    margin: 10px auto 0;
  }
}

/* Marker icons: emoji-in-a-circle, colored by linkage status */
.monument-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 2px solid white;
  box-shadow: 0 2px 6px rgba(0,0,0,0.35);
  font-size: 16px;
  transition: transform 0.12s ease;
}
.monument-icon:hover { transform: scale(1.15); }
/* Color alone isn't enough - ~8% of men are red-green colorblind (moot
   here since we dropped red/green, but the principle still applies to any
   two hues). A solid vs. dashed ring gives a second, non-color channel. */
.monument-icon.has-photo { background: var(--marker); border-style: solid; }
.monument-icon.no-photo { background: var(--marker); border-style: dashed; }

/* Whichever monument a click/search/"Cerca de aquí" tap just opened (see
   highlightMonument() in app.js) - after .has-photo/.no-photo so it wins on
   specificity regardless of which of those is also present. Purple, not
   blue/teal/terracotta - those already mean you-are-here/linked/missing,
   this is a distinct "this one, right now" signal, reusing the brand
   color already meaning "selected/active" elsewhere (.page-nav-link.active,
   #filter-btn.active, ...). A slow, gentle size+opacity pulse (own
   keyframe, not a reuse of #locate-btn.locating's plain opacity-only
   `pulse` - growing/shrinking a small button by the same amount its icon
   grows/shrinks here would look off), not a hard on/off blink -
   continuous, but subtle (max +15% size) enough to sit on screen for as
   long as the panel is open without turning into a WCAG flashing concern
   or becoming annoying background noise. */
.monument-icon.selected {
  background: var(--cyl-purple);
  border-color: white;
  border-style: solid;
  border-width: 3px;
  /* Two shadows: the same drop shadow every marker gets, a bit stronger,
     plus a soft purple halo past the marker's own edge - visible at rest,
     not just mid-pulse, so "this one" still reads at the small end of the
     animation cycle rather than only at its peak. */
  box-shadow:
    0 3px 12px rgba(0,0,0,0.5),
    0 0 0 5px rgba(116,44,100,0.28);
  animation: monument-pulse 1.4s ease-in-out infinite;
}
/* Never scale(1): the selected marker should read as bigger than its
   neighbours at every point in the cycle, not just at the peak. Kept as a
   transform rather than a bigger iconSize so the marker's anchor - and so
   its position on the map - can't move when it's selected (see
   MONUMENT_ICON_SIZE in app.js). Peak raised from 1.28 to 1.4 alongside
   the halo above - both exist for the same reason, making the one
   selected marker easier to spot at a glance on a map that can have
   dozens of others in view at once. */
@keyframes monument-pulse {
  0%, 100% { transform: scale(1.1); opacity: 1; }
  50% { transform: scale(1.4); opacity: 0.75; }
}

/* Cluster bubbles: color communicates the linked/missing ratio of the
   monuments inside, computed per-cluster in app.js - so the map still
   tells the "coverage gap" story even zoomed out. */
.cluster-icon div {
  /* Fill lives here now, not in clusterIcon()'s inline style: with one
     color for every cluster there is nothing left to compute per cluster
     except the border, which is the part that carries meaning. */
  background: var(--marker);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  border: 3px solid white;
  box-shadow: 0 2px 10px rgba(0,0,0,0.35);
  color: white;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 13px;
}

/* --- Hero: the photo (or gradient fallback) at the top of every panel --- */

.hero {
  position: relative;
  height: 260px;
  overflow: hidden;
  background: linear-gradient(135deg, var(--cyl-purple) 0%, var(--cyl-purple-dark) 100%);
}
.hero-placeholder-icon {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 110px;
  line-height: 1;
  opacity: 0.28;
  /* translateY(-15%) - not padding-bottom, which this used to be - nudges
     the icon above dead-center, out of the scrim/title's way at the
     bottom. padding-bottom looked identical everywhere this shipped
     narrow (sidebar panel, or mobile's full-width-but-short bottom sheet)
     but is a CSS trap: percentage padding resolves against the
     *containing block's width*, even on the vertical axis - once a
     written page (About/Contribute/...) went full-width in page mode, 15%
     of a 1400px+ hero was a couple hundred px, shoving the icon mostly
     above the (overflow:hidden) top edge. transform percentages resolve
     against the element's own box instead, so this reads the same at any
     width - combined with the existing rightward nudge (most emoji glyphs
     render with extra right-side bearing inside their own character cell,
     so true flex-centering still looks slightly left-of-center), in one
     transform since an element only gets the one. */
  transform: translate(0.06em, -15%);
  pointer-events: none;
}
.hero-fallback::before {
  /* subtle texture so a flat gradient doesn't feel like an empty state */
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at 25% 20%, rgba(255,255,255,0.12), transparent 55%);
}
/* Shorter than a real photo hero: pages that only ever show this icon
   placeholder (About/Contribute/Stats/menu, or a monument with no photo
   anywhere) don't need as much vertical space as an actual Commons photo,
   which stays at its own full size (.hero's own height/aspect-ratio rules,
   untouched) - this only overrides the fallback case, same specificity as
   .hero so it needs to come after it in source order to win. */
.hero-fallback { height: 180px; }
.hero-fallback .hero-placeholder-icon { font-size: 78px; }

/* The hero while the photo is still being fetched (see heroBlock()'s
   `loading`). Without this the wait renders identically to "this monument
   has no photo at all" - the panel looks settled, then a photo appears in
   it a second later. Two cues: the category placeholder drops to a faint
   watermark rather than sitting there at its normal strength as if it were
   the final answer, and a spinner says the panel is still working. */
.hero-loading .hero-placeholder-icon { opacity: 0.14; }
.hero-spinner {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 28px;
  height: 28px;
  margin: -14px 0 0 -14px;
  border: 3px solid rgba(255,255,255,0.35);
  border-top-color: white;
  border-radius: 50%;
  /* Same keyframe the first-paint loading screen spins on - one spinner
     idiom across the app rather than a second, subtly different one. */
  animation: loading-spin 0.8s linear infinite;
  pointer-events: none;
}
.hero a { display: block; height: 100%; position: relative; }
/* goToIndex() now hides #main-image-link (the <a> above) while a video is
   showing instead - same reasoning as .hero-video[hidden] just below: the
   display:block right above would otherwise out-cascade the hidden
   attribute's own (lower-priority, browser-default) display:none. */
.hero a[hidden] { display: none; }
.hero-img {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.4s ease;
}
.hero a:hover .hero-img { transform: scale(1.04); }

/* Portrait photos (towers, standalone statues, ...) lose most of their
   subject when force-cropped into this wide box - cover just cuts off the
   top and bottom. For those, object-fit switches to contain (the JS in
   applyHeroOrientation() decides which - see there) so the whole photo is
   visible, and a blurred, brightness-dimmed copy of the same photo fills
   the empty sides behind it rather than leaving bare letterbox bars. Not
   used for landscape photos, which already fill the box well as-is. */
.hero-img-bg {
  display: none;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: blur(28px) brightness(0.55) saturate(1.15);
  transform: scale(1.15); /* the blur softens past its own edges - scale up so that never shows as a thin unblurred rim */
}
.hero-portrait .hero-img { object-fit: contain; background: transparent; }
.hero-portrait .hero-img-bg { display: block; }

/* A video in the gallery (see VIDEO_EXTENSIONS/isVideoFile() in app.js) -
   positioned exactly like .hero-img-bg (absolute, filling .hero) since
   it's a sibling of #main-image-link now, not nested inside it (see the
   comment on #main-video in heroBlock()). display: block is needed since
   goToIndex() toggles this element's hidden attribute to show/hide it, and
   the [hidden] rule right below has to come *after* this one - author
   rules of equal specificity are decided by source order, and the
   hidden="" attribute's own display:none only comes from the browser's
   default (lowest-priority) stylesheet, so any later same-specificity
   author rule would otherwise silently win and defeat it. */
.hero-video {
  display: block;
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: black;
}
.hero-video[hidden] { display: none; }
.hero-portrait .hero-video { object-fit: contain; }

/* The neighbor photo peeking in from off-screen while wireHeroSwipe() in
   app.js is live-tracking a horizontal drag - mirrors #main-image-link's
   own bg+fg pair (.hero-img-bg/.hero-img) so a portrait photo is already
   letterboxed with its blurred backdrop *during* the drag, not just once
   goToIndex() commits the swap and applyHeroOrientation() takes over for
   real. .hero-drag-preview is the wrapper the JS transforms as one unit -
   sized/positioned exactly like #main-image-link so it tiles edge-to-edge
   with it as both get dragged; z-index 1 on the wrapper keeps the whole
   thing under .hero-scrim (2) as it slides past the title/gradient. */
.hero-drag-preview {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}
.hero-drag-preview-bg,
.hero-drag-preview-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.hero-drag-preview-bg {
  display: none; /* shown only once wireHeroSwipe()'s decideOrientation() finds this preview is a portrait photo */
  filter: blur(28px) brightness(0.55) saturate(1.15);
  transform: scale(1.15); /* same over-scale as .hero-img-bg - the blur softens past its own edges otherwise */
}
.hero-drag-preview-img.portrait { object-fit: contain; background: transparent; }

.hero-scrim {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 2; /* above .hero-img (1) and .hero-img-bg - without this it fell
                 behind the photo once .hero-img became a positioned/
                 z-indexed element for the portrait-photo backdrop trick */
  padding: 40px 22px 18px;
  background: linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.35) 55%, transparent 100%);
  color: white;
}
.hero-kicker {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  opacity: 0.9;
  margin-bottom: 4px;
}
.hero-title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 24px;
  line-height: 1.15;
  margin: 0;
  text-shadow: 0 1px 8px rgba(0,0,0,0.3);
}

/* Swipe indicator - mobile-only (see the @media 768px block below), and
   only ever populated (see updateHeroDots() in app.js) when there's
   actually more than one photo to swipe between. Sits near the top, not
   the bottom, since .hero-scrim/.hero-title already own the bottom edge. */
.hero-dots {
  position: absolute;
  top: 12px;
  left: 0;
  right: 0;
  z-index: 3;
  display: none;
  justify-content: center;
  gap: 6px;
  pointer-events: none;
}
.hero-dots:empty { display: none !important; }
.hero-dot { width: 6px; height: 6px; border-radius: 50%; background: rgba(255,255,255,0.5); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
.hero-dot.active { background: white; }

@media (max-width: 768px) {
  /* pan-y, not none: a vertical drag through the hero should still scroll
     the page normally (mobile's bottom-sheet panel can be short enough that
     the hero itself sits near a scroll boundary) - only horizontal gestures
     are ours to interpret as a swipe, per wireHeroSwipe() in app.js. Kept
     here, next to .hero-dots' own base rule, rather than in the earlier
     global mobile block - .hero-dots{display:flex} needs to out-cascade
     the unconditional .hero-dots{display:none} above it, which only works
     if this comes after it in source order (equal specificity, so it's
     purely a source-order tiebreak either way). */
  .hero { touch-action: pan-y; }
  .hero-dots { display: flex; }
}

.panel-body { padding: 20px 22px 28px; }
.panel-body .meta { color: var(--ink-soft); font-size: 13px; margin-bottom: 4px; }
.panel-body h3 {
  font-family: var(--font-display);
  font-weight: 600;
  margin: 24px 0 8px;
  font-size: 16px;
  color: var(--cyl-purple-dark);
}

.source-list { margin: 0; padding-left: 20px; font-size: 13px; line-height: 1.8; color: var(--ink); }
.source-list a { color: var(--cyl-purple); }

/* Cross-links between the "written pages" (About/Contribute/Stats/Privacy/
   Imprint - see pageNavHtml() in app.js) - first thing in .panel-body so
   it reads as this page's own sub-nav, not part of the page content
   proper. Buttons, not <a href>, matching how the rest of the app's
   navigation (list rows, menu) is JS-driven rather than real links. */
.page-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 18px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--border);
}
.page-nav-link {
  font-family: inherit;
  font-size: 13px;
  font-weight: 500;
  padding: 6px 12px;
  border-radius: 14px;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--ink-soft);
  cursor: pointer;
}
.page-nav-link:hover { border-color: var(--cyl-purple); color: var(--ink); }
.page-nav-link.active { background: var(--cyl-purple); border-color: var(--cyl-purple); color: white; cursor: default; }

.stats-mission {
  border-left: 3px solid var(--cyl-purple);
  padding-left: 12px;
  margin: 6px 0 18px;
  font-style: italic;
  color: var(--ink);
}

/* 2x2 rather than one row of four: this sits in a side panel, and four
   figures across it left each label a two-word column too narrow to read.
   The grid drops to a single column only on the narrowest phones. */
.stats-numbers { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 4px 0 6px; }
@media (max-width: 340px) { .stats-numbers { grid-template-columns: 1fr; } }
.stat-box {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 12px 10px;
  text-align: center;
}
.stat-value { font-family: var(--font-display); font-size: 22px; font-weight: 600; color: var(--cyl-purple-dark); }
.stat-label { font-size: 11.5px; color: var(--ink-soft); margin-top: 2px; line-height: 1.3; }

/* The Wikidata link count, kept visible but out of the way. It is the
   number this project is actually built on, and the history chart still
   tracks it - but it measures the plumbing, whereas the four figures above
   measure what a visitor gets. */
.stats-record { font-size: 12px; color: var(--ink-soft); line-height: 1.45; margin: 2px 0 10px; }

/* renderPhotoNote() in app.js. The photo figure is the one box not
   refreshed by every `make build` - only by a manual `make photo-stats` -
   and it is a floor, not a count. The figure says "more than" and this
   note carries the as-of date, so it never reads as precise or as live as
   its neighbours. */
.stats-photos-note { font-size: 11.5px; color: var(--ink-soft); margin: -2px 0 8px; line-height: 1.4; }

.stats-chart-wrap { position: relative; }
.stats-chart { width: 100%; height: auto; margin-top: 6px; display: block; cursor: crosshair; }
.stats-chart-axis { display: flex; justify-content: space-between; font-size: 11px; color: var(--ink-soft); margin-top: 2px; }
.stats-chart-legend { display: flex; gap: 16px; font-size: 12.5px; color: var(--ink-soft); margin-top: 8px; }
.legend-swatch { display: inline-block; width: 12px; height: 3px; margin-right: 5px; vertical-align: middle; }
.legend-swatch.article { background: var(--marker); }
.legend-swatch.image { background: var(--cyl-purple); }
.stats-chart-empty { font-size: 13px; color: var(--ink-soft); font-style: italic; margin-top: 4px; }

/* Hover overlay: a per-point invisible hit-area, a vertical guide line and
   two dots (one per series) that only appear while hovering, plus a
   floating tooltip anchored above the guide line - all pure SVG/CSS, no
   charting library. */
.stats-chart-hit { fill: transparent; }
.stats-chart-guide { stroke: var(--ink-soft); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0; }
/* r is intentionally not set here - it's a CSS-stylable SVG2 geometry
   property, so a value here would override the JS setAttribute('r', ...)
   calls that actually show/hide the dot (CSS always wins over presentation
   attributes). Starts at the SVG default (0, i.e. invisible). */
.stats-chart-dot { stroke-width: 2; fill: var(--bg, #fff); transition: r 0.1s ease; }
.stats-chart-dot.article { stroke: var(--marker); }
.stats-chart-dot.image { stroke: var(--cyl-purple); }
.stats-chart-tooltip {
  /* Fixed, not absolute: #panel/#panel-content clip overflow (rounded
     corners, scroll), which silently cropped the tooltip whenever it had
     to render above the chart or near the panel's edge. Fixed positioning
     escapes that (nothing between here and the viewport sets a transform/
     filter, which would otherwise re-establish a containing block) - JS
     computes left/top in viewport coordinates and clamps them to stay
     fully on-screen. */
  position: fixed;
  left: 0;
  top: 0;
  transform: translateX(-50%);
  background: var(--cyl-purple-dark);
  color: #fff;
  padding: 7px 10px;
  border-radius: 8px;
  font-size: 12px;
  line-height: 1.5;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.1s ease;
  z-index: 2000;
}
.stats-chart-tooltip.above { transform: translate(-50%, calc(-100% - 10px)); }
.stats-chart-tooltip.below { transform: translate(-50%, 10px); }
.stats-chart-tooltip.visible { opacity: 1; }
.stats-chart-tooltip .tt-date { font-weight: 600; margin-bottom: 2px; }
.stats-chart-tooltip .tt-row { display: flex; align-items: center; gap: 5px; }
.stats-chart-tooltip .tt-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.stats-chart-tooltip .tt-dot.article { background: var(--marker); }
.tt-row-nodata { color: var(--ink-soft); font-style: italic; }
.stats-chart-tooltip .tt-dot.image { background: var(--cyl-purple); }
.stats-chart-tooltip .tt-delta { color: #c9e8dc; }
.stats-chart-tooltip .tt-delta.down { color: #f0c8c8; }

.list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); }
/* .list-row is a real <a href="?id=..."> now (see monumentListItemHtml() /
   showListPanel() in app.js), not a plain click-only <li> - reset the
   default link look since visually it should still read as one plain row,
   same as before this was a real link. */
.list-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 2px;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  font-size: 13.5px;
  color: inherit;
  text-decoration: none;
  transition: background 0.12s ease;
}
.list-row:hover { background: var(--bg); }
/* Keyboard focus needs its own visible ring - browsers don't draw one on
   an <a> the way they do on a <button>/<input> by default. -2px offset
   (inset, not outset) so it doesn't get clipped by .panel-body's own
   overflow or bleed into the next row. */
.list-row:focus-visible { outline: 2px solid var(--cyl-purple); outline-offset: -2px; border-radius: 4px; }
.list-row-icon {
  width: 22px; height: 22px;
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 11px;
  flex-shrink: 0;
}
.list-row-icon.has-photo { background: var(--marker); }
.list-row-icon.no-photo { background: transparent; box-shadow: inset 0 0 0 2px var(--marker); }
.list-row-icon-plain { width: 22px; text-align: center; font-size: 16px; flex-shrink: 0; }
.list-row-thumb {
  width: 26px; height: 26px;
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
}
.list-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-row-stat { color: var(--ink-soft); font-size: 12.5px; flex-shrink: 0; }

/* First thing in .panel-body now (right under the hero photo) - tighter
   top margin than a mid-content element would want, so it reads as
   attached to the photo above rather than floating in the body padding. */
.image-caption { font-size: 11.5px; color: var(--ink-soft); margin: -6px 0 14px; line-height: 1.4; }
.image-caption:empty { display: none; }
.image-caption a { color: var(--ink-soft); }

.gallery { display: flex; gap: 8px; overflow-x: auto; margin: 12px 0 4px; padding-bottom: 4px; }
.gallery img {
  height: 68px;
  width: 68px;
  object-fit: cover;
  border-radius: 8px;
  cursor: pointer;
  border: 2px solid transparent;
  flex-shrink: 0;
  transition: transform 0.15s ease, border-color 0.15s ease;
}
.gallery img:hover { border-color: var(--cyl-purple); transform: translateY(-2px); }
/* Whichever photo is currently in the hero slot - stays in the gallery
   rather than being excluded from it, marked instead of removed, so "which
   one am I looking at" is always visible at a glance. */
.gallery img.active { border-color: var(--cyl-purple); cursor: default; }

/* Wraps each thumbnail (see renderGallery() in app.js) purely as an anchor
   for .video-thumb's play-icon overlay below - sizing/hover/border all
   stay on the <img> itself, unchanged; this shrinks to fit it exactly. */
.gallery-thumb { position: relative; display: inline-flex; flex-shrink: 0; }
.video-thumb::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 0;
  height: 0;
  /* a solid CSS triangle, not an emoji/icon font glyph - crisp at any
     size and never waits on a font to load */
  border-style: solid;
  border-width: 7px 0 7px 11px;
  border-color: transparent transparent transparent white;
  filter: drop-shadow(0 1px 2px rgba(0,0,0,0.6));
  pointer-events: none; /* clicks fall through to the <img> underneath, same goToIndex() as any other thumbnail */
}

/* Appended after the thumbs, only when fetchGalleryFiles() found more than
   the 12 shown - links out to the full Commons category instead of trying
   to fetch/paginate everything ourselves. Sized to match .gallery img so it
   sits in the scroll strip as just one more tile, not a different kind of
   thing. */
.gallery-more {
  height: 68px;
  min-width: 68px;
  max-width: 90px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 4px 8px;
  border-radius: 8px;
  border: 2px solid var(--border);
  background: var(--bg);
  color: var(--ink-soft);
  font-size: 11px;
  font-weight: 600;
  line-height: 1.25;
  text-decoration: none;
  flex-shrink: 0;
  transition: border-color 0.15s ease, color 0.15s ease;
}
.gallery-more:hover { border-color: var(--cyl-purple); color: var(--cyl-purple); }

.extract { font-size: 14.5px; line-height: 1.6; margin: 14px 0; color: var(--ink); }
/* Contact email shipped as a PNG (imprint.body/privacy.controller.body), not
   plain text or a mailto: link, so simple scrapers don't pick it up. */
.contact-email-img { vertical-align: middle; }
.text-license { font-size: 11px; color: var(--ink-soft); margin: -8px 0 14px; line-height: 1.4; }
.text-license a { color: var(--ink-soft); }

/* Was a plain underlined text link, easy to miss right under a wall of
   extract text - promoted to the same pill-button treatment as every
   other actionable link on this panel (.badge), so "there's a whole
   article, this is just the lead" actually reads as an action, not a
   footnote. Still the neutral/outlined .badge, not the filled purple
   .contribute-cta-btn variant - this isn't asking the visitor to do
   anything for the project, just offering to keep reading. */
/* Right-aligned: it sits directly under the extract and reads as "…and it
   continues over there", which is where the eye already is at the end of
   a paragraph. display:flex + fit-content rather than the inherited
   inline-flex, because margin-left:auto only pushes a block-level box. */
/* .badge.read-more-btn, not .read-more-btn: .badge sits *below* this in
   the file and sets display:inline-flex at the same specificity, so a
   single-class selector here silently loses the cascade and margin-left:
   auto has no block-level box to push (measured: 22px from the left edge,
   i.e. no effect at all). Two classes win regardless of source order,
   which also means neither rule has to stay put to keep this working. */
.badge.read-more-btn {
  display: flex;
  width: fit-content;
  margin: -2px 0 14px auto;
}

/* The two rows sat as thin outlined pills directly on the panel's white,
   between a 11px grey license line and the next heading - nothing for
   them to belong to, so they read as leftovers rather than as the
   monument's link section. Every other block here is anchored by a
   heading or a filled callout; this gives them the same footing: one
   tinted surface both rows sit on.
   Deliberately NOT solved by giving each source its own color - that was
   tried before and reverted for good reason (see the shared-neutral rule
   further down). The chips stay neutral; what changed is that they now
   sit on something instead of floating. Against --bg they read as solid
   white chips rather than outlines on outlines. */
.monument-links {
  margin-top: 18px;
  padding: 12px 14px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 12px;
}
/* Slightly tighter than a free-standing .badge: at the panel's real width
   the default 14px padding pushed "Wikipedia" onto its own line, which
   turned the deliberate two rows back into three and undid the grouping.
   Scoped to this block so badges elsewhere keep their normal size. */
.monument-links .badge { padding: 6px 11px; font-size: 12.5px; }
.link-badges { display: flex; flex-wrap: wrap; gap: 6px; }
/* A hairline, not just a bigger gap, between the sources row and the
   actions row. A gap alone works only as long as neither row wraps: on a
   phone the three source chips break onto two lines, and then "one group
   of two rows" and "two groups" are separated by 2px of margin
   difference, which is no separation at all. The rule holds at every
   width. */
.link-badges + .link-badges {
  margin-top: 9px;
  padding-top: 9px;
  border-top: 1px solid var(--border);
}
.badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 7px 14px;
  border-radius: 18px;
  font-size: 13px;
  font-weight: 500;
  text-decoration: none;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--ink);
  transition: border-color 0.15s ease, transform 0.1s ease;
}
.badge svg { flex-shrink: 0; }
.wiki-w-icon {
  font-family: var(--font-display);
  font-weight: 700;
  font-style: normal;
  font-size: 14px;
  line-height: 1;
  flex-shrink: 0;
}
.badge:hover { transform: translateY(-1px); }
/* jcyl/wikidata/wikipedia/share-btn/filter-link deliberately share one
   neutral color - previously each had its own (blue for Wikidata,
   near-black for Wikipedia, purple for share), which read as arbitrary
   rather than meaningful next to each other. The brand marks (Wikidata's
   tricolor SVG, Wikipedia's serif "W") keep their own identity regardless -
   only the button text/border is unified. */
.badge.wikidata,
.badge.wikipedia,
.badge.jcyl,
.badge.share-btn,
.badge.filter-link {
  border-color: var(--border);
  color: var(--ink-soft);
}
/* font-family: inherit only, NOT the "font" shorthand - "font: inherit"
   resets size/weight/line-height too, silently overriding the 13px/500
   .badge sets above and making this one button look different from its
   sibling badges (buttons don't inherit font-family by default, unlike
   every other element, so *something* here is still required). */
.badge.share-btn { font-family: inherit; cursor: pointer; }

/* First-timer primer shown before handing someone off to Wikipedia or
   Commons (see showContributionIntro()). Same dimmed-backdrop treatment
   as #welcome-modal, but built in JS on demand rather than living in
   index.html: it's shown at most twice per browser, so there's no reason
   for it to sit in the DOM of every page load. */
.intro-modal {
  position: fixed;
  inset: 0;
  z-index: 2950;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(43, 37, 33, 0.55);
  padding: 20px;
}
.intro-card {
  background: var(--card);
  border-radius: 16px;
  max-width: 430px;
  width: 100%;
  padding: 26px 26px 20px;
  box-shadow: 0 12px 40px rgba(0,0,0,0.3);
  max-height: 88vh;
  overflow-y: auto; /* the Spanish copy plus three links is tall on a short phone */
}
.intro-card h2 {
  font-family: var(--font-display);
  font-size: 19px;
  font-weight: 600;
  color: var(--cyl-purple-dark);
  margin: 0 0 10px;
  text-wrap: balance;
}
.intro-body { font-size: 14px; line-height: 1.6; margin: 0 0 14px; color: var(--ink); }
.intro-links { list-style: none; margin: 0 0 18px; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.intro-links a {
  display: block;
  padding: 8px 10px;
  border-radius: 8px;
  font-size: 13.5px;
  color: var(--cyl-purple);
  text-decoration: none;
  background: var(--bg);
}
.intro-links a:hover { background: #f3edf1; text-decoration: underline; }
/* Full width and centred: this is the one thing the card is asking for,
   and it shouldn't have to be hunted for among the reading links. */
.intro-continue { display: flex; justify-content: center; width: 100%; box-sizing: border-box; }
.intro-cancel {
  display: block;
  margin: 10px auto 0;
  padding: 4px;
  border: none;
  background: none;
  font: inherit;
  font-size: 12.5px;
  color: var(--ink-soft);
  text-decoration: underline;
  cursor: pointer;
}

/* Generic "this monument is missing X, here's how to add it" callout -
   originally just the no-photo case (still .contribute-cta in the DOM,
   renamed from upload-cta since it's now shared), reused as-is for the
   no-Wikipedia-article case below. Purely a visual pattern, nothing
   upload-specific in it. */
.contribute-cta {
  margin-top: 12px;
  padding: 14px;
  background: #f5f0f7;
  border: 1px solid #e5d5e2;
  border-radius: 10px;
}
.contribute-cta-text { font-size: 13.5px; color: var(--ink); margin-bottom: 10px; line-height: 1.5; }
.contribute-cta-btn.badge { background: var(--cyl-purple); color: white; border-color: var(--cyl-purple); }
.contribute-cta-btn.badge:hover { background: var(--cyl-purple-dark); }

.missing-note {
  font-size: 13.5px;
  color: #8a4a1f;
  background: #fbf0e6;
  border: 1px solid #f0d9c2;
  border-radius: 10px;
  padding: 11px 14px;
  margin-top: 10px;
}

.loading { color: var(--ink-soft); font-size: 13px; margin-top: 12px; }

/* Skeleton placeholder for a monument panel that's still waiting on
   Wikidata/Commons - see loadingSkeletonHtml(). Shapes deliberately match
   the real thing they stand in for (.gallery img is 68px square, .extract
   lines are 14.5px/1.6, .badge pills are ~32px tall with an 18px radius),
   so the swap to real content doesn't visibly re-flow the panel. */
.skeleton {
  /* The sweep is a wide gradient moved across an oversized background box,
     not an extra overlay element - one painted layer per shape. */
  background: linear-gradient(90deg, var(--border) 25%, #f4efe9 37%, var(--border) 63%);
  background-size: 300% 100%;
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
  from { background-position: 100% 50%; }
  to { background-position: 0 50%; }
}
.skeleton-block { margin-top: 14px; }
.skeleton-gallery { display: flex; gap: 8px; margin: 12px 0 4px; }
.skeleton-thumb { width: 68px; height: 68px; border-radius: 8px; flex-shrink: 0; }
.skeleton-lines { margin: 18px 0; display: flex; flex-direction: column; gap: 9px; }
.skeleton-line { height: 11px; border-radius: 4px; }
.skeleton-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
.skeleton-badge { height: 32px; border-radius: 18px; }

#locate-btn {
  position: absolute;
  bottom: 24px;
  right: 16px;
  z-index: 500;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: none;
  background: rgba(255,255,255,0.92);
  backdrop-filter: blur(8px);
  box-shadow: 0 4px 16px rgba(0,0,0,0.2);
  font-size: 18px;
  cursor: pointer;
  transition: transform 0.12s ease;
  /* Centers the blue-dot SVG the way emoji text never needed - see
     #search-toggle-btn for the same pattern. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
#shuffle-btn {
  position: absolute;
  bottom: 78px;
  right: 16px;
  z-index: 500;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: none;
  background: rgba(255,255,255,0.92);
  backdrop-filter: blur(8px);
  box-shadow: 0 4px 16px rgba(0,0,0,0.2);
  font-size: 18px;
  cursor: pointer;
  transition: transform 0.12s ease;
}
#shuffle-btn:hover { transform: scale(1.08) rotate(-8deg); background: white; }

/* Stacked above shuffle/locate, same 54px rhythm (78 - 24) those two
   already use. */
#filter-btn {
  position: absolute;
  bottom: 132px;
  right: 16px;
  z-index: 500;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: none;
  background: rgba(255,255,255,0.92);
  backdrop-filter: blur(8px);
  box-shadow: 0 4px 16px rgba(0,0,0,0.2);
  font-size: 18px;
  cursor: pointer;
  transition: transform 0.12s ease;
  /* Centers the funnel SVG the way emoji text never needed - see
     #search-toggle-btn for the same pattern. #filter-badge stays
     position: absolute, unaffected by the flex centering. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
#filter-btn:hover { transform: scale(1.08); background: white; }
#filter-btn.active { background: var(--cyl-purple); color: white; }

/* One-shot "the filter just changed" cue, added by
   showMapFilteredByStatus() when the filter comes from somewhere other
   than this button's own panel (the picture-of-the-week strip, the
   Contribute page) - otherwise nothing at this end of the screen moves and
   the new #filter-btn.active/badge state is easy to miss entirely. The
   expanding ring is a ::before so it can't disturb #filter-badge (::after
   is free either way, but the badge is a real child element - a ring drawn
   on the button itself would have to sit under it). */
#filter-btn.just-filtered::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--cyl-purple);
  /* `forwards` matters here, it isn't boilerplate: the class stays on the
     button after the animation ends (it's only ever cleared to re-trigger
     the next one), and without a fill mode the ::before would snap back to
     the rule above - a fully opaque purple ring drawn around the button,
     permanently. Holding the last keyframe leaves it at opacity 0 instead. */
  animation: filter-ping 0.75s ease-out 2 forwards;
  pointer-events: none;
}
#filter-btn.just-filtered { animation: filter-bump 0.75s ease-out 2; }
@keyframes filter-ping {
  0% { transform: scale(1); opacity: 0.85; }
  100% { transform: scale(1.9); opacity: 0; }
}
@keyframes filter-bump {
  0%, 100% { transform: scale(1); }
  35% { transform: scale(1.18); }
}

/* Markers fade out while the filter swaps underneath them, then back in
   on the new set - see showMapFilteredByStatus(). Covers the swap itself
   AND closePanel()'s map resize, so neither shows as a jump. One selector
   covers cluster bubbles too: Leaflet.markercluster's clusterPane defaults
   to the plain marker pane, so both individual markers and clusters are
   children of .leaflet-marker-pane (verified in the running page - there
   is no separate cluster pane to fade). */
#map-wrap .leaflet-marker-pane {
  transition: opacity 0.22s ease;
}
#map-wrap.filter-changing .leaflet-marker-pane {
  opacity: 0;
}

/* Desktop-only explanatory label for the floating map buttons -
   shuffle/locate/filter sit at the map's edge as bare icons, and the
   browser's own native title tooltip is tiny, slow to appear, and easy to
   miss. Reads data-tooltip (see the data-i18n-tooltip handling in
   applyStaticI18n(), i18n.js - kept in sync with the current language)
   via a pure-CSS ::after, positioned to the button's left since these
   buttons all sit at the map's right edge. No position:relative added
   here - #shuffle-btn/#locate-btn/#filter-btn are already
   position:absolute, which is its own valid containing block for an
   absolutely-positioned ::after; adding relative here would override
   that and break their actual on-page position. Desktop only
   (min-width: 769px, same breakpoint used elsewhere) - mobile has no
   meaningful hover and no room to spare for a label popping out
   sideways. */
@media (min-width: 769px) {
  [data-i18n-tooltip]::after {
    content: attr(data-tooltip);
    position: absolute;
    right: calc(100% + 10px);
    top: 50%;
    transform: translateY(-50%) translateX(6px);
    background: var(--cyl-purple-dark);
    color: white;
    padding: 6px 10px;
    border-radius: 6px;
    font-size: 12px;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.12s ease, transform 0.12s ease;
    z-index: 600;
  }
  [data-i18n-tooltip]:hover::after,
  [data-i18n-tooltip]:focus-visible::after {
    opacity: 1;
    transform: translateY(-50%) translateX(0);
  }
}

.filter-badge {
  position: absolute;
  top: -2px;
  right: -2px;
  min-width: 16px;
  height: 16px;
  padding: 0 3px;
  border-radius: 8px;
  background: var(--cyl-purple);
  color: white;
  font-size: 10px;
  font-weight: 600;
  line-height: 16px;
  text-align: center;
}

#filter-panel {
  display: none;
  position: absolute;
  bottom: 132px;
  right: 68px; /* clears #filter-btn to its left, rather than overlapping it */
  width: 260px;
  max-height: 60vh;
  overflow-y: auto;
  background: var(--card);
  border-radius: 12px;
  box-shadow: 0 8px 28px rgba(0,0,0,0.25);
  z-index: 700;
}
#filter-panel.open { display: block; }

#filter-panel-header,
.filter-section-label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 12px 14px 8px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--ink-soft);
}
/* Estado sits above Categorías in the same panel - a divider between them
   reads as two distinct groups instead of one long list with an odd gap. */
#filter-panel-header { border-top: 1px solid var(--border); margin-top: 4px; }
#filter-panel-actions { display: flex; gap: 10px; flex-shrink: 0; }
#filter-panel-actions button {
  border: none;
  background: none;
  padding: 0;
  font: inherit;
  font-weight: 600;
  text-transform: none;
  letter-spacing: normal;
  color: var(--cyl-purple);
  cursor: pointer;
}
#filter-panel-actions button:hover { color: var(--cyl-purple-dark); }

.filter-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 14px;
  cursor: pointer;
  color: var(--ink);
  font-size: 13.5px;
}
.filter-row:hover { background: var(--bg); }
.filter-row input[type="checkbox"] { flex-shrink: 0; accent-color: var(--cyl-purple); }
.filter-row-icon { font-size: 15px; flex-shrink: 0; }
.filter-row-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filter-row-count { color: var(--ink-soft); font-size: 12px; flex-shrink: 0; }
.filter-row.unchecked .filter-row-name,
.filter-row.unchecked .filter-row-count { opacity: 0.5; }

/* Picture of the week - docked flush to the map's own bottom edge, full
   width, no rounded corners/gap floating it over the map: a shelf that's
   clearly part of the map chrome, not a card that landed adrift in open
   space (that first version - a rounded floating card centered in empty
   map area - read as lost/arbitrary; anchoring to the edge fixes that).
   Desktop-only: on mobile the map is too cramped for a docked strip, and
   the swipeable gallery already covers "surface a good photo" there.
   POTW_STRIP_HEIGHT below (116px) is echoed in the desktop-only block
   further down that lifts the zoom control/attribution/shuffle/locate/
   filter buttons clear of this strip - keep both in sync if it changes. */
#potw {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 500;
  height: 116px;
  display: flex;
  align-items: center;
  background: rgba(255,255,255,0.92);
  backdrop-filter: blur(8px);
  box-shadow: 0 -4px 16px rgba(0,0,0,0.15);
  cursor: pointer;
}
#potw:hover { background: rgba(255,255,255,0.98); }
#potw[hidden] { display: none; }
#potw img { display: block; width: 168px; height: 116px; object-fit: cover; flex-shrink: 0; }
#potw-info { padding: 0 16px; overflow: hidden; }
#potw-label {
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.4px;
  color: var(--cyl-purple);
}
#potw-name {
  font-size: 16px;
  font-weight: 600;
  color: var(--ink);
  margin-top: 3px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#potw-credit {
  font-size: 11.5px;
  color: var(--ink-soft);
  margin-top: 5px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#potw-credit a { color: var(--ink-soft); }
#potw-credit:empty { display: none; }
#potw-cta {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--cyl-purple);
  margin-top: 8px;
}
#potw:hover #potw-cta { text-decoration: underline; }

/* Collaboration ask (see initPictureOfTheWeek() in app.js) - two real
   links (map/Contribute), not one destination. Was a small rounded pill
   floating in the strip's own leftover space; now it's its own full-height,
   full-width-of-its-share zone instead (flex: 1 + align-self: stretch, no
   border-radius/max-width to cap it) - the tint fills the whole part of
   the bar this content owns, right out to #potw's own right edge, rather
   than a card-shaped island with bare strip still showing around it.
   #potw-collab-text still caps its own width (so the sentence wraps at a
   sane measure instead of stretching edge to edge on a wide screen) and
   the whole thing is centered in whatever space is actually available.
   Hidden below 960px - #potw-info alone already wants most of that width,
   no good place left to put this. */
#potw-collab {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  flex: 1;
  align-self: stretch;
  margin-left: 28px;
  padding: 8px 24px;
  background: linear-gradient(135deg, rgba(116,44,100,0.09), rgba(116,44,100,0.03));
  /* #potw sets cursor: pointer for the card as a whole (clicking it opens
     the featured monument) - but this block is about the monuments that
     have no photo at all, so its click is deliberately ignored there.
     Undo the inherited pointer so it doesn't advertise a click that does
     nothing; its own links opt back in just below. */
  cursor: default;
}
/* Same "big bold number" language as .stat-value on
   the Stats page - a familiar visual cue for "here's a figure worth
   noticing" rather than a plain inline number lost in a sentence. */
#potw-collab-count {
  font-family: var(--font-display);
  font-size: 19px;
  font-weight: 700;
  color: white;
  background: var(--cyl-purple);
  border-radius: 10px;
  min-width: 40px;
  height: 40px;
  padding: 0 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}
#potw-collab-text { max-width: 320px; }
/* 500, not the 600 the short label used to be - a full sentence set in a
   heavier weight starts reading as shouty rather than just noticeable. */
#potw-collab-label { font-size: 12.5px; font-weight: 500; color: var(--ink); line-height: 1.4; }
#potw-collab-links { font-size: 11.5px; margin-top: 5px; }
/* cursor spelled out rather than left to the UA stylesheet: #potw-collab
   above sets `default`, and cursor inherits - these are the one thing in
   that block that IS clickable. */
#potw-collab-links a { color: var(--cyl-purple); font-weight: 600; text-decoration: none; cursor: pointer; }
#potw-collab-links a:hover { text-decoration: underline; }
#potw-collab-links .sep { color: var(--ink-soft); margin: 0 3px; }

/* Replaces #potw-collab-count/-text once geolocation resolves (see
   renderPotwNearby() in app.js) - same slot, same label styling as
   #potw-collab-label above, just a short list instead of a count+
   sentence. */
#potw-collab-nearby { max-width: 320px; }
#potw-nearby-list { list-style: none; margin: 5px 0 0; padding: 0; }
#potw-nearby-list li { border-top: 1px solid rgba(116,44,100,0.12); }
#potw-nearby-list li:first-child { border-top: none; }
#potw-nearby-list a {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 3px 0;
  color: var(--ink);
  text-decoration: none;
  font-size: 12px;
}
#potw-nearby-list a:hover .potw-nearby-name { text-decoration: underline; }
.potw-nearby-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.potw-nearby-dist { flex-shrink: 0; color: var(--ink-soft); font-size: 11px; }
@media (max-width: 960px) {
  #potw-collab { display: none; }
}

#potw-close {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 1;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: none;
  background: rgba(0,0,0,0.35);
  color: white;
  font-size: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
#potw-close:hover { background: rgba(0,0,0,0.55); }
@media (max-width: 768px) {
  #potw { display: none; }
}
/* Lift the map's own bottom-anchored chrome (Leaflet's zoom control +
   attribution share the .leaflet-bottom corner containers) and our own
   button stack clear of the 116px strip above - only while #map-wrap
   actually carries .potw-visible (toggled in app.js alongside #potw's own
   hidden state), so once the strip is dismissed everything settles back
   down instead of leaving a gap of bare map beneath it. Desktop-only,
   matching #potw's own breakpoint - mobile (where the strip never shows,
   so the class is never added) needs no override either way. */
@media (min-width: 769px) {
  #map-wrap.potw-visible .leaflet-bottom { bottom: 116px; }
  #map-wrap.potw-visible #locate-btn { bottom: 140px; }
  #map-wrap.potw-visible #shuffle-btn { bottom: 194px; }
  #map-wrap.potw-visible #filter-btn,
  #map-wrap.potw-visible #filter-panel { bottom: 248px; }
}

/* #contribute-bar: unlike #potw, on screen (mobile and desktop both)
   whenever the plain map is showing at all - see app.js, toggled in the
   same two places as #potw but with no dismiss state of its own. */
#contribute-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 499; /* just under #potw (500) so its shadow still reads if the two ever visually meet */
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 16px;
  background: rgba(116,44,100,0.94);
  backdrop-filter: blur(8px);
  color: white;
  font-size: 13.5px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}
#contribute-bar:hover { background: var(--cyl-purple); }
#contribute-bar[hidden] { display: none; }
/* Two copies, not one truncated by CSS - a centered line clipped with
   text-overflow: ellipsis only ever gets a hard cut on both ends (the
   property assumes start- or end-aligned overflow, not a centered line
   overflowing both sides at once), so a shorter sentence for narrow
   screens reads properly instead of just losing its edges. */
.contribute-bar-text-full,
.contribute-bar-text-short {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
.contribute-bar-text-short { display: none; }
@media (max-width: 480px) {
  .contribute-bar-text-full { display: none; }
  .contribute-bar-text-short { display: inline; }
}

/* #potw stacks above #contribute-bar rather than the two overlapping -
   desktop-only, matching #potw's own breakpoint (on mobile #potw never
   shows, so #contribute-bar is always the true floor there). */
@media (min-width: 769px) {
  #potw { bottom: 48px; }
}

/* Same lift idea as .potw-visible above, but unconditional (mobile and
   desktop both) since #contribute-bar itself is. Same 54px rhythm between
   locate/shuffle/filter, just based off a 48px floor instead of 0. */
#map-wrap.contribute-bar-visible .leaflet-bottom { bottom: 48px; }
#map-wrap.contribute-bar-visible #locate-btn { bottom: 72px; }
#map-wrap.contribute-bar-visible #shuffle-btn { bottom: 126px; }
#map-wrap.contribute-bar-visible #filter-btn,
#map-wrap.contribute-bar-visible #filter-panel { bottom: 180px; }

/* Both strips up at once (desktop only - #contribute-bar-visible alone
   already covers mobile, where .potw-visible never applies): higher
   specificity than either single-class rule above, so this wins outright
   regardless of source order, rather than the usual last-rule-wins
   tiebreak silently picking whichever strip's rule happens to come later
   in the file. */
@media (min-width: 769px) {
  #map-wrap.contribute-bar-visible.potw-visible .leaflet-bottom { bottom: 164px; }
  #map-wrap.contribute-bar-visible.potw-visible #locate-btn { bottom: 188px; }
  #map-wrap.contribute-bar-visible.potw-visible #shuffle-btn { bottom: 242px; }
  #map-wrap.contribute-bar-visible.potw-visible #filter-btn,
  #map-wrap.contribute-bar-visible.potw-visible #filter-panel { bottom: 296px; }
}

#locate-btn:hover { transform: scale(1.08); background: white; }
#locate-btn.locating { animation: pulse 1s ease-in-out infinite; }
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

.you-are-here-dot {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #2a6fbb;
  border: 3px solid white;
  box-shadow: 0 1px 6px rgba(0,0,0,0.4);
  position: relative;
}
.you-are-here-dot::after {
  content: '';
  position: absolute;
  inset: -8px;
  border-radius: 50%;
  border: 2px solid #2a6fbb;
  animation: locate-ring 1.8s ease-out infinite;
}
@keyframes locate-ring {
  0% { transform: scale(0.6); opacity: 0.8; }
  100% { transform: scale(1.8); opacity: 0; }
}

#toast {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%) translateY(20px);
  background: var(--ink);
  color: white;
  padding: 12px 20px;
  border-radius: 10px;
  font-size: 13.5px;
  max-width: 320px;
  text-align: center;
  box-shadow: 0 6px 20px rgba(0,0,0,0.3);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease, transform 0.25s ease;
  z-index: 2000;
}
#toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
/* Clear the picture-of-the-week strip (116px, desktop only) when it's up -
   the toast is bottom-anchored to the viewport and would otherwise land
   right on top of it. Barely mattered while the only toast was a
   geolocation error; showMapFilteredByStatus() now toasts on a path that
   runs *from* that strip, so the two are on screen together routinely.
   :has() rather than a second class toggled from JS: #map-wrap.potw-visible
   is already maintained in four places (see dismissPotw() and friends), and
   a fifth to keep in sync is exactly how those drift apart. */
body:has(#map-wrap.potw-visible) #toast { bottom: 140px; }
/* Same reasoning, for #contribute-bar (48px, mobile and desktop both -
   see its own CSS) - and the combined case when both strips are up. The
   combined selector needs no extra specificity trick like the button
   rules above: :has() rules here don't compete with each other the same
   way, since only one of these three ever matches body at a time. */
body:has(#map-wrap.contribute-bar-visible:not(.potw-visible)) #toast { bottom: 72px; }
body:has(#map-wrap.contribute-bar-visible.potw-visible) #toast { bottom: 188px; }

/* On big screens the map has plenty of room to spare - scale up the chrome
   around it (topbar, legend, panel) rather than leaving it looking thin
   and undersized next to all that empty map space. */
@media (min-width: 1400px) {
  #topbar { height: 66px; padding: 0 32px; }
  #topbar .brand { font-size: 25px; }
  #logo-mark { height: 50px; }
  #topbar button { font-size: 14.5px; padding: 9px 17px; }
  #menu-btn { width: 42px; height: 42px; }

  #search-wrap { max-width: 540px; margin: 0 28px; }
  #search-input { font-size: 15px; padding: 10px 18px; }
  .search-row { font-size: 14.5px; padding: 9px 10px; }
  .search-section-label { font-size: 12px; }

  #panel.open { width: 500px; }
  #panel-content { width: 500px; }

  .hero { height: 320px; }
  .hero-placeholder-icon { font-size: 140px; }
  .hero-scrim { padding: 48px 28px 22px; }
  .hero-kicker { font-size: 13px; }
  .hero-title { font-size: 30px; }
  .hero-fallback { height: 200px; }
  .hero-fallback .hero-placeholder-icon { font-size: 92px; }

  .panel-body { padding: 26px 30px 34px; }
  .panel-body .meta { font-size: 14px; }
  .panel-body h3 { font-size: 18px; margin-top: 28px; }
  .extract { font-size: 15.5px; }
  .source-list { font-size: 14px; }
  .list-row { font-size: 14.5px; padding: 12px 2px; }
  .badge { font-size: 14px; padding: 8px 16px; }

  /* A wrapping grid, not the mobile/compact horizontal scroll strip - past
     this width there's room to see several rows of photos at once instead
     of scrolling sideways through a filmstrip. auto-fill sizes the column
     count to whatever width the gallery actually has, no fixed count
     needed - it'll fit more columns once the 1800px tier below widens the
     panel further. */
  .gallery {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
    gap: 10px;
    overflow: visible;
  }
  /* .gallery-thumb (the grid cell) fills the cell; .gallery-thumb img
     (the actual thumbnail) then fills *that*, overriding its own base
     68x68 fixed size - same two-rule split .gallery-more already needed
     when this was still a bare <img>, just spread across the wrapper now. */
  .gallery-thumb, .gallery-more { width: 100%; height: 90px; max-width: none; min-width: 0; }
  .gallery-thumb img { width: 100%; height: 100%; }
}

/* Large desktop: the panel gets meaningfully wider still, single column
   (a two-column hero/text split was tried and reverted - it made the hero
   taller but not wider, and a landscape photo's real constraint is width;
   cover just crops harder into a narrow-but-tall box, so it read as MORE
   zoomed-in, not bigger). The hero's height switches from a fixed pixel
   number to aspect-ratio, so it actually grows in both dimensions together
   as the wider panel gives it more width, instead of staying pinned at the
   1400px tier's 320px forever while only the width around it changes. */
@media (min-width: 1800px) {
  #panel.open { width: 680px; }
  #panel-content { width: 680px; }
  .hero { height: auto; aspect-ratio: 3 / 2; }
  .hero-placeholder-icon { font-size: 170px; }
  .hero-title { font-size: 34px; }
  /* Real photos scale up with the wider panel via aspect-ratio above - the
     icon placeholder shouldn't grow the same way, it's not a photo with a
     "bigger is better" case for it. Back to a fixed, modest height. */
  .hero-fallback { height: 200px; aspect-ratio: auto; }
  .hero-fallback .hero-placeholder-icon { font-size: 100px; }
}

