/* Google Fonts (Inter + Space Grotesk) are loaded from base.html's
   <head> via <link rel="preconnect">/<link rel="stylesheet"> -- NOT a
   second @import here. @import inside a stylesheet on the critical
   render path blocks that stylesheet's own parsing until the imported
   CSS is fetched, and can't be discovered by the browser's preload
   scanner nearly as early as a <link> in <head> can (confirmed as
   Lighthouse's top render-blocking-requests flag). This @import also
   requested a DIFFERENT, incomplete weight set than what's actually
   used below (500;700, missing 600 and 800 entirely) -- base.html's
   <link> now requests the real set every weight below actually needs
   (400;700 Inter, 600;700;800 Space Grotesk), so removing this
   redundant, wrong-weight duplicate is a pure improvement either way. */

:root {
  --c-base: #080808;
  --c-surface: #111111;
  --c-elevated: #1C1C1C;
  --c-border: #2A2A2A;
  --c-border-mid: #3D3D3D;
  --c-accent: #F5A623;
  --c-text: #F0F0F0;
  --c-muted: #888888;
  --c-faint: #444444;

  /* Neon nightlife accent pair, layered with the existing warm
     --c-accent for a three-stop brand gradient. Defined once here so
     every other rule references them via var(--c-*) -- see
     test_style_css_responsive.py's approved-palette check, which
     forbids raw hex literals anywhere outside a --c-* definition
     line like this one. */
  --c-accent-pink: #FF2E7A;
  --c-accent-violet: #8B5CF6;

  /* Translucent glow/glass variants -- rgba(), not hex, so these sit
     outside the hex-literal check entirely while still giving every
     other rule a single named source of truth to reference. */
  --c-glow-pink: rgba(255, 46, 122, 0.45);
  --c-glow-violet: rgba(139, 92, 246, 0.4);
  --c-glow-amber: rgba(245, 166, 35, 0.3);
  --c-surface-glass: rgba(17, 17, 17, 0.62);
  --c-elevated-glass: rgba(28, 28, 28, 0.72);

  --gradient-brand: linear-gradient(135deg, var(--c-accent-pink), var(--c-accent-violet));
  --gradient-brand-wide: linear-gradient(120deg, var(--c-accent-pink) 0%, var(--c-accent-violet) 55%, var(--c-accent) 100%);

  /* Reward-card-only palette -- deliberately a different, hotter hue
     order (orange -> red -> purple) than --gradient-brand's
     pink -> violet, so the card doesn't blend in as "just more of the
     same UI". --c-* prefix (not e.g. --reward-*) purely to stay
     inside test_style_css_responsive.py's approved-palette check,
     which only exempts hex literals inside a --c-*: definition line. */
  --c-reward-orange: #FFB347;
  --c-reward-red: #FD1D1D;
  --c-reward-purple: #833AB4;
  --c-reward-glow-red: rgba(253, 29, 29, 0.15);
  --c-reward-glow-purple: rgba(131, 58, 180, 0.35);
  --gradient-reward: linear-gradient(135deg, var(--c-reward-orange), var(--c-reward-red) 55%, var(--c-reward-purple));

  --font-display: 'Space Grotesk', sans-serif;
  --font-body: 'Inter', sans-serif;
  --font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;

  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-full: 999px;
}

* {
  box-sizing: border-box;
}

/* Fills the margin area outside body's own 1940px cap (below) on very
   wide screens -- body::before's glow background is `position: fixed`
   (viewport-relative, covers the full screen regardless of body's own
   width), but withOUT this, the bare strip of html itself beyond
   body's edges would show the browser's default background instead
   of the dark theme. */
html {
  background-color: var(--c-base);
}

body {
  margin: 0 auto;
  max-width: 1940px;
  background-color: var(--c-base);
  color: var(--c-text);
  font-family: var(--font-body);
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  position: relative;
  overflow-x: hidden;
}

/* Atmospheric "club lighting" glow, fixed behind all content. Pure
   decoration -- no template markup needed, so it can't break the
   index.html/style.css class-reconciliation test. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(circle at 15% -10%, var(--c-glow-pink), transparent 40%),
    radial-gradient(circle at 90% 5%, var(--c-glow-violet), transparent 38%),
    radial-gradient(circle at 50% 110%, var(--c-glow-amber), transparent 45%);
  filter: blur(60px);
  opacity: 0.8;
}

.search-form,
.results-main,
.sort-control,
.pagination {
  position: relative;
  z-index: 1;
}

.site-header {
  /* Sticky, not relative -- kept on screen while the results list
     scrolls underneath it. The existing translucent glass background
     + backdrop-filter blur below already reads well with content
     passing behind it, so no separate "scrolled" style is needed. A
     higher z-index than the other positioned elements above (all at
     1) is required, not optional -- without it, .results-main (later
     in DOM order, same stacking context) would paint over the sticky
     header instead of under it once they overlap during scroll. */
  position: sticky;
  top: 0;
  z-index: 10;
  padding: 24px 32px;
  border-bottom: 1px solid var(--c-border);
  background-color: var(--c-surface-glass);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px 24px;
}

/* City header photo (Sprint 21): a thin, non-sticky panoramic strip
   directly below the sticky site-header on a city's own pages --
   NOT itself sticky, deliberately: pinning a large decorative image
   in place while scrolling would eat into exactly the content space
   this was asked to stay out of ("not taking up too much space from
   the content"). Scrolls away normally once the visitor starts
   reading results, leaving only the slim nav bar pinned.

   max-height: 10vh is the hard cap that was actually asked for.
   No min-height: aspect-ratio below already guarantees the box scales
   down smoothly to zero as width shrinks, never collapsing outright --
   an explicit min-height here would instead force the box TALLER than
   the 16:1 shape on narrow viewports, and since background-size below
   is contain (never crops), that extra height shows as a visible
   empty --c-surface gap above and below the banner, not a safety net.

   aspect-ratio: 1600/100 keeps the box's own shape matching the SVGs'
   real 16:1 shape at every width instead of a fixed vh-based height --
   on a narrow phone viewport that shrinks the whole banner down
   proportionally (still shows every landmark, just smaller) instead
   of forcing a squarer box that a cover/crop approach would then have
   to zoom into and crop most of the landmarks out of.

   background-size: contain (never cover) + no-repeat guarantees the
   full banner is always visible, whatever the exact box shape ends up
   being once max-height clamps it away from pure 16:1 -- letterboxing
   (plain --c-surface bars) is the acceptable outcome there, cropping
   content is not.

   max-width: 1940px matches body's own max-width (this site's actual
   content container cap, see body{} above) so the banner fills the
   full page width on desktop instead of stopping short at a stale
   1600px figure left over from when headers were fixed-1600px-wide
   raster photos -- the SVGs are vector art, no upscale-blur concern
   at any width; centering it leaves a plain --c-surface margin only
   on viewports wider than 1940px, matching the site's own dark
   background rather than looking like a rendering bug. */
