/* ==========================================================================
   CRAWLLOWEEN components
   Nav, footer, buttons, night selector, neighborhood cards, price ladder,
   proof strip, gallery, reviews, drop modal, forms.

   Depends on tokens.css and base.css. Never hardcode a value here.

   Naming: shared components are unprefixed (.btn, .card). Page-specific
   blocks get a page prefix in their own stylesheet (.home__reel, .crawl__route).
   ========================================================================== */

/* ================================================================== nav === */

.site-nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: var(--z-nav);
  height: var(--nav-h);
  display: flex;
  align-items: center;
  background: transparent;
  transition:
    background var(--dur-base) var(--ease-standard),
    transform var(--dur-base) var(--ease-standard),
    border-color var(--dur-base) var(--ease-standard);
  border-bottom: 1px solid transparent;
}

/* motion.js sets exactly these two. Do not invent others. */
.site-nav.is-stuck {
  background: var(--veil-nav);
  backdrop-filter: blur(14px);
  border-bottom-color: var(--line);
}

.site-nav.is-hidden {
  transform: translateY(-100%);
}

.site-nav__inner {
  width: 100%;
  max-width: var(--max);
  margin-inline: auto;
  padding-inline: var(--gutter);
  display: flex;
  align-items: center;
  gap: var(--sp-5);
}

.site-nav__brand {
  display: flex;
  align-items: center;
  /* The wordmark is a link home and was 30px tall inside a 68px bar, so it read
     as a comfortable target and audited as a failing one. Centred in the bar, so
     this adds height to the hit box and moves nothing. */
  min-height: var(--tap-min);
  gap: var(--sp-2);
  text-decoration: none;
  font-family: var(--font-display);
  font-weight: var(--fw-black);
  font-size: 1.15rem;
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  margin-right: auto;
}

/* The wordmark in the bar, at 46px in a 68px bar. Matched to the proportion the
   Bizzy Nights build uses (58px in a ~72px bar), where the logo is recognised as a
   mark rather than read as a word, which is the right job for a corner lockup.

   Served from the 320px rendition, not the 640px one: displayed near 94px wide, 320
   source pixels is over 3x density, so it stays crisp on a retina screen while
   costing 19KB instead of 48KB.

   line-height: 0 on the anchor stops the inline box adding a few phantom pixels
   under the image and pushing the bar's optical centre off. */
.site-nav__brand { line-height: 0; }

.site-nav__brand picture,
.site-nav__brand img {
  display: block;
  height: 46px;
  width: auto;
}

@media (max-width: 860px) {
  /* Smaller on a phone so it never crowds the Menu button. */
  .site-nav__brand img { height: 38px; }
}

.site-nav__links {
  display: flex;
  align-items: center;
  gap: var(--sp-5);
}

.site-nav__links a {
  text-decoration: none;
  font-size: var(--fs-sm);
  font-weight: var(--fw-semi);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-dim);
  transition: color var(--dur-fast) var(--ease-standard);
}

.site-nav__links a:hover,
.site-nav__links a[aria-current="page"] { color: var(--text); }

.nav-toggle {
  display: none;
  background: none;
  border: var(--border);
  border-radius: var(--r-sm);
  padding: var(--sp-2) var(--sp-3);
  /* The only way into the nav on a phone, and it audited at 38.7px tall. */
  min-height: var(--tap-min);
  color: var(--text);
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  cursor: pointer;
}

.nav-drawer {
  position: fixed;
  inset: 0;
  z-index: var(--z-drawer);
  background: var(--bg);
  padding: calc(var(--nav-h) + var(--sp-6)) var(--gutter) var(--sp-6);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  overflow-y: auto;
}

/* `display: flex` above beats the UA stylesheet's `[hidden] { display: none }`,
   so the drawer needs the same explicit guard that .lightbox, .drop-modal,
   .night-panel and .countdown already carry. Without it the drawer ships open
   over every page. */
.nav-drawer[hidden] { display: none; }

.nav-drawer a {
  text-decoration: none;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  text-transform: uppercase;
  letter-spacing: var(--track-tight);
  padding-block: var(--sp-3);
  border-bottom: 1px solid var(--line);
}

.nav-drawer__close {
  position: absolute;
  top: var(--sp-4);
  right: var(--gutter);
  background: none;
  border: var(--border);
  border-radius: var(--r-sm);
  color: var(--text);
  padding: var(--sp-2) var(--sp-3);
  cursor: pointer;
}

/* Six nav items plus a CTA is wide, and between the drawer breakpoint and about
   1100px it was wrapping "Pick your crawl" onto two lines and pushing the bar to
   double height. Tighter type and gap in that band keeps it on one line rather than
   moving the breakpoint, which would take the links away from tablets that have
   room for them. */
@media (min-width: 861px) and (max-width: 1100px) {
  .site-nav__links { gap: var(--sp-4); }
  .site-nav__links a { font-size: var(--fs-xs); letter-spacing: var(--track-normal); }
  .site-nav__inner { gap: var(--sp-4); }
}

@media (max-width: 860px) {
  .site-nav__links { display: none; }
  .nav-toggle { display: block; }
}

/* ============================================================== buttons === */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-5);
  border: 1px solid transparent;
  border-radius: var(--r-sm);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  text-decoration: none;
  text-align: center;
  cursor: pointer;
  transition:
    background var(--dur-fast) var(--ease-standard),
    border-color var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard),
    transform var(--dur-fast) var(--ease-release);
}

.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }

.btn--primary {
  background: var(--accent);
  color: var(--text-on-accent);
  box-shadow: var(--sh-accent);
}
.btn--primary:hover { background: var(--accent-deep); color: var(--bone); }

/* ------------------------------------------------------------- torn edge ---
   The ripped-tape button shape, taken from the Scare City reference. The tear is
   a clip-path polygon in tokens.css, so it costs no image and stretches to any
   button width.

   THE TEAR IS ON A CHILD, NOT ON THE BUTTON. clip-path clips everything the
   element paints, and that includes the focus ring, so putting the polygon on the
   button itself silently deletes the keyboard indicator. The polygon therefore
   goes on an ::before that carries the background, and the button keeps its own
   outline, which is free to paint outside the torn shape.

   The consequence is that .btn--tear must override .btn--primary's background
   rather than layer over it, or the untorn rectangle shows through behind the
   tear. Same for the box-shadow: a shadow follows the border box, not the clip,
   so it would draw a clean rectangle around a ragged shape. */
.btn--tear {
  position: relative;
  background: transparent;
  box-shadow: none;
  border-color: transparent;
  /* Slightly more room top and bottom, because the tear bites into both edges. */
  padding-block: calc(var(--sp-3) + 3px);
}

.btn--tear::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--accent);
  clip-path: var(--tear-a);
  transition: background var(--dur-fast) var(--ease-standard);
}

.btn--tear:hover { background: transparent; }
.btn--tear:hover::before { background: var(--accent-deep); }

/* Alternating the polygon stops a row of torn buttons reading as a repeated
   logo instead of as torn paper. */
.btn--tear:nth-of-type(even)::before { clip-path: var(--tear-b); }

/* z-index: -1 on the ::before puts it behind the button's own text, but it also
   puts it behind an opaque ancestor background unless the button establishes its
   own stacking context. isolation does that without side effects. */
.btn--tear { isolation: isolate; }

.btn--ghost {
  background: transparent;
  border-color: var(--line-strong);
  color: var(--text);
}
.btn--ghost:hover { border-color: var(--accent); color: var(--accent); }

.btn--quiet {
  background: transparent;
  color: var(--text-dim);
  padding-inline: 0;
}
.btn--quiet:hover { color: var(--accent); }

.btn--lg { padding: var(--sp-4) var(--sp-6); font-size: var(--fs-body); }
.btn--full { width: 100%; }

.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }

/* =============================================================== tags ===== */

.tag {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  padding: var(--sp-1) var(--sp-3);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-pill);
  font-size: var(--fs-micro);
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}

.tag--accent {
  border-color: var(--accent);
  color: var(--accent);
}

.tag--solid {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--text-on-accent);
}

/* ========================================================= proof strip ====

   Real 2025 numbers. Competitors write "Hundreds of Crawlers"; every figure
   here traces to DATA.md 9.
   ======================================================================== */

.proof {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(50%, 150px), 1fr));
  gap: var(--sp-5);
  padding-block: var(--sp-6);
  border-block: var(--border);
}

.proof__item { text-align: center; }

.proof__num {
  display: block;
  font-family: var(--font-display);
  font-size: clamp(2.4rem, 6vw, 3.6rem);
  font-weight: var(--fw-black);
  line-height: 1;
  letter-spacing: var(--track-tight);
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

.proof__label {
  display: block;
  margin-top: var(--sp-2);
  font-size: var(--fs-xs);
  font-weight: var(--fw-semi);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}

/* ====================================================== night selector ====

   The core navigation. Three nights, five neighborhoods, one decision.

   The panels are NOT hidden here. site.js hides the inactive ones on init, so
   with JS off every night's cards are visible and the tabs act as jump links.
   ======================================================================== */

.nights {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-bottom: var(--sp-6);
}

.night-tab {
  flex: 1 1 auto;
  min-width: 150px;
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  padding: var(--sp-4) var(--sp-5);
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  color: var(--text-dim);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition:
    border-color var(--dur-fast) var(--ease-standard),
    background var(--dur-fast) var(--ease-standard),
    color var(--dur-fast) var(--ease-standard);
}

.night-tab:hover { border-color: var(--line-strong); color: var(--text); }

.night-tab.is-active {
  background: var(--bg-elev);
  border-color: var(--ember);
  color: var(--text);
  box-shadow: inset 0 -3px 0 0 var(--ember);
}

.night-tab__date {
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  font-weight: var(--fw-black);
  letter-spacing: var(--track-tight);
  text-transform: uppercase;
  color: inherit;
}

.night-tab__name {
  font-size: var(--fs-micro);
  font-weight: var(--fw-semi);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-faint);
}

.night-tab.is-active .night-tab__name { color: var(--ember); }

.night-panel[hidden] { display: none; }

/* ==================================================== neighborhood card ===

   Each card sets its own --accent via data-hood, so the whole card, its
   button and its glow follow that neighborhood without any per-card overrides.
   ======================================================================== */

.hood-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
  gap: var(--sp-5);
}

.hood-card {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--bg-elev);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  text-decoration: none;
  transition:
    border-color var(--dur-base) var(--ease-standard),
    transform var(--dur-base) var(--ease-standard),
    box-shadow var(--dur-base) var(--ease-standard);
}

/* The lift is a shadow pair, not a filter and not a scale on the card: the card
   is a grid item, so anything that changes its box reflows five neighbours.
   --sh-accent is the cast underneath, --sh-bloom is the halo in the card's own
   accent, and both come from the accent the card set via data-hood. */
.hood-card:hover,
.hood-card:focus-within {
  border-color: var(--accent);
  transform: translateY(-3px);
  box-shadow: var(--sh-accent), var(--sh-bloom);
}

.hood-card__media {
  position: relative;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  background: var(--bg-sunk);
}

.hood-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--dur-slower) var(--ease-standard);
}

.hood-card:hover .hood-card__media img { transform: scale(1.04); }

/* THE CARD ART NOW CARRIES ITS OWN TYPE, so this scrim had to pull back.

   It used to ramp from 58% down, which was right when the art was a wordmark-free
   flyer crop. The 2026 card headers have the neighborhood name burned into the
   lower third, and a veil starting at 58% ran straight through that lettering and
   greyed it out.

   The gradient now starts at 86%, which is below the type and still seats the 4px
   accent bar underneath it. If the art is ever recropped so the name sits higher,
   this number has to follow it.

   Nothing was ever cropping the name, and that is worth recording so nobody
   "fixes" it: the media box is aspect-ratio 16/10, the art is 1600x1000, and those
   are the same ratio, so object-fit: cover has nothing to trim. Changing either
   one without the other will start clipping the wordmark. */
.hood-card__media::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, transparent 0%, transparent 86%, var(--veil-modal) 100%);
}

/* The accent bar is how a returning buyer finds their neighborhood fast. */
.hood-card__media::before {
  content: '';
  position: absolute;
  inset: auto 0 0 0;
  height: 4px;
  z-index: 1;
  background: var(--accent);
  transition: box-shadow var(--dur-base) var(--ease-standard);
}