.city-header-photo {
  position: relative;
  width: 100%;
  max-width: 1940px;
  margin: 0 auto;
  aspect-ratio: 1600 / 100;
  max-height: 10vh;
  background-size: contain;
  background-position: center;
  background-repeat: no-repeat;
  background-color: var(--c-surface);
}

.tagline {
  margin: 0;
  color: var(--c-muted);
  font-family: var(--font-body);
  font-size: 0.85rem;
}

/* 2026-08-07: auto margins on BOTH sides (not just the left) center
   this block in whatever free space is left in .site-header's row
   after the brand/tagline on one side and .language-selector (pinned
   to the true right edge, see below) on the other -- a standard
   flexbox centering trick, not a fixed/guessed offset. */
.site-nav {
  margin-left: auto;
  margin-right: auto;
  display: flex;
  align-items: center;
  gap: 16px;
}

/* 2026-08-07: underlined (previously text-decoration: none) so these
   two links read as distinct clickable items rather than two words
   sitting next to each other -- confirmed live this was genuinely
   ambiguous at a glance ("Cities Contact us"). Matches the footer's
   own city links (.footer-cities a), which never removed the
   default underline in the first place -- this brings the header
   nav in line with that existing convention instead of introducing a
   third, different "is this a link" treatment. */
.site-nav a {
  color: var(--c-muted);
  text-decoration: underline;
  text-underline-offset: 3px;
  font-size: 0.85rem;
  transition: color 0.2s ease;
}

.site-nav a:hover {
  color: var(--c-text);
}

/* Sprint 22: language selector -- a plain <select> inside .site-nav
   (see base.html's own comment on why it's a form, not JS-driven). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.language-selector {
  display: inline-flex;
  align-items: center;
}

.language-selector select {
  background-color: var(--c-surface);
  color: var(--c-muted);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-sm);
  font-family: var(--font-body);
  font-size: 0.8rem;
  padding: 2px 6px;
  cursor: pointer;
}

.language-selector select:hover {
  color: var(--c-text);
}

/* 2026-08-08: display: contents on desktop makes this wrapper
   invisible to layout -- .site-nav and .language-selector inside it
   behave exactly as if they were direct flex children of .site-header
   (unchanged from before this wrapper existed). The mobile media query
   below replaces this with a real box, fixed to the bottom of the
   viewport instead. */
.header-links {
  display: contents;
}

.site-footer {
  padding: 16px 32px;
  text-align: center;
  color: var(--c-faint);
  font-size: 0.75rem;
}

.site-footer p {
  margin: 0;
}

.footer-cities {
  margin-bottom: 10px;
}

.footer-cities-label {
  margin-right: 4px;
}

.footer-cities a {
  color: var(--c-faint);
  margin: 0 6px;
  white-space: nowrap;
}

.footer-cities a:hover {
  color: var(--c-text);
}

/* 2026-08-08: .brand is now a flex wrapper around the favicon image
   + the actual wordmark text, not the styled element itself -- the
   gradient-text-clip treatment below only makes sense applied to
   .brand-text (a bare "img" has no glyphs for background-clip: text
   to clip against; on the parent it would just fail to paint the icon
   at all, since the element's own background wouldn't show through a
   non-text child either way). */
.brand {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  text-decoration: none;
  margin: 0;
  width: fit-content;
  transition: opacity 0.2s ease;
}

.brand-icon {
  width: 24px;
  height: 24px;
  border-radius: 8px;
  flex-shrink: 0;
}

.brand-text {
  font-family: var(--font-display);
  background: var(--gradient-brand-wide);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  font-size: 1.75rem;
  letter-spacing: 0.02em;
}

.brand:hover {
  opacity: 0.85;
}

select.form-input {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  background-color: var(--c-surface);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-md);
  color: var(--c-text);
  font-family: var(--font-body);
  padding: 12px 16px;
  width: 100%;
  cursor: pointer;
  background-image: linear-gradient(45deg, transparent 50%, var(--c-muted) 50%), linear-gradient(135deg, var(--c-muted) 50%, transparent 50%);
  background-position: calc(100% - 20px) calc(1em + 2px), calc(100% - 15px) calc(1em + 2px);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

select.form-input:focus {
  outline: none;
  border-color: var(--c-accent-violet);
  box-shadow: 0 0 0 3px var(--c-glow-violet);
}

select.form-input option {
  background-color: var(--c-surface);
  color: var(--c-text);
}

select.form-input option:disabled {
  color: var(--c-faint);
}

.form-input {
  background-color: var(--c-surface);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-md);
  color: var(--c-text);
  font-family: var(--font-body);
  padding: 12px 16px;
  width: 100%;
  min-width: 0;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

.form-input:focus {
  outline: none;
  border-color: var(--c-accent-violet);
  box-shadow: 0 0 0 3px var(--c-glow-violet);
}

.search-form {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: flex-end;
  padding: 24px 32px;
  border-bottom: 1px solid var(--c-border);
  background-color: var(--c-surface-glass);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
}

.search-form .form-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
  flex: 1 1 200px;
}

.search-form label {
  font-family: var(--font-body);
  color: var(--c-muted);
  font-size: 0.85rem;
}