/* On hover the bar itself blooms. It is 4px of solid accent already sitting at
   the join between the art and the copy, so it is the right thing to light up
   rather than adding another overlay over the art. */
.hood-card:hover .hood-card__media::before,
.hood-card:focus-within .hood-card__media::before {
  box-shadow: 0 0 20px 2px var(--accent-glow);
}

/* The .hood-card__media--art variant and its .hood-card__char label were the
   stand-in for a card with nothing it was allowed to show. All six flyer crops
   are now on disk in assets/img/flyers, so every card carries a real <img> and
   both rules are deleted rather than left as dead CSS.

   The crops are illustration, not documentary photography (FLYER-MANIFEST.md),
   which is why they are cleared for the cards and the heroes and are kept out
   of the 2025 gallery. */

.hood-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-5);
  flex: 1;
}

.hood-card__name {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  letter-spacing: var(--track-tight);
  text-transform: uppercase;
  color: var(--text);
}

/* The card title is the only text route into a neighborhood page, and it audited
   at 26px tall on every phone width. inline-flex rather than block so the
   underline still hugs the words instead of running the full card width. */
.hood-card__name a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
}

/* Venue count, walk time, vibe. The specifics competitors omit. */
.hood-card__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4);
  font-size: var(--fs-xs);
  font-weight: var(--fw-semi);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-faint);
}

.hood-card__meta strong { color: var(--accent); font-weight: var(--fw-bold); }

.hood-card__vibe {
  font-size: var(--fs-sm);
  color: var(--text-dim);
  flex: 1;
}

.hood-card__foot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-4);
  margin-top: auto;
  padding-top: var(--sp-4);
  border-top: 1px solid var(--line);
}

/* Price on the card. No competitor does this. */
.hood-card__price {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}

.hood-card__price-now {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  line-height: 1;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

/* THE NIGHT CHIP, and it is deliberately loud.

   This was --fs-micro accent text with no plate, which put the single most
   mistakeable fact on the card at the smallest size on the card. Eighteen
   crawl-nights means the date is half of what is being bought, and it is chosen by
   a tab that has usually scrolled away, so it is now a bordered plate at --fs-xs
   sitting directly beside the button that acts on it.

   site.js writes the label into [data-night-out], so this always agrees with the
   selected tab. The drop modal then repeats it back in full long form. Three
   places, one source. */
.hood-card__price-tier {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--accent);
  border-radius: var(--r-sm);
  font-family: var(--font-display);
  font-size: var(--fs-xs);
  font-weight: var(--fw-black);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

/* ========================================================= price ladder ===

   The whole ladder, visible, with the next step named. This is differentiator
   number one and it is the section competitors do not have at all.
   ======================================================================== */

.ladder {
  border: var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
}

.ladder__row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: var(--sp-4);
  align-items: center;
  padding: var(--sp-4) var(--sp-5);
  border-bottom: 1px solid var(--line);
}

.ladder__row:last-child { border-bottom: 0; }

.ladder__row--past {
  color: var(--text-faint);
  background: var(--bg-sunk);
}
.ladder__row--past .ladder__price { text-decoration: line-through; }

.ladder__row--now {
  background: var(--bg-elev);
  box-shadow: inset 3px 0 0 0 var(--accent);
}

.ladder__row--next { color: var(--text-dim); }

.ladder__tier {
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  font-size: var(--fs-sm);
}

.ladder__when {
  font-size: var(--fs-xs);
  color: var(--text-faint);
  text-align: right;
}

.ladder__price {
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  font-weight: var(--fw-black);
  font-variant-numeric: tabular-nums;
  text-align: right;
  min-width: 4ch;
}

.ladder__row--now .ladder__price { color: var(--accent); }

@media (max-width: 560px) {
  .ladder__row {
    grid-template-columns: 1fr auto;
    row-gap: var(--sp-1);
  }
  .ladder__when { grid-column: 1 / -1; text-align: left; }
}

/* ============================================================ countdown ===

   Hidden by default. site.js only unhides it once it has confirmed the target
   is genuinely in the future, so this can never render a negative timer.
   ======================================================================== */

.countdown[hidden] { display: none; }

.countdown {
  display: flex;
  gap: var(--sp-4);
}

.countdown__cell { text-align: center; min-width: 3.5ch; }

.countdown__num {
  display: block;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  line-height: 1;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

.countdown__label {
  display: block;
  margin-top: var(--sp-1);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-faint);
}

/* =============================================================== cards ==== */

.card {
  background: var(--bg-elev);
  border: var(--border);
  border-radius: var(--r-lg);
  padding: var(--sp-5);
}

.card--accent { border-color: var(--accent); }

/* --sp-3 to its body copy, NOT --sp-2. Three components set an --fs-h4 title
   above a paragraph: this one, .notice__title and .steps__title. The other two
   both sit 12px off their copy and this one sat at 8px, so identical patterns
   shipped at two distances and the cards read tighter than the notices beside
   them on the same page. */
.card__title {
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  font-weight: var(--fw-black);
  letter-spacing: var(--track-tight);
  text-transform: uppercase;
  margin-bottom: var(--sp-3);
}

/* Numbered "what's included" items. */
.included {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
  gap: var(--sp-5);
}

.included__item {
  display: flex;
  gap: var(--sp-4);
  align-items: flex-start;
}

.included__num {
  flex: 0 0 auto;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  line-height: 1;
  color: var(--accent);
  opacity: 0.55;
  font-variant-numeric: tabular-nums;
}

/* ============================================================== route ====

   The venue list with its admission window. This level of detail is the
   "specificity over urgency" play, so it is a first-class component and not
   fine print.
   ======================================================================== */

.route {
  border: var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
}

.route__stop {
  display: grid;
  /* index, venue mark, name, door window. The mark column is added by site.js, and
     `auto` collapses to zero width when it is absent, so a no-script row keeps the
     original three-column layout with no gap where the plate would be. */
  grid-template-columns: auto auto 1fr auto;
  gap: var(--sp-4);
  align-items: baseline;
  padding: var(--sp-4) var(--sp-5);
  border-bottom: 1px solid var(--line);
}

.route__stop:last-child { border-bottom: 0; }