.button {
  background: var(--gradient-brand);
  color: var(--c-text);
  border: none;
  border-radius: var(--radius-full);
  font-family: var(--font-display);
  font-weight: 700;
  padding: 12px 24px;
  cursor: pointer;
  box-shadow: 0 8px 24px -8px var(--c-glow-pink);
  transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
}

.button:hover {
  transform: translateY(-2px) scale(1.02);
  box-shadow: 0 12px 32px -8px var(--c-glow-violet);
  filter: brightness(1.08);
}

.button:active {
  transform: translateY(0) scale(0.99);
}

.quick-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

/* Quick filters share .button's pill shape/sizing/hover-lift affordance
   (per Sprint 14 Subtask 5: "reuse that class rather than inventing a
   new visual style"), but the vivid gradient fill itself is reserved
   for marking the ACTIVE preset -- an inactive quick filter overrides
   .button's background/shadow down to the same muted, outlined
   treatment .genre-pill and .pagination a already use, so there is
   real visual contrast for .pagination .current-page's own pattern
   (filled --gradient-brand) to reuse on the active one. */
.quick-filter {
  display: inline-block;
  text-decoration: none;
  font-size: 0.85rem;
  padding: 8px 16px;
  background: transparent;
  color: var(--c-muted);
  border: 1px solid var(--c-border-mid);
  box-shadow: none;
}

.quick-filter:hover {
  border-color: var(--c-accent-violet);
  color: var(--c-text);
  transform: none;
  box-shadow: none;
}

.quick-filter-active {
  background: var(--gradient-brand);
  color: var(--c-text);
  border-color: transparent;
}

.quick-filter-active:hover {
  border-color: transparent;
}

/* Sprint 16, subtask 1: share button + copy/native-share confirmation.
   Same outlined-pill shape and violet glow-on-hover language as
   .quick-filter above -- this is a secondary action next to the
   results, not a primary CTA, so it deliberately does NOT use the
   solid --gradient-brand fill .quick-filter-active reserves for
   "currently selected" state. */
.share-button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-body);
  font-size: 0.85rem;
  padding: 8px 16px;
  background: transparent;
  color: var(--c-muted);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-sm, 4px);
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
}

.share-button:hover {
  border-color: var(--c-accent-violet);
  color: var(--c-text);
  box-shadow: 0 0 0 3px var(--c-glow-violet);
}

.share-button-icon {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
}

.share-button-label {
  font-family: var(--font-body);
}

.share-confirmation {
  display: inline-block;
  margin-left: 8px;
  font-family: var(--font-body);
  font-size: 0.8125rem;
  color: var(--c-accent-pink);
  opacity: 0;
  transition: opacity 0.15s;
}

.share-confirmation.is-visible {
  opacity: 1;
}

.date-range-toggle {
  color: var(--c-muted);
  font-family: var(--font-body);
  font-size: 0.85rem;
  flex: 1 1 200px;
}

.date-range-toggle summary {
  cursor: pointer;
  color: var(--c-muted);
  padding: 8px 0;
}

/* Filter drawer: on wide viewports .filter-drawer-panel/.search-form
   render exactly as they did before this wrapper was introduced --
   .filter-fab (the mobile pill) is hidden and the checkbox driving
   it is irrelevant. On narrow viewports (see the max-width media
   query below) .filter-fab becomes a sticky pill that toggles a
   full-screen overlay via #filter-toggle:checked, a JS-free
   checkbox-driven disclosure (plain CSS sibling selectors, no
   dependency on the browser's own <details> open/closed rendering,
   which -- unlike a plain element -- cannot be reliably forced open
   again with author CSS once collapsed). */
.filter-toggle-checkbox {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Desktop: collapsed, the drawer is just the compact toggle bar, so
   sticking it to the bottom of the (already sticky) header costs
   almost no permanent viewport space and keeps "☰ Search" reachable
   while scrolling. Expanded, the panel is tall (city, quick filters,
   genre/venue pills, custom range, Search) -- staying sticky then
   would permanently occupy a large, fixed chunk of the viewport, so
   this drops back to normal document flow (scrolls away with the rest
   of the page) the moment it's opened. top: 85px matches
   .site-header's own rendered height (24px 32px padding + its content)
   -- see that rule's own sticky/z-index comment. Mobile's OWN sticky
   treatment (inside the max-width media query below) already differs
   intentionally -- collapsed sticks at the very top (no header offset
   needed there), expanded becomes a full-screen overlay instead of
   losing sticky outright -- so this is scoped to NOT apply there. */
@media (min-width: 769px) {
  .filter-drawer {
    position: sticky;
    top: 85px;
    z-index: 5;
  }

  /* Expanded: still normal document flow (scrolls away, not sticky --
     position: relative behaves identically to static for that
     purpose), but ALSO now the positioning context for .filter-fab
     below, which becomes the panel's own embedded close control
     rather than a separate row above it. top: 0 is required, not
     decorative -- the base .filter-drawer rule above sets top: 85px
     for the sticky COLLAPSED state, which position: static ignores
     entirely but position: relative does NOT: left as inherited, it
     shifted the whole expanded panel 85px down from its natural
     position, opening a dead gap under the header. */
  #filter-toggle:checked ~ .filter-drawer {
    position: relative;
    top: 0;
  }

  /* Expanded: "× Close" no longer occupies its own full-width bar --
     it's absolutely positioned into the top-right corner of the panel
     below, reading as part of that one surface rather than a second
     stacked box. .search-form's own padding-top (below) is widened to
     reserve clear space for it, so it never overlaps the city select/
     quick filters/Search button that already sit in that same corner. */
  #filter-toggle:checked ~ .filter-drawer .filter-fab {
    position: absolute;
    top: 18px;
    right: 32px;
    width: auto;
    min-height: 0;
    padding: 4px 10px;
    background: none;
    border: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    z-index: 2;
  }

  #filter-toggle:checked ~ .filter-drawer .search-form {
    padding-top: 56px;
  }
}