.route__index {
  font-family: var(--font-display);
  font-weight: var(--fw-black);
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  min-width: 2ch;
}

.route__name {
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-normal);
}

.route__addr {
  display: block;
  font-size: var(--fs-xs);
  font-weight: var(--fw-body);
  color: var(--text-faint);
  letter-spacing: var(--track-normal);
  text-transform: none;
}

.route__window {
  font-size: var(--fs-xs);
  font-weight: var(--fw-semi);
  color: var(--text-dim);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.route__specials {
  grid-column: 2 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-top: var(--sp-2);
}

.route__special {
  font-size: var(--fs-micro);
  padding: var(--sp-1) var(--sp-2);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  color: var(--text-dim);
}

@media (max-width: 560px) {
  .route__stop { grid-template-columns: auto auto 1fr; row-gap: var(--sp-1); gap: var(--sp-3); }
  .route__window { grid-column: 3; text-align: left; }
}

/* ============================================================= gallery ==== */

.gallery {
  display: grid;
  /* The 50% has to allow for the gap or two tracks overflow by one gap width
     and the grid silently drops to a single column on a phone, which turns a
     16-tile gallery into 7,500px of scrolling. */
  grid-template-columns: repeat(auto-fill, minmax(min(calc(50% - var(--sp-2)), 220px), 1fr));
  gap: var(--sp-3);
}

.gallery__item {
  position: relative;
  display: block;
  overflow: hidden;
  border-radius: var(--r-md);
  background: var(--bg-elev);
  border: 0;
  padding: 0;
  cursor: zoom-in;
  aspect-ratio: 3 / 4;
}

.gallery__item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition:
    transform var(--dur-slow) var(--ease-standard),
    opacity var(--dur-fast) var(--ease-standard);
}

.gallery__item:hover img { transform: scale(1.05); opacity: 0.88; }

/* A few tiles run tall so the grid does not read as a uniform contact sheet. */
.gallery__item--tall { aspect-ratio: 3 / 5; }
.gallery__item--wide { aspect-ratio: 3 / 2; grid-column: span 2; }

.lightbox {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--sp-5);
  background: var(--veil-lightbox);
}

.lightbox[hidden] { display: none; }

.lightbox img {
  max-width: min(100%, 1100px);
  max-height: 78vh;
  object-fit: contain;
  border-radius: var(--r-md);
}

.lightbox__caption {
  max-width: 60ch;
  text-align: center;
  font-size: var(--fs-sm);
  color: var(--text-dim);
}

.lightbox__close,
.lightbox__prev,
.lightbox__next {
  position: absolute;
  background: var(--veil-control);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-pill);
  color: var(--text);
  width: 46px;
  height: 46px;
  display: grid;
  place-items: center;
  cursor: pointer;
  font-size: 1.2rem;
}

.lightbox__close { top: var(--sp-5); right: var(--sp-5); }
.lightbox__prev  { left: var(--sp-4); }
.lightbox__next  { right: var(--sp-4); }

/* ============================================================= reviews ====

   Real reviews from real 2025 attendees. Critical reviews get the identical
   visual treatment to positive ones on purpose: differentiating them would
   undo the credibility the section exists to build. There is no star-rating
   graphic that could be mistaken for an aggregate we do not have.
   ======================================================================== */

.reviews {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
  gap: var(--sp-5);
}

.review {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-5);
  background: var(--bg-elev);
  border: var(--border);
  border-radius: var(--r-lg);
}

.review__rating {
  display: flex;
  gap: var(--sp-1);
  font-size: var(--fs-sm);
  color: var(--accent);
  letter-spacing: var(--track-wide);
}

.review__text {
  flex: 1;
  font-size: var(--fs-sm);
  color: var(--text);
}

.review__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-3);
  font-size: var(--fs-micro);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-faint);
}

.review__verified { color: var(--ok); }

/* Shown until real reviews land. Never replaced by invented copy. */
.reviews-empty {
  padding: var(--sp-6);
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-lg);
  text-align: center;
  color: var(--text-dim);
}

/* ========================================================== drop modal ==== */

.drop-modal {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: grid;
  place-items: center;
  padding: var(--sp-5);
  background: var(--veil-modal);
}

.drop-modal[hidden] { display: none; }

.drop-modal__card {
  position: relative;
  width: min(100%, 460px);
  background: var(--bg-elev);
  border: 1px solid var(--accent);
  border-radius: var(--r-lg);
  padding: var(--sp-6);
  box-shadow: var(--sh-lg);
  text-align: center;
}

.drop-modal__close {
  position: absolute;
  top: var(--sp-3);
  right: var(--sp-3);
  background: none;
  border: 0;
  color: var(--text-dim);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
  padding: var(--sp-2);
}
.drop-modal__close:hover { color: var(--text); }

.drop-modal__title {
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  font-weight: var(--fw-black);
  letter-spacing: var(--track-tight);
  text-transform: uppercase;
  margin-bottom: var(--sp-3);
}

.drop-modal iframe {
  width: 100%;
  border: 0;
  min-height: 340px;
  margin-top: var(--sp-4);
}

/* ============================================================ mobile cta ===

   Sticky buy bar. Only Crawl With US has one, and theirs points at the wrong
   Eventbrite page.
   ======================================================================== */

.mobile-cta {
  position: fixed;
  inset: auto 0 0 0;
  z-index: var(--z-nav);
  display: none;
  align-items: center;
  gap: var(--sp-4);
  padding: var(--sp-3) var(--gutter);
  background: var(--veil-bar);
  backdrop-filter: blur(14px);
  border-top: 1px solid var(--line);
  /* Clear the iOS home indicator. */
  padding-bottom: calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
}

.mobile-cta__price {
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  font-weight: var(--fw-black);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.mobile-cta__price small {
  display: block;
  font-family: var(--font-sans);
  font-size: var(--fs-micro);
  font-weight: var(--fw-semi);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--accent);
}

.mobile-cta .btn { flex: 1; }

@media (max-width: 860px) {
  .mobile-cta { display: flex; }

  /* Keep the sticky bar from covering the end of the page.
     THE SAFE-AREA INSET HAS TO BE IN BOTH PLACES. The bar adds
     env(safe-area-inset-bottom) to its own padding so it clears the home
     indicator, which makes the bar that much taller. A flat reservation here did
     not, so on any notched iPhone the bar grew by the inset and the last ~34px of
     the footer sat underneath it. Measured at 430x932: 72px bar against 76px of
     padding on paper, 106px against 76px in fact. */
  body { padding-bottom: calc(var(--cta-bar-h) + env(safe-area-inset-bottom, 0px)); }
}

/* =============================================================== forms ==== */

.field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}

.field label {
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}

.field input,
.field select,
.field textarea {
  padding: var(--sp-3) var(--sp-4);
  background: var(--bg-sunk);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  color: var(--text);
  transition: border-color var(--dur-fast) var(--ease-standard);
}

.field input:focus,
.field select:focus,
.field textarea:focus {
  border-color: var(--accent);
  outline: none;
}

.field .is-invalid { border-color: var(--err); }

.field__hint {
  font-size: var(--fs-xs);
  color: var(--text-faint);
}

/* Netlify Forms honeypot. Must never be visible or focusable. */
.honeypot {
  position: absolute;
  left: -9999px;
  opacity: 0;
  pointer-events: none;
}

/* ================================================================ FAQ ===== */

/* THE PADDING LIVES ON THE SUMMARY, NOT ON THE ITEM. Only the <summary> is
   clickable, so padding on .faq__item put 16px of dead space above and below
   every question and left a 25.9px tap target inside a 57.9px row. Moving the
   same two values onto the summary makes the whole row tappable and renders
   identically when closed. */
.faq__item {
  border-bottom: 1px solid var(--line);
}

/* details/summary so the answers are in the DOM for search and readable with
   JS off. No accordion script needed. */
.faq__item summary {
  cursor: pointer;
  font-weight: var(--fw-bold);
  font-size: var(--fs-body);
  list-style: none;
  display: flex;
  justify-content: space-between;
  gap: var(--sp-4);
  padding-block: var(--sp-4);
  min-height: var(--tap-min);
}

.faq__item summary::-webkit-details-marker { display: none; }

.faq__item summary::after {
  content: '+';
  color: var(--accent);
  font-family: var(--font-display);
  font-size: var(--fs-h4);
  line-height: 1;
}

.faq__item[open] summary::after { content: '\2013'; }

/* The summary's own bottom padding is now the gap above the answer, so this no
   longer carries a margin-top. It does carry the bottom padding the item used
   to, so an open answer still clears the row rule. */
.faq__item > *:not(summary) {
  padding-bottom: var(--sp-4);
  color: var(--text-dim);
  font-size: var(--fs-sm);
  max-width: 70ch;
}

/* ============================================================== footer ==== */

.site-footer {
  padding-block: var(--sp-8) var(--sp-6);
  border-top: var(--border);
  background: var(--bg-sunk);
}

.site-footer__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
  gap: var(--sp-6);
  margin-bottom: var(--sp-7);
}

.site-footer h3 {
  font-size: var(--fs-xs);
  font-family: var(--font-sans);
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-widest);
  text-transform: uppercase;
  color: var(--text-faint);
  margin-bottom: var(--sp-3);
}

.site-footer a {
  text-decoration: none;
  color: var(--text-dim);
  font-size: var(--fs-sm);
  transition: color var(--dur-fast) var(--ease-standard);
}
.site-footer a:hover { color: var(--accent); }

/* Footer navigation is the fallback route to every page on the site, and on a
   phone it is a column of 19px-tall links. The hit box has to be the whole row,
   so the link becomes a flex box at the tap minimum and the list drops the
   margin that was doing the separating. Pitch goes from 27px to 44px: the links
   look about the same, the gaps between them stop being dead pixels.

   Scoped to the two nav columns rather than `.site-footer a`, so the inline
   links in the legal strip stay inline and do not each claim a 44px row. */
.site-footer__grid li a {
  display: flex;
  align-items: center;
  min-height: var(--tap-min);
}

.site-footer__grid li + li { margin-top: 0; }

/* A 44px row centres a 19px line, so each link now carries about --sp-3 of its
   own leading. Left alone, the heading's --sp-3 stacks on top of that and a
   column heading sits 25px off its first link but 12px off a first paragraph, from
   markup that reads the same. So the heading drops its margin where a list
   follows and keeps it where a paragraph does. Both land at --sp-3 optically. */
.site-footer__grid h3 { margin-bottom: 0; }
.site-footer__grid h3 + p { margin-top: var(--sp-3); }

/* space-between worked while this held exactly two paragraphs. It now holds four,
   so the row aligns to the start and the copyright line takes the full width at the
   end of it. Without that, four items distributed across a wide footer read as an
   unrelated set of links rather than as one contact block. */
.site-footer__legal {
  padding-top: var(--sp-5);
  border-top: 1px solid var(--line);
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-5);
  align-items: center;
  justify-content: flex-start;
  font-size: var(--fs-xs);
  color: var(--text-faint);
}

/* The 21+ notice is a footer fixture, not an afterthought. */
.site-footer__age {
  font-weight: var(--fw-bold);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--text-dim);
}

/* ================================================================ drip ====

   The one motif every flyer shares is a dripping wordmark, so this is that
   reduced to a section-heading mark: a 2px accent rule with four teardrops
   hanging off it. Purely decorative. The markup carries aria-hidden and no
   text, so nothing here reaches a screen reader.

   THE BOX RESERVES THE LONGEST DROP. The rule is a ::before pinned to the top
   of a box whose height equals the longest drop, rather than a 2px element with
   drops overhanging it. That is the difference between a mark that can never
   shift layout and one that grows into the paragraph below it, and adjacent
   sibling margins collapse, so the spacing route does not work.

   Lengths, offsets and delays are all deliberately uneven. Four evenly spaced
   drops of one length read as a dotted border, not as a drip.
   ======================================================================== */

.drip {
  position: relative;
  display: block;
  width: min(100%, 190px);
  /* The longest stem plus the bead's overhang below it. */
  height: calc(var(--sp-5) + var(--sp-2));
  pointer-events: none;
}

.drip::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 2px;
  border-radius: var(--r-pill);
  background: var(--accent);
}

/* The stem is 6px against a 2px rule, and the bead is 10px against the 6px
   stem. Those two ratios are the whole thing: a 3px stem under a 2px rule with
   a 7px bead reads as a clothesline with pegs on it, because the bead is wider
   than its stem by more than it is long. Sitting the bead only 4px below the
   stem's end merges the two silhouettes into one falling drop. */