/* Sprint 17: collapsed-by-default filter drawer on the RESULTS page
   (fits more of the page above the fold without scrolling), open by
   default on the plain search form (nothing to hide before a first
   search -- the intro text literally says "pick a city below").
   templates/index.html sets #filter-toggle's initial checked state
   from show_results; the checkbox-driven sibling-selector mechanism
   below is otherwise identical to the mobile-only version the max-
   width media query further down already had (that block now adds
   ONLY the full-screen-overlay treatment on top of this, for narrow
   viewports specifically). */
/* Full-width bar, not a shrink-to-content pill -- a small isolated
   rectangle floating at the left reads as an orphaned leftover
   control, not an intentional toolbar. Matching the header's own
   surface-glass treatment (border-bottom + translucent background)
   makes it read as a natural extension of the page chrome instead of
   a randomly-styled button dropped on top of it. */
.filter-fab {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-height: 48px;
  padding: 12px 20px;
  background-color: var(--c-surface-glass);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--c-border);
  border-radius: var(--radius-md);
  font-family: var(--font-display);
  font-weight: 600;
  color: var(--c-text);
  cursor: pointer;
  box-sizing: border-box;
}

.filter-fab-label {
  white-space: nowrap;
}

.filter-fab-label-open {
  display: none;
}


.filter-drawer-panel {
  display: none;
}

#filter-toggle:checked ~ .filter-drawer .filter-drawer-panel {
  display: block;
  margin-top: 16px;
}

/* Desktop: no gap needed above the panel -- .filter-fab no longer
   occupies its own row there (see the min-width: 769px block above),
   so the 16px breathing room the base rule above still needs for
   mobile's separate sticky-bar treatment would otherwise show up as a
   dead strip between the header and the panel here. Placed AFTER the
   base rule on purpose: identical selector specificity means source
   order decides the winner, and a copy of this override inside the
   earlier min-width media query lost to this rule every time despite
   matching -- confirmed live as the actual cause of that gap. */
@media (min-width: 769px) {
  #filter-toggle:checked ~ .filter-drawer .filter-drawer-panel {
    margin-top: 0;
  }
}

#filter-toggle:checked ~ .filter-drawer .filter-fab-label-closed {
  display: none;
}

/* Close sits at the trailing edge, not stacked at the same leading
   position "☰ Filters" occupied -- margin-left: auto on the sole
   visible label pushes it there regardless of the bar's own
   justify-content, so this one rule covers both mobile and desktop
   without needing separate per-breakpoint alignment overrides. */
#filter-toggle:checked ~ .filter-drawer .filter-fab-label-open {
  display: inline;
  margin-left: auto;
}

/* The chip's whole purpose is summarizing the current search WHILE
   COLLAPSED -- once expanded, the form fields themselves already show
   those values, and leaving the chip visible competes with
   .filter-fab-label-open's auto-margin for the same trailing space
   (confirmed: on a narrow viewport this left "Close" only partially
   pushed right instead of reaching the true edge, since the chip
   after it still needed room). */
#filter-toggle:checked ~ .filter-drawer .filter-fab-chip {
  display: none;
}

.filter-fab-chip {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 0.8rem;
  color: var(--c-muted);
  background-color: var(--c-elevated);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-full);
  padding: 4px 12px;
}

.intro {
  padding: 32px;
  max-width: 640px;
}

.intro-lede {
  font-family: var(--font-display);
  color: var(--c-text);
  font-size: 1.5rem;
  margin: 0 0 8px;
}

.intro-body {
  font-family: var(--font-body);
  color: var(--c-muted);
  font-size: 1rem;
  line-height: 1.6;
  margin: 0;
}

.results-main {
  flex: 1;
  padding: 32px;
}

.results-title {
  font-family: var(--font-display);
  color: var(--c-text);
  font-size: 1.5rem;
  margin: 0 0 16px;
}

.city-description {
  font-family: var(--font-body);
  color: var(--c-muted);
  font-size: 0.9rem;
  max-width: 640px;
  margin: -8px 0 20px;
}

.cities-page {
  max-width: 720px;
  padding: 32px;
}

.cities-page > h1 {
  font-family: var(--font-display);
  color: var(--c-text);
  font-size: 1.75rem;
  margin: 0 0 24px;
}

.cities-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}

.cities-list-item {
  background-color: var(--c-surface);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-lg);
  padding: 16px;
}

.cities-list-item > a {
  font-family: var(--font-display);
  color: var(--c-accent);
  font-size: 1.1rem;
  text-decoration: none;
}

.cities-list-item > a:hover {
  text-decoration: underline;
}

.cities-list-item > .city-description {
  margin: 8px 0 0;
  max-width: none;
}

.contact-page {
  max-width: 560px;
  padding: 32px;
}

.contact-page > h1 {
  font-family: var(--font-display);
  color: var(--c-text);
  font-size: 1.75rem;
  margin: 0 0 24px;
}

.contact-section {
  background-color: var(--c-surface);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-lg);
  padding: 24px;
  margin-bottom: 24px;
}

.contact-section h2 {
  font-family: var(--font-display);
  color: var(--c-text);
  font-size: 1.15rem;
  margin: 0 0 8px;
}

.contact-section > p {
  color: var(--c-muted);
  font-size: 0.9rem;
  line-height: 1.5;
  margin: 0 0 16px;
}

.contact-form {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.contact-form label {
  font-family: var(--font-body);
  color: var(--c-muted);
  font-size: 0.85rem;
  margin-bottom: -6px;
}

.contact-form textarea.form-input {
  resize: vertical;
  font-family: var(--font-body);
}

.contact-form .button {
  align-self: flex-start;
}

.form-success {
  color: var(--c-text);
  background-color: var(--c-elevated);
  border: 1px solid var(--c-accent-violet);
  border-radius: var(--radius-md);
  padding: 10px 14px;
  font-size: 0.9rem;
  margin: 0 0 16px;
}

.form-error {
  color: var(--c-text);
  background-color: var(--c-elevated);
  border: 1px solid var(--c-accent-pink);
  border-radius: var(--radius-md);
  padding: 10px 14px;
  font-size: 0.9rem;
  margin: 0 0 16px;
}

.results-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;
}

.results-count {
  color: var(--c-muted);
  font-family: var(--font-body);
  font-size: 0.85rem;
  margin: 0;
}

.results {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 16px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.event-card {
  background-color: var(--c-surface);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-lg);
  /* Sprint 18: nudged from 20px -> 24px so the card grid's own visual
     density better matches the airier header/search-pane above it. */
  padding: 24px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  position: relative;
  overflow: hidden;
  /* Fixed height, not just a min-height -- a long title/venue name
     (see .event-name/.event-venue's own line-clamp below) used to
     stretch some cards taller than their grid row neighbours, making
     an otherwise even grid look ragged. Clamping both title and venue
     to a bounded number of lines makes every card's content provably
     fit within this height regardless of text length, so the fixed
     height never clips real content. 262px (not 230px) -- tall enough
     for .event-name's 3-line clamp, one line more than a plain 2-line
     title needs, so a long event name reads more fully before it
     truncates; raised from 254px to 262px alongside the padding bump
     above, preserving the exact same usable content height. */
  height: 262px;
  touch-action: pan-y;
  user-select: none;
  cursor: grab;
  transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
    box-shadow 0.25s ease, border-color 0.25s ease, background-color 0.25s ease,
    opacity 0.25s ease;
}

/* While a swipe drag is in progress, static/app.js drives transform
   and opacity directly on every pointer move -- the eased transition
   above would lag a finger/cursor by ~250ms, so it's suspended for
   the duration of the drag and restored once the card snaps back or
   the page reloads. */
.event-card-dragging {
  transition: none;
  cursor: grabbing;
}

.event-card[data-favorite="true"] {
  border-color: var(--c-accent);
}

.event-card[data-hidden="true"] {
  opacity: 0.5;
}

.event-card::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--gradient-brand-wide);
  opacity: 0.85;
}

.event-card:hover {
  background-color: var(--c-elevated);
  border-color: var(--c-accent-violet);
  transform: translateY(-4px);
  box-shadow: 0 16px 40px -16px var(--c-glow-violet), 0 8px 20px -12px var(--c-glow-pink);
}

.event-card-header {
  display: flex;
  align-items: flex-start;
  gap: 12px;
}

.event-card-header .event-name {
  flex: 1 1 auto;
  min-width: 0;
}

.event-card .event-name {
  font-family: var(--font-display);
  color: var(--c-text);
  font-size: 1.1rem;
  line-height: 1.3;
  margin: 0;
  /* Truncate past 3 lines rather than letting a long title stretch
     the card taller than its grid neighbours -- 3 lines lets a long
     title read almost in full before truncating (see .event-card's
     height, sized for this), while still leaving room for
     venue/time/genre below it, which matter just as much for scanning
     a results grid. The full, untruncated title is still reachable
     via the title="" attribute's native tooltip. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.event-card .event-name a {
  color: var(--c-accent);
  text-decoration: none;
  transition: color 0.2s ease;
}

.event-card .event-name a:hover {
  color: var(--c-accent-pink);
  text-decoration: underline;
}

.event-card .event-venue {
  color: var(--c-muted);
  font-size: 0.9rem;
  line-height: 1.3;
  margin: 0;
  /* Same reasoning as .event-name's clamp above -- a long venue name
     (e.g. two names joined with "/") must not be able to grow the
     card past its fixed height either. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.event-card .event-start {
  color: var(--c-faint);
  font-size: 0.85rem;
  margin: 0;
}

.event-genre {
  display: inline-block;
  align-self: flex-start;
  background-color: var(--c-elevated);
  border: 1px solid var(--c-border-mid);
  color: var(--c-muted);
  font-family: var(--font-body);
  font-size: 0.75rem;
  padding: 4px 12px;
  margin-top: 4px;
  border-radius: var(--radius-full);
}

.event-card-actions {
  /* Bottom-right corner of the card: last in DOM order among
     .event-card's flex-column children, margin-top: auto pushes it
     down to consume any remaining vertical space (rather than sitting
     wherever the genre pill happens to end), and align-self: flex-end
     pulls it to the right -- real reserved space in normal flow, not
     an absolutely-positioned overlay that could sit on top of a long
     title/venue/description. */
  flex: 0 0 auto;
  margin-top: auto;
  align-self: flex-end;
  display: flex;
  gap: 6px;
}

.event-badge {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border-radius: var(--radius-full);
  border: none;
  background-color: var(--c-surface);
  cursor: pointer;
  transition: background-color 0.2s ease, transform 0.15s ease;
}

.event-badge:hover {
  background-color: var(--c-elevated);
  transform: scale(1.08);
}

.event-badge-icon {
  width: 18px;
  height: 18px;
}

/* Default state: grey outline, unfilled. */
.event-badge-favorite .event-badge-icon {
  fill: none;
  stroke: var(--c-muted);
  stroke-width: 1.6;
}

.event-badge-hidden .event-badge-icon .icon-eye-shape {
  fill: none;
}

.event-badge-hidden .event-badge-icon {
  stroke: var(--c-muted);
  stroke-width: 1.6;
}

/* Active state: filled -- brand pink heart, brand violet crossed-out
   eye (the palette has no literal red/blue token; these are the
   closest approved --c-accent-* tones, reused rather than introducing
   new raw colours -- see test_no_hardcoded_hex_colours_outside_approved_palette). */
.event-badge-favorite[aria-pressed="true"] .event-badge-icon {
  fill: var(--c-accent-pink);
  stroke: var(--c-accent-pink);
}

.event-badge-hidden[aria-pressed="true"] .event-badge-icon {
  stroke: var(--c-accent-violet);
}

.event-badge-hidden[aria-pressed="true"] .event-badge-icon .icon-eye-shape {
  fill: var(--c-accent-violet);
}

/* Unconfirmed badge: a static indicator (not a button -- nothing to
   toggle), shown when a previously-stored event was carried over
   because the latest scrape didn't return it (see db.py's
   merge_and_save_events). Amber/--c-accent to read as "needs a
   second look", distinct from the favorite/hidden badges' pink/violet. */
.event-badge-unconfirmed {
  cursor: help;
}

.event-badge-unconfirmed:hover {
  transform: none;
}