.drip > span {
  --drip-len: var(--sp-3);
  position: absolute;
  top: 2px;
  width: 6px;
  height: var(--drip-len);
  border-radius: 0 0 3px 3px;
  background: var(--accent);
}

/* The bead is anchored to the stem's own bottom edge, so it travels down with
   the stem as it lengthens and needs no animation of its own. */
.drip > span::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -4px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--accent);
  transform: translateX(-50%);
}

.drip > span:nth-child(1) { left: 7%;  --drip-len: var(--sp-3); }
.drip > span:nth-child(2) { left: 27%; --drip-len: var(--sp-5); }
.drip > span:nth-child(3) { left: 54%; --drip-len: var(--sp-2); }
.drip > span:nth-child(4) { left: 81%; --drip-len: var(--sp-4); }

/* The drops lengthen and fade in on reveal.

   The pre-state is gated on .js-motion AND on the drip's own .is-in, which is
   why the element carries its own data-reveal="fade" rather than leaning on the
   .section-head around it. A drip inside a heading that is not a reveal target
   would otherwise never receive .is-in and would sit at zero length forever. */
@media (prefers-reduced-motion: no-preference) {
  .js-motion .drip > span {
    height: 0;
    opacity: 0;
    transition:
      height var(--dur-slower) var(--ease-entrance),
      opacity var(--dur-slow) var(--ease-standard);
  }

  .js-motion .drip.is-in > span {
    height: var(--drip-len);
    opacity: 1;
  }

  .js-motion .drip.is-in > span:nth-child(1) { transition-delay: var(--dur-instant); }
  .js-motion .drip.is-in > span:nth-child(2) { transition-delay: var(--dur-base); }
  .js-motion .drip.is-in > span:nth-child(3) { transition-delay: var(--dur-fast); }
  .js-motion .drip.is-in > span:nth-child(4) { transition-delay: var(--dur-slow); }
}

/* Inside a section head the drip is just another child, so it picks up the
   sp-4 rhythm from .section-head > * + * and needs no spacing of its own. */

/* ============================================================= marquee ====

   One line, the six neighborhoods, each in its own accent, each a link. The
   accent comes from data-hood on the link itself, so the strip reads correctly
   on a neighborhood page where <body> has already set a single accent.

   The content is duplicated and the copy carries aria-hidden="true", so the
   loop is seamless visually and a screen reader hears six names once.
   ======================================================================== */

/* The band's own padding drops from --sp-4 to --sp-2 because the links inside it
   now carry the tap minimum themselves. Band height goes 53.6px to 60px, so the
   strip reads the same and every name is a real target. */
.marquee-band {
  overflow: hidden;
  padding-block: var(--sp-2);
  background: var(--bg-sunk);
  border-block: var(--border);
}

.marquee__track {
  display: flex;
  width: max-content;
}

.marquee__set {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
}

.marquee__item {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
}

/* A mark after EVERY name rather than between pairs. The loop seam joins the
   last name of one set to the first name of the next, and an `+ li::before`
   rule leaves exactly that one junction unmarked. */
.marquee__item::after {
  content: '\00b7';
  margin-inline: var(--sp-5);
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  line-height: 1;
  color: var(--text-faint);
}

.marquee__link {
  /* A 21.6px tall link on a strip that is moving is the hardest target on the
     site to hit. inline-flex rather than block so the strip stays one line. */
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-min);
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  line-height: 1;
  letter-spacing: var(--track-tight);
  text-transform: uppercase;
  white-space: nowrap;
  text-decoration: none;
  color: var(--accent);
  transition: text-shadow var(--dur-fast) var(--ease-standard);
}

.marquee__link:hover,
.marquee__link:focus-visible { text-shadow: 0 0 24px var(--accent-glow); }

/* The animation is inside no-preference rather than relying on the reduced
   motion clamp in tokens.css: that clamp takes --dur-marquee to 0.01ms, which
   would spin this strip rather than stop it. With motion off the first set
   sits still and the duplicate stays clipped. */
@media (prefers-reduced-motion: no-preference) {
  .marquee__track {
    animation: marquee-run var(--dur-marquee) linear infinite;
  }

  .marquee-band:hover .marquee__track,
  .marquee-band:focus-within .marquee__track { animation-play-state: paused; }
}

/* -50% is one full set, because the track holds exactly two identical sets. */
@keyframes marquee-run {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* ========================================================== video band ====

   ONE full-bleed band on the home page carries the skyline loop. Same contract
   as the index hero: the <source media> query holds the desktop-only split with
   JS off, motion.js strips the src under 861px, under save-data and under
   reduced motion, and the still underneath is what renders in every one of
   those cases. Two autoplaying videos on one page is a battery and bandwidth
   problem, so this stays the only other one.
   ======================================================================== */

.band {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: var(--bg-sunk);
}

/* OVERSCAN, and it is not cosmetic. data-parallax translates this box on the Y
   axis, and at 0.04 over a band this tall the travel reaches about 40px each
   way. A box at inset:0 would slide off its own section and expose a 40px strip
   of --bg-sunk at the top or the bottom edge, which reads as a rendering fault
   because this band has bright cloud in it. The extra sp-8 top and bottom is
   what the transform eats into. The section clips the rest. */
.band__media {
  position: absolute;
  inset: calc(var(--sp-8) * -1) 0;
  z-index: -2;
}

.band__video,
.band__still {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.band__video { z-index: 1; }
.band__still { z-index: 0; }

/* motion.js adds this when it declines to play. Drop the element so the still
   underneath renders rather than a black poster box. */
.band__video.is-poster-only { display: none; }

@media (max-width: 860px) {
  .band__video { display: none; }
}

.band__scrim {
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--scrim-band);
}

/* The content has to opt into a layer above the two negative ones. */
.band > .wrap {
  position: relative;
  z-index: 0;
}

/* The footer contact pair, @handle and the phone number, are the two ways to
   reach the business from a phone and they sit on their own line rather than
   inside a sentence. So unlike the inline links in body copy, they are NOT
   exempt from the target-size minimum: tapping to call is a real action, and at
   19px tall it was a miss-prone one.
   
   Inline links inside a paragraph are deliberately left alone. Padding those
   would break the line box, and the target-size rule exempts them for exactly
   that reason. */
.site-footer__legal a {
  display: inline-block;
  min-height: 44px;
  padding-block: var(--sp-3);
  /* Pull the added height back out of the flow so the footer does not grow. */
  margin-block: calc(var(--sp-3) * -1);
}

/* The sticky bar tucks away while the hero's own CTA is on screen.
   
   At 375px the bar is up to 92px tall and the hero CTA row can sit directly
   behind it, so on groups.html a ghost button was almost entirely covered at
   scroll 0. Nothing was permanently trapped, since scrolling frees it and the bar
   offers the same action, but a button you cannot press is still a button you
   cannot press.
   
   site.js drives this with an IntersectionObserver on the hero actions, NOT a
   scroll listener. The project rule forbids scroll listeners; observers are what
   motion.js already uses everywhere. */
.mobile-cta {
  transition: transform var(--dur-base) var(--ease-standard);
}

.mobile-cta.is-tucked {
  transform: translateY(100%);
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .mobile-cta { transition: none; }
}

/* ============================================================== soundtrack ==

   A Spotify embed. Third-party iframe, so it is lazy and it sits well below the
   fold: it must never compete with the hero for the LCP.

   The embed is the ONLY third-party frame on the site besides drop capture, and
   it is the reason `_headers` cannot tighten to a strict frame-src without
   allowlisting open.spotify.com. Noted so nobody tightens it and breaks this.
   ======================================================================== */

.soundtrack {
  display: grid;
  gap: var(--sp-5);
  align-items: center;
}

@media (min-width: 861px) {
  .soundtrack { grid-template-columns: 1fr 1.15fr; gap: var(--sp-7); }
}

.soundtrack__frame {
  border: var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
  background: var(--bg-elev);
  /* Reserve the embed's height so it cannot shift layout when it loads. */
  min-height: 352px;
}

.soundtrack__frame iframe {
  display: block;
  width: 100%;
  height: 352px;
  border: 0;
}

/* ==========================================================================
   THE SOUNDTRACK DOCK

   A floating Spotify player that arrives shortly after the visitor does. Bottom
   right, and a pill on phones.

   WHY BOTTOM RIGHT AND NOT BOTTOM LEFT. Left was the first choice, on the
   reasoning that the sticky ticket bar owns the bottom edge and the drawer button
   owns the top right. That was wrong, and visibly so: every hero on this site is
   left-aligned, so a panel in the bottom-left corner landed directly on top of the
   hero's primary ticket CTA and the route link beside it. A player that covers the
   buy button is worse than no player. The right half of the hero is artwork, so
   that is where a persistent panel can sit without costing anything.

   WHY IT SITS BELOW THE DRAWER IN THE STACK. --z-dock is under --z-drawer on
   purpose: if the two ever overlap, a nav that cannot be closed is a worse
   failure than a player that is briefly covered.
   ========================================================================== */

/* Collapsed, the dock shrinks to its own content on every screen size, not only on
   phones. It defaults to collapsed now, so this IS the resting appearance: a small
   pill rather than a 340px panel sitting in the corner of an unread page. --dock-w
   only applies once it is open. */
.dock:not(.is-open) { width: auto; }

.dock {
  position: fixed;
  right: var(--gutter);
  /* No sticky bar on desktop: .mobile-cta is display:none until 860px, so the
     dock only has to clear it inside the phone block below. */
  bottom: calc(var(--sp-5) + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-dock);
  width: min(var(--dock-w), calc(100vw - var(--gutter) * 2));
  background: var(--bg-elev);
  border: 1px solid var(--line-strong);
  /* The torn top edge, so the dock reads as the same family of shapes as the
     buttons. Only the top is torn: tearing all four sides of a panel that holds a
     third-party iframe would clip the player's own controls. */
  border-radius: var(--r-sm);
  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.66);
  overflow: hidden;
  /* Off-screen and inert until site.js opens it. */
  transform: translate3d(0, calc(100% + var(--sp-6)), 0);
  opacity: 0;
  transition:
    transform var(--dur-slower) var(--ease-entrance),
    opacity var(--dur-slow) var(--ease-entrance);
}

.dock[hidden] { display: none; }

.dock.is-up {
  transform: translate3d(0, 0, 0);
  opacity: 1;
}

/* Steps aside while the soundtrack SECTION is on screen. That section carries the
   same playlist in a full-size embed, so leaving the dock up puts two copies of one
   player on screen at once, which reads as a duplicate rather than as a feature.
   Needs both classes to beat .is-up, which sets the transform it is undoing. */
.dock.is-up.is-away {
  transform: translate3d(0, calc(100% + var(--sp-6)), 0);
  opacity: 0;
  pointer-events: none;
}

/* ------------------------------------------------------------------ bar --- */

.dock__bar {
  display: flex;
  align-items: stretch;
  border-bottom: 1px solid transparent;
  transition: border-color var(--dur-fast) var(--ease-standard);
}

.dock.is-open .dock__bar { border-bottom-color: var(--line); }

.dock__toggle {
  flex: 1;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  min-height: var(--tap-min);
  padding: var(--sp-3) var(--sp-4);
  background: none;
  border: 0;
  color: var(--text);
  font-family: var(--font-type);
  font-size: var(--fs-xs);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  text-align: left;
  cursor: pointer;
}

.dock__toggle:hover { color: var(--accent); }

.dock__close {
  flex: 0 0 auto;
  width: var(--tap-min);
  min-height: var(--tap-min);
  background: none;
  border: 0;
  color: var(--text-faint);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
}

.dock__close:hover { color: var(--text); }

/* ---------------------------------------------------------- equaliser --- */

/* Four bars that bounce, so the collapsed pill still reads as audio. Purely
   decorative: it is not driven by playback, because a cross-origin Spotify iframe
   exposes no playback state to the parent page at all. It would be dishonest to
   imply otherwise, which is why it animates only while the dock is OPEN and the
   visitor could plausibly have started something. */
.dock__eq {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  width: 14px;
  height: 14px;
  flex: 0 0 auto;
}

.dock__eq i {
  flex: 1;
  background: var(--accent);
  height: 30%;
}