.event-badge-unconfirmed .event-badge-icon {
  fill: none;
  stroke: var(--c-accent);
  stroke-width: 1.6;
}

/* Sponsored partner-offer card, rendered as one of the .results list
   items (see templates/index.html) so it sits inline with the event
   cards it interrupts. Deliberately styled to stand apart -- a
   different gradient direction/hue than the rest of the page, a
   tinted outer glow instead of a flat shadow, and a spring-eased
   pop-in -- rather than blending in as "just another card". */
.reward-card {
  /* span 1 here is the safe default -- it can NEVER force CSS Grid to
     create an extra implicit column. static/app.js's sizeRewardCard()
     overrides this inline to "span 2" once it knows the real column
     count (see there for why: confirmed live that grid-column: span N
     for an N the grid doesn't actually have at a given width does NOT
     clamp down gracefully -- it forces an extra implicit column sized
     by grid-auto-columns (default "auto"), which corrupted EVERY
     row's column widths, not just this card's row, since column
     tracks are shared across the whole grid). */
  grid-column: span 1;
  /* Same fixed height as .event-card (see that rule) so this card
     never sticks up taller than its row neighbours -- every child
     element below is sized/spaced to comfortably fit within it at
     both 1-column (mobile) and 2-column widths, with a line-clamp on
     the description as a safety net rather than letting overflow:
     hidden clip it mid-sentence. */
  height: 262px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: center;
  border-radius: var(--radius-lg);
  padding: 24px;
  background: var(--gradient-reward);
  color: var(--c-text);
  box-shadow: 0 0 0 4px var(--c-reward-glow-red), 0 12px 30px var(--c-reward-glow-purple);
  animation: reward-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

@keyframes reward-pop {
  0%   { opacity: 0; transform: scale(0.85) translateY(8px); }
  100% { opacity: 1; transform: scale(1) translateY(0); }
}

.reward-eyebrow {
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.65rem;
  font-weight: 700;
  opacity: 0.9;
  margin-bottom: 4px;
}

.reward-percentage {
  font-family: var(--font-display);
  font-size: 2.1rem;
  font-weight: 800;
  line-height: 1;
}

.reward-service {
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 700;
  margin: 4px 0 6px;
}

.reward-code {
  display: inline-block;
  background-color: var(--c-elevated-glass);
  border: 2px dashed var(--c-text);
  border-radius: var(--radius-md);
  padding: 3px 10px;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  font-weight: 700;
  letter-spacing: 0.05em;
}

.reward-description {
  font-size: 0.7rem;
  line-height: 1.35;
  opacity: 0.95;
  margin-top: 6px;
  /* Safety net, not the primary fit strategy -- the sizes/spacing
     above are already tuned to fit the full text at both 1- and
     2-column widths, but a line-clamp here means a narrower viewport
     than tested (or a future edit to this copy) degrades to a clean
     ellipsis instead of the card's own overflow: hidden clipping the
     text mid-sentence. */
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Shown by static/app.js the moment a search/filter navigation to
   /events is triggered (form submit or a pagination/filter link
   click), and hidden again by default on every fresh page load --
   the natural request/response cycle clears it, no JS teardown call
   needed on the arriving page. */
.search-loading-overlay {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: none;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 24px;
  text-align: center;
  background-color: var(--c-surface-glass);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
}

.search-loading-overlay.is-visible {
  display: flex;
}

.search-loading-spinner {
  width: 40px;
  height: 40px;
  border-radius: var(--radius-full);
  border: 4px solid var(--c-border-mid);
  border-top-color: var(--c-accent-pink);
  animation: search-loading-spin 0.8s linear infinite;
}

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

.search-loading-message {
  max-width: 320px;
  color: var(--c-text);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1rem;
  margin: 0;
}

.genre-filters,
.venue-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  /* Full-width row of their own within .search-form's flex layout,
     rather than squeezing in alongside the city select/quick filters
     at whatever leftover inline space remains. */
  flex: 1 1 100%;
}

.genre-pill {
  display: inline-block;
  text-decoration: none;
  color: var(--c-muted);
  font-family: var(--font-body);
  font-size: 0.8rem;
  padding: 8px 16px;
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-full);
  transition: border-color 0.2s ease, color 0.2s ease;
}

.genre-pill:hover {
  border-color: var(--c-accent-violet);
  color: var(--c-text);
}

.genre-pill-active {
  background: var(--gradient-brand);
  color: var(--c-text);
  border-color: transparent;
}

.genre-pill-reset {
  padding: 8px 12px;
  color: var(--c-muted);
  font-weight: 600;
}

.genre-pill-reset:hover {
  border-color: var(--c-accent-pink);
  color: var(--c-accent-pink);
}

.empty-state {
  color: var(--c-muted);
  font-family: var(--font-body);
  text-align: center;
  padding: 40px;
  border: 1px dashed var(--c-border-mid);
  border-radius: var(--radius-lg);
}

/* Sprint 18: sits directly above the results grid, not inside
   .results-header -- the venue/sort controls act on the LIST right
   below them, so they read more naturally anchored there than tucked
   next to the count+share row above. Selects are reskinned pills
   (same glass surface + custom chevron language as select.form-input
   above) rather than the old boxy <select> default. */
.sort-control {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  padding: 0;
  margin-bottom: 20px;
}

.sort-control label {
  color: var(--c-muted);
  font-size: 0.85rem;
  font-family: var(--font-body);
}

.sort-control select {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  background-color: var(--c-surface-glass);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-full);
  color: var(--c-text);
  font-family: var(--font-body);
  font-size: 0.85rem;
  padding: 8px 32px 8px 16px;
  cursor: pointer;
  background-image: linear-gradient(45deg, transparent 50%, var(--c-muted) 50%), linear-gradient(135deg, var(--c-muted) 50%, transparent 50%);
  background-position: calc(100% - 18px) calc(1em + 0px), calc(100% - 13px) calc(1em + 0px);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

.sort-control select:hover {
  border-color: var(--c-accent-violet);
}

.sort-control select:focus {
  outline: none;
  border-color: var(--c-accent-violet);
  box-shadow: 0 0 0 3px var(--c-glow-violet);
}

.sort-control select option {
  background-color: var(--c-surface);
  color: var(--c-text);
}

/* Sprint 14 Subtask 5: moved from centered to bottom-right of the
   results section. */
.pagination {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  padding: 24px 0 0;
}

.pagination a,
.pagination span {
  font-family: var(--font-body);
  color: var(--c-text);
  text-decoration: none;
  padding: 8px 16px;
  border: 1px solid var(--c-border-mid);
  border-radius: var(--radius-full);
  transition: border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}

.pagination a:hover {
  border-color: var(--c-accent-pink);
  color: var(--c-accent-pink);
  box-shadow: 0 4px 16px -6px var(--c-glow-pink);
}

.pagination .current-page {
  color: var(--c-base);
  background: var(--gradient-brand);
  border-color: transparent;
}

@media (max-width: 768px) {
  /* 2026-08-08: the sticky header used to stack brand/tagline/nav/
     language-selector as four separate centered rows (~158px tall),
     permanently eating almost a fifth of a typical phone viewport on
     every single scroll. Tagline is dropped entirely here -- it's
     marketing copy, not functional chrome, and the intro section
     already carries the same "what is this site" context on the
     landing page (see .intro). Nav links + language selector move out
     of the header into their own fixed bottom bar (.header-links
     below) instead of sharing it with the brand, so the sticky header
     now carries only the brand -- justify-content: center centers
     that lone remaining item.

     backdrop-filter: none is NOT decorative here -- backdrop-filter
     (like filter/perspective/will-change: transform) makes its
     element the CONTAINING BLOCK for any position: fixed descendant,
     not just the viewport. .header-links below is fixed and lives
     inside this header (required for the desktop display: contents
     trick to keep it visually part of the header row there), so
     without this override its "bottom: 0" resolved against the
     header's own ~69px-tall box instead of the viewport -- confirmed
     live, it rendered pinned just under the brand instead of at the
     bottom of the screen. */
  .site-header {
    justify-content: center;
    padding: 16px 20px;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .tagline {
    display: none;
  }

  /* .header-links pulled this out of .site-header's flex flow (fixed-
     position elements don't participate in their DOM parent's flex
     layout even though they're still its child) -- these resets are
     for the bar below, not the header anymore. */
  .site-nav {
    margin-left: 0;
    margin-right: 0;
  }

  /* Nav links + language selector, now a real box (see the desktop
     display: contents rule) pinned to the bottom of the viewport --
     reachable with a thumb without permanently eating vertical space
     at the TOP of the screen the way sharing the sticky header did.
     z-index: 15 sits above ordinary content and the collapsed filter
     pill (5) but below the full-screen filter overlay (20), which
     already covers the whole viewport with its own solid background
     when open -- no separate rule needed to hide this bar then, the
     modal just paints over it. body's own padding-bottom (see that
     rule) keeps this from covering the last real content (the site
     footer) when scrolled all the way down. */
  .header-links {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 20px;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 15;
    padding: 12px 20px;
    background-color: var(--c-surface-glass);
    backdrop-filter: blur(16px);
    -webkit-backdrop-filter: blur(16px);
    border-top: 1px solid var(--c-border);
  }

  /* Sticky filter pill: collapsed state sits at the top of the
     scroll so it stays reachable with a thumb without permanently
     eating vertical space the way the old always-expanded form did.
     top must match the sticky header's own rendered height (69px now
     that nav/language moved out to .header-links -- verify live if
     either changes) -- left at 0 this sat almost entirely UNDER the
     header once actually scrolled (confirmed live), not visibly below
     it, since both are sticky-to-viewport-top in the same stacking
     context. */
  .filter-drawer {
    position: sticky;
    top: 69px;
    z-index: 5;
  }

  .filter-fab {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    min-height: 48px;
    padding: 12px 16px;
    background-color: var(--c-surface-glass);
    backdrop-filter: blur(16px);
    -webkit-backdrop-filter: blur(16px);
    border-bottom: 1px solid var(--c-border);
    font-family: var(--font-display);
    font-weight: 700;
    color: var(--c-text);
  }

  /* Collapsed-by-default is now a base-level rule (see the non-media-
     query .filter-drawer-panel rule above) -- mobile only adds its
     own padding on top of that shared behavior. */
  .filter-drawer-panel {
    padding: 16px;
  }

  /* Opened: the whole drawer becomes a full-screen overlay (our
     equivalent of a bottom-sheet/modal, built from the same
     surface/blur tokens the header already uses -- there is no
     pre-existing modal component in this app to reuse) and the
     pill turns into its sticky close bar. The panel's display:block
     on check is already a base-level rule; only the fullscreen
     positioning below is mobile-specific. */
  #filter-toggle:checked ~ .filter-drawer:not(.filter-drawer-landing) {
    position: fixed;
    inset: 0;
    z-index: 20;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    background-color: var(--c-base);
  }

  #filter-toggle:checked ~ .filter-drawer:not(.filter-drawer-landing) .filter-fab {
    position: sticky;
    top: 0;
    z-index: 1;
  }

  /* Landing: expanded by default since there's no prior search to
     summarize into a pill, but unlike the user-triggered overlay
     above, there's no "previous view" to return to either -- taking
     over the full viewport here would just bury the brand header and
     intro copy that are already sitting right above it in the DOM.
     Stays in normal document flow instead, same as desktop's own
     expanded (position: relative) treatment. */
  #filter-toggle:checked ~ .filter-drawer.filter-drawer-landing {
    position: static;
  }

  /* 2026-08-08: on landing there's no prior collapsed pill to return
     to (see the position: static rule above) -- "× Close" would just
     collapse the page's own primary call-to-action, the search form
     itself, which isn't something a fresh visitor needs. Unlike the
     user-triggered overlay case above, this isn't a modal either, so
     there's no dismiss-affordance requirement to preserve -- hiding
     the whole bar (not just its label) reclaims its padding/height
     too, not just the text inside it. */
  #filter-toggle:checked ~ .filter-drawer.filter-drawer-landing .filter-fab {
    display: none;
  }

  .search-form {
    flex-direction: column;
    align-items: stretch;
    padding: 0;
    border-bottom: none;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .search-form .form-group {
    flex: 1 1 auto;
  }

  /* .date-range-toggle's base flex: 1 1 200px sets a 200px flex-basis
     for the DESKTOP row layout, where that's a sensible starting
     WIDTH alongside the quick-filter buttons. .search-form switches to
     flex-direction: column on mobile above, which turns that same
     200px into a minimum HEIGHT instead -- confirmed live, the
     collapsed <details> (only its one-line <summary> visible) was
     reserving 200px of blank space below it regardless. flex: 1 1 auto
     here sizes it to its actual (collapsed or expanded) content like
     .form-group above already does. */
  .date-range-toggle {
    flex: 1 1 auto;
  }

  .search-form .button {
    width: 100%;
  }

  .intro {
    padding: 16px;
  }

  .results {
    grid-template-columns: 1fr;
  }

  .results-main {
    padding: 16px;
  }

  /* 2026-08-07: count+share now stay on one compact row instead of
     stacking -- the "(updated Xm ago)" clause is usually the long
     part, so it's the one that truncates with an ellipsis under real
     space pressure rather than pushing the share button onto its own
     line and costing a whole extra row above the fold. */
  .results-header {
    flex-wrap: nowrap;
    gap: 8px;
  }

  .results-count {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .share-button {
    flex: 0 0 auto;
    padding: 8px;
  }

  /* Icon-only on mobile -- "Share search results" ate most of the
     available width on the one-line count+share row above, leaving
     the count truncated down to almost nothing. The button still has
     its own aria-label, so the accessible name is unaffected. */
  .share-button-label {
    display: none;
  }

  /* 2026-08-07: the venue/sort controls and the city description are
     secondary, "refine further" content -- reordered (not reflowed in
     the DOM, so source order and screen-reader order are untouched)
     so they sit below pagination at the very bottom of the page,
     instead of pushing the first actual event card off the bottom of
     a phone screen. Every direct child of .results-main needs an
     explicit order -- one left at the implicit default (0) would jump
     ahead of all of these regardless of where it sits in the markup. */
  .results-main {
    display: flex;
    flex-direction: column;
  }

  .results-main > .results-title { order: 1; }
  .results-main > .results-header { order: 2; }
  .results-main > .empty-state,
  .results-main > .results { order: 3; }
  .results-main > .pagination { order: 4; }
  .results-main > .sort-control {
    order: 5;
    flex-direction: column;
    align-items: stretch;
    margin-top: 24px;
    padding-top: 16px;
    border-top: 1px solid var(--c-border);
  }

  .results-main > .city-description { order: 6; }

  .sort-control select {
    width: 100%;
  }

  /* 2026-08-08: .event-card's base 262px height (see that rule's own
     comment) is sized for DESKTOP's multi-column grid, where every
     card needs to match its row neighbours' height. Mobile's grid is
     single-column (.results below), so that justification doesn't
     apply -- carrying the same height over just wasted space below a
     typical short title, pushing fewer events into view per scroll.
     Still a fixed height, not auto -- every mobile card stays the
     same height as every other (just a smaller one), rather than each
     sizing to its own content and reading as a ragged, uneven list.
     The title's line-clamp is tightened from 3 to 2 lines to match:
     the clamp only controls where "…" truncation kicks in, not the
     actual space reserved (that's the fixed height alone) -- leaving
     it at 3 here would let a genuinely 3-line title's real text spill
     past this shorter card and get clipped by its own overflow:
     hidden, cutting off the venue/time/genre/actions below it. */
  .event-card {
    height: 236px;
    padding: 18px;
    gap: 6px;
  }

  .event-card .event-name {
    -webkit-line-clamp: 2;
  }

  /* Every remaining tappable control gets a real 48x48 hit area --
     the event card link, quick/genre filter pills, and pagination
     links previously relied on text-sized padding alone.
     display: block (not flex) deliberately -- .event-name's own
     -webkit-line-clamp (see that rule) requires its content to stay
     in normal block/inline flow to count line boxes correctly; a flex
     child broke that, letting a long title escape the 2-line clamp
     specifically on mobile. min-height alone still guarantees the
     48px tap target without a flex context -- no negative margin to
     compensate for it: this is the very first element inside the
     card (right after its own top padding), and a negative top margin
     here pulled the title up far enough to clip its first line
     against the card's own overflow: hidden edge, confirmed live on a
     real mobile viewport. A short single-line title just gets a
     little extra breathing room around it instead. */
  .event-card .event-name a {
    display: block;
    min-height: 48px;
  }

  .quick-filter,
  .genre-pill {
    min-height: 48px;
    display: inline-flex;
    align-items: center;
    box-sizing: border-box;
  }

  .form-input,
  select.form-input {
    min-height: 48px;
    padding: 14px 16px;
    box-sizing: border-box;
  }

  .sort-control select {
    min-height: 48px;
    padding: 14px 32px 14px 16px;
    box-sizing: border-box;
  }

  .button {
    min-height: 48px;
    padding: 14px 24px;
    box-sizing: border-box;
  }

  /* Pagination becomes a full-width stack of large thumb-friendly
     buttons instead of small inline links -- this app renders pages
     server-side (no client-side fetch/append), so a page change is a
     normal navigation and never causes the jumpy-footer reflow an
     appended "Load More" list can cause. */
  .pagination {
    flex-direction: column;
    align-items: stretch;
    padding: 16px 0 0;
  }

  .pagination a,
  .pagination span {
    min-height: 48px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: 100%;
  }

  /* Blur is comparatively expensive on lower-powered mobile GPUs --
     keep the glow atmosphere but cut its cost on small screens. */
  body::before {
    filter: blur(36px);
  }

  /* Reserves room for .header-links' fixed bottom bar so it never
     covers the last real content on the page (the site footer) once
     scrolled all the way down -- fixed-position elements don't push
     on document flow the way an in-flow element would. Verify live
     against .header-links' actual rendered height if either changes. */
  body {
    padding-bottom: 60px;
  }
}