@media (prefers-reduced-motion: no-preference) {
  .dock.is-open .dock__eq i { animation: dock-eq 1.1s ease-in-out infinite; }
  .dock.is-open .dock__eq i:nth-child(2) { animation-delay: -0.35s; }
  .dock.is-open .dock__eq i:nth-child(3) { animation-delay: -0.7s; }
  .dock.is-open .dock__eq i:nth-child(4) { animation-delay: -0.15s; }

  @keyframes dock-eq {
    0%, 100% { height: 25%; }
    50%      { height: 100%; }
  }
}

/* ----------------------------------------------------------------- body --- */

/* Collapsed by animating max-height rather than toggling display, so the panel
   slides instead of jumping. 172px clears Spotify's compact player at 152px plus
   its own padding. */
.dock__body {
  max-height: 0;
  overflow: hidden;
  transition: max-height var(--dur-slower) var(--ease-standard);
}

.dock.is-open .dock__body { max-height: 172px; }

.dock__body iframe {
  display: block;
  width: 100%;
  height: 152px;
  border: 0;
}

/* ---------------------------------------------------------------- phone --- */

/* ---------------------------------------------------------------- phone --- */

/* 860px, not 700px: that is where .mobile-cta appears, and the dock's whole
   vertical position depends on whether that bar is on screen. Splitting those two
   breakpoints would leave a band of widths where the dock sits on top of the buy
   bar. */
@media (max-width: 860px) {
  .dock {
    /* Clear the sticky buy bar. That bar sells tickets and the player does not,
       so the player is what moves. */
    bottom: calc(var(--cta-bar-h) + var(--sp-2) + env(safe-area-inset-bottom, 0px));
    /* Collapsed, it shrinks to its own content rather than taking a fixed 15rem.
       A floating panel unavoidably covers whatever is under it, so on the smallest
       screens the honest fix is to make it small: auto width takes it from about
       61% of a 390px viewport down to under half, which is the difference between
       obscuring a pair of night tabs and sitting beside them. It is also
       collapsible and dismissible, which is the rest of the answer. */
    width: auto;
    max-width: calc(100vw - var(--gutter) * 2);
    /* Stays pinned right so the collapsed pill does not drift across the screen
       between breakpoints. */
    right: var(--gutter);
  }

  /* The toggle stops stretching, so the bar is only as wide as its label. */
  .dock__toggle { flex: 0 1 auto; }

  /* Full width once opened, because the player itself needs the room. */
  .dock.is-open { width: calc(100vw - var(--gutter) * 2); }
  .dock.is-open .dock__toggle { flex: 1; }

  .dock__label { font-size: var(--fs-micro); }

  .dock { transition:
            transform var(--dur-slower) var(--ease-entrance),
            opacity var(--dur-slow) var(--ease-entrance),
            width var(--dur-base) var(--ease-standard); }

  /* THE DOCK DOES NOT DROP WHEN THE BUY BAR TUCKS.
     It used to, so that no gap was left under it. That put the pill straight on
     top of the hero's secondary CTA at 390px, because the buy bar is tucked for
     exactly as long as the hero CTAs are on screen. Holding the dock at the bar's
     height leaves a small empty strip below it while the bar is away, which nobody
     will notice, instead of covering a link, which they will. */
}

/* The contact row inside the footer legal block. Wraps to its own line so the
   address and entity notice never sit mid-sentence beside a phone number. */
.site-footer__contact {
  flex: 1 1 100%;
  color: var(--text-dim);
}

.site-footer__contact a { color: var(--text); }
.site-footer__contact a:hover { color: var(--accent); }

/* ------------------------------------------------------------ venue marks ---
   A mark beside each named venue on a route. site.js builds these from the venue
   manifest in config.js; the markup ships without them because they are decoration
   and every fact in the row is already real text.

   The plate takes the venue's OWN neighborhood accent via data-hood, not the
   page's. On the homepage both 2025 routes are listed together, so River North
   stops read orange and West Loop stops read red in the same column, which is the
   same colour key the rest of the site navigates by.

   Logo-ready: .route__mark-img covers the monogram when a file is supplied, and
   site.js removes the img on error so a missing file falls back rather than
   leaving a hole. */
.route__mark {
  position: relative;
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  border: 1px solid var(--accent);
  border-radius: var(--r-sm);
  background: var(--bg-elev);
  overflow: hidden;
  /* The row is baseline-aligned, so without this the plate hangs below the text. */
  align-self: center;
}

.route__mark-text {
  font-family: var(--font-display);
  font-weight: var(--fw-black);
  font-size: 0.95rem;
  letter-spacing: var(--track-normal);
  color: var(--accent);
  line-height: 1;
}

.route__mark[data-long] .route__mark-text { font-size: 0.72rem; }

.route__mark-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  padding: 4px;
  background: var(--bg-elev);
}

@media (max-width: 560px) {
  .route__mark { width: 34px; height: 34px; }
  .route__mark-text { font-size: 0.8rem; }
  .route__mark[data-long] .route__mark-text { font-size: 0.62rem; }
}

/* --------------------------------------------------- what you are buying ---
   The line the drop modal opens with: neighborhood, then the date in full.

   Set large and in the accent rather than as a quiet caption, because it is the
   only place in the funnel that names what was chosen. Eighteen crawl-nights
   means the night came from a tab that has already scrolled out of view, so this
   is a buyer's only chance to notice they are about to sign up for the wrong one.

   Long-form date on purpose. "Sat Oct 24" is how the tabs read, and it is the
   abbreviation people misread; "Saturday, October 24, 2026" cannot be mistaken
   for the 30th, and it carries the year, which matters on a page that also talks
   about 2025 constantly. */
.drop-modal__what {
  font-family: var(--font-display);
  font-weight: var(--fw-black);
  font-size: var(--fs-h4);
  line-height: var(--lh-snug);
  letter-spacing: var(--track-normal);
  text-transform: uppercase;
  color: var(--accent);
  padding-bottom: var(--sp-3);
  margin-bottom: var(--sp-3);
  border-bottom: 1px solid var(--line);
  /* Room for the close button, which is positioned in the same top-right corner. */
  padding-right: var(--sp-6);
}

.drop-modal__what[hidden] { display: none; }
