:root {
  --color-bg: #0A0A0A;
  --color-text: #C5BFAE;
  --color-accent: #B8FF00;
  /* V66: bureaucracy red (report-to-311, closure stamp). Lifted
     from Yelp's #D32323, which is only ~3.8:1 on the page black —
     this value holds ≥5:1 (WCAG AA for normal text) at full
     opacity. Never render it below opacity 1 on text. */
  --color-stars: #F03C3C;

  --font-editorial: Georgia, 'Times New Roman', Times, serif;
  --font-data: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-ui: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;

  --space-sm: 0.5rem;
  --space-md: 1rem;
  --space-lg: 2rem;

  /* V30: shared chrome geometry. The map page's header and the
     loading screen's header are the SAME bar: identical height,
     padding, and a single vertical midline every chrome element
     centers on (title, about link, status text, the enter/skip
     button). Alignment through the loading→map fade holds by
     construction — change these tokens and both bars move
     together. --chrome-control-h is the tallest chrome control
     (the loading CTA); the bar is sized from it. */
  --chrome-pad-x: 1.5rem;
  --chrome-pad-y: 0.875rem;
  --chrome-control-h: 2.25rem;
  /* +1px = the bar's border (border-box): keeps a min-height-
     governed bar exactly as tall as a content-governed one. */
  --chrome-bar-h: calc(var(--chrome-control-h) + 2 * var(--chrome-pad-y) + 1px);
}

/* V33: one override retunes every chrome bar (headers and footers,
   map page and loading screen) for phones. */
@media (max-width: 768px) {
  :root {
    --chrome-pad-x: 1rem;
    --chrome-pad-y: 0.5rem;
    --chrome-control-h: 2rem;
  }
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  /* Explicit bg so iOS Safari elastic-scroll overscroll doesn't
     reveal the layer behind the wrapper. The map-wrapper's
     overflow:auto on mobile is the actual scroll surface; without
     this, hard upward pan bleeds the map content above the
     header. */
  background-color: var(--color-bg);
}

html,
body {
  margin: 0;
  padding: 0;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-data);
  font-variant-numeric: tabular-nums;
  position: relative;
  /* V21: dropped min-height:100vh and overflow-x:hidden. The map
     page body is sized by body:not(.about-page) { height:100dvh }
     and that rule also sets overflow:hidden. The about page lets
     content drive body height and has no horizontal overflow
     source. */
}

/* Map page (everything that isn't the about page) is a single-
   screen experience: page-header on top, map-wrapper fills the
   remainder. V20 restructured as a flex column so the wrapper
   fills main height without calc() arithmetic on 100dvh - header
   (which was producing banding when the calculation didn't match
   the actual header height exactly). Body locked to 100dvh +
   overflow:hidden so Safari's toolbar collapse can't leave a gap
   below the map; descendant horizontal pan inside .map-wrapper
   still works via its own overflow:auto on mobile. */
body:not(.about-page) {
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* Override the global main { display:flex; align-items:center }
   rule on the map page. With body as a flex column, main needs
   to flex-fill the remainder, NOT flex-center the wrapper —
   centering produced banding above and below the wrapper when
   wrapper was shorter than viewport. min-height:0 lets the flex
   child shrink below its content's intrinsic size as required.
   .about-page main already has its own display:block. */
body:not(.about-page) main {
  flex: 1 1 0;
  min-height: 0;
  display: block;
  padding: 0;
}

/* Visited links: project-wide override. Default browser purple
   reads as unreadable on the dark background — keep visited
   links the same off-white as unvisited; hover/focus uses the
   accent green (handled by per-component a:hover rules). */
a:visited {
  color: inherit;
}

body::after {
  content: '';
  position: fixed;
  /* V22: was `inset: 0`. On iOS Safari `inset: 0` on a fixed
     element clips to the visual viewport (= innerHeight), which on
     the body-locked map page stops above the URL-bar floating-pill
     zone. The grain texture then ended sharply at y=innerHeight,
     exactly where full-height sheets (.modal-card height:100dvh)
     ended too — creating a visible band where the grain treatment
     stopped and bare html bg began. The 100lvh extent covers the
     largest possible viewport including the area behind the URL
     bar pill, so grain treatment is continuous from y:0 to the
     physical screen bottom. On viewports without a floating URL
     bar (desktop, PWA standalone) 100lvh === innerHeight, so this
     resolves identically to the prior inset:0. */
  top: 0;
  left: 0;
  right: 0;
  height: 100lvh;
  pointer-events: none;
  background-image: url('assets/grain.png');
  background-repeat: repeat;
  mix-blend-mode: multiply;
  opacity: 0.35;
  /* V21: 10000 sits explicitly above .loading-screen (z:9999) so
     the grain texture overlays every visual state including the
     loading splash. Previously both were 9999 and stacking was
     determined by DOM order — fragile. */
  z-index: 10000;
}

/* V21: removed the module-scope min-height/flex-center on main.
   On the map page the body:not(.about-page) main override controls
   layout (flex:1 1 0; display:block). The about page has its own
   .about-main rules. The original min-height:calc(100vh - 3.5rem)
   was the source of the V11–V19 banding wars and now has no live
   consumer. */
main {
  padding: 0;
}

/* Wrapper is the visible viewport slot for the map: full width,
   chrome-offset height (header above, footer below), scrollable
   overflow. Whichever axis the map exceeds the wrapper on (see the
   anchoring comment at .map-bg) pans; the initial scroll position
   is set by main.js centerMapOnVenueCluster() so the venue cluster
   opens centered in the visible band on both axes. */
.map-wrapper {
  position: relative;
  width: 100%;
  /* V20: height:100% of the flex-filling main, which is itself
     body-height minus header. No calc() arithmetic to mismatch
     the actual header height. Toolbar collapse cascades through
     body 100dvh → main flex:1 → wrapper 100% → map height:100%. */
  height: 100%;
  /* V25: overflow auto (was hidden) + container-type: size so the
     map's anchoring can switch on the wrapper's own aspect ratio
     (see the @container rule below .map-bg). Width-anchored maps
     overflow the wrapper's top edge only — unreachable overflow is
     clipped, never scrollable, so wide viewports render exactly as
     the old overflow:hidden did. Height-anchored maps overflow to
     the right and the wrapper becomes the pan surface (previously
     gated to the ≤768px media query). */
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-x pan-y;
  scrollbar-width: none;
  -ms-overflow-style: none;
  container-type: size;
  /* z-index + isolation creates a stacking context below the page
     header (z-index 100) so the wide scrollable map content can't
     visually escape past the wrapper edge during momentum scroll. */
  z-index: 1;
  isolation: isolate;
}

.map-wrapper::-webkit-scrollbar {
  display: none;
}

/* V73: mouse users drag-pan the map (main.js setupMapDragPan).
   Grab cursor advertises it; pins keep their own pointer cursor. */
@media (hover: hover) and (pointer: fine) {
  .map-wrapper {
    cursor: grab;
  }

  .map-wrapper.is-panning {
    cursor: grabbing;
  }
}

/* V21/V25: width-anchored on wide wrappers, height-anchored on
   tall ones. Both share aspect-ratio: 1920 / 1334 (map.mp4's
   intrinsic dimensions) so .map-bg and .pin-layer always render
   at identical sizes — pin percentages from venues.js land on
   the same map points regardless of viewport.

   Wide wrappers (width:100%, height auto via aspect-ratio): fills
   the wrapper edge-to-edge horizontally; the map's height exceeds
   the wrapper and the excess scrolls vertically.

   Tall wrappers (height:100%, width auto, via the @container rule
   below): produces a map wider than the wrapper, and the excess
   scrolls horizontally. V25 moved this switch from the ≤768px
   media query to a container query on the wrapper's own aspect
   ratio — device width was a proxy that failed on tablet portrait
   and tall desktop windows (width-anchored map fell short of the
   wrapper height, leaving a void above the map).

   V27: anchored top-left (was bottom-left). Overflow on the side
   opposite an absolute anchor is unreachable — the old `bottom: 0`
   made the top crop unscrollable on wide viewports. With top/left
   anchoring every crop is scrollable, and main.js
   centerMapOnVenueCluster() scrolls the initial view to the venue
   cluster centroid on both axes, so short wrappers open centered
   on the pins instead of pinned to the map's bottom band.

   FILE DEPENDENCY: if assets/map.mp4 is replaced with a video
   at a different aspect ratio, update the aspect-ratio value
   below AND the @container query to match the new file's
   intrinsic width/height. */
.map-bg,
.pin-layer {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  aspect-ratio: 1920 / 1334;
}

/* V25: flip to height-anchored the moment the wrapper is
   proportionally taller than the map. Container query (not media
   query) so the threshold measures the wrapper itself — viewport
   ratio is off by the header height. JS side: main.js
   centerMapOnVenueCluster() detects the resulting overflow and
   scrolls to the venue-cluster centroid. */
@container (max-aspect-ratio: 1920 / 1334) {
  .map-bg,
  .pin-layer {
    width: auto;
    height: 100%;
  }
}

.pin-layer {
  pointer-events: none;
}

.pin {
  position: absolute;
  cursor: pointer;
  pointer-events: auto;
  transform: translate(-50%, -50%);
  transition: transform 120ms ease;
  background: transparent;
  border: none;
  padding: 0;
}

.pin img {
  display: block;
  width: 100px;
  height: 100px;
  pointer-events: none;
  
  /* Mask the rectangular WebP into a circular sticker */
  border-radius: 50%;
  border: 3px solid var(--color-accent);
  background: var(--color-bg);
  object-fit: cover;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}

.pin:hover,
.pin:focus-visible {
  transform: translate(-50%, -50%) scale(1.1);
}

.pin:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 3px;
}

.pin:active {
  transform: translate(-50%, -50%) scale(0.94);
}

@media (prefers-reduced-motion: reduce) {
  .pin {
    transition: none;
  }
  .pin:hover,
  .pin:focus-visible,
  .pin:active {
    transform: translate(-50%, -50%);
  }
}

/* Pin labels — small venue name below each pin on mobile only.
   Hidden on desktop (the hover tooltip carries that role). Alley
   pin is deliberately unlabeled; its locked/unlocked state styling
   carries the visual meaning. */
.pin-label {
  display: none;
}

/* Alley pin: locked (visited < threshold) and unlocked states. The
   pin is always rendered; visual treatment indicates whether the
   experience is available. Locked taps surface a toast (mobile) or
   the gating message in the hover tooltip (desktop).
   V9: switched from CSS filter:grayscale(1) (which persisted on
   Safari iOS even after the rule no longer matched — V7/V8 CSS-
   level fixes didn't take) to a two-asset swap. The pin renders
   both alley.webp (color) and alley-gray.webp (grayscale); CSS
   toggles display:none between them based on data-alley-state.
   Pre-baked grayscale defeats every form of filter compositing
   cache. */
.pin[data-pin-id='alley'][data-alley-state='locked'] {
  opacity: 0.4;
  cursor: not-allowed;
}

.pin[data-pin-id='alley'][data-alley-state='locked']:hover {
  opacity: 0.45;
}

/* Default + locked: show the pre-baked grayscale image. */
.pin[data-pin-id='alley'] .pin-img-color {
  display: none;
}

.pin[data-pin-id='alley'] .pin-img-gray {
  display: block;
}

/* Unlocked: swap to the color image, hide grayscale. */
.pin[data-pin-id='alley'][data-alley-state='unlocked'] .pin-img-color {
  display: block;
}

.pin[data-pin-id='alley'][data-alley-state='unlocked'] .pin-img-gray {
  display: none;
}

/* Unlocked alley: a one-shot burst plays first (driven by the
   `data-alley-just-unlocked` flag set in main.js when state
   transitions live), then a steady pulse takes over. On reload
   the flag isn't present so only the pulse runs — preserves the
   pulse without re-playing the dramatic moment.
   Explicit filter:none + opacity:1 + cursor:pointer clear the
   locked state's grayscale + dimmed cursor when the state flips
   live (some browsers cache the previous filter value until
   reflow). main.js forces a reflow on the transition as a
   belt-and-suspenders. */
.pin[data-pin-id='alley'][data-alley-state='unlocked'] {
  opacity: 1;
  cursor: pointer;
  animation: alleyPulse 2s ease-in-out infinite;
}

.pin[data-pin-id='alley'][data-alley-state='unlocked'][data-alley-just-unlocked='true'] {
  opacity: 1;
  cursor: pointer;
  animation:
    alleyUnlockBurst 1.2s ease-out 1,
    alleyPulse 2s ease-in-out 1.2s infinite;
}

/* V11 fix: every keyframe transform preserves the parent .pin's
   translate(-50%, -50%) centering. CSS animations replace the
   entire transform property during keyframes — declaring only
   scale() un-translated the pin to its top-left origin, causing
   the visible location shift between locked (static) and unlocked
   (animated) states. */
@keyframes alleyPulse {
  0%,
  100% {
    filter: drop-shadow(0 0 4px rgba(180, 255, 100, 0.3));
    transform: translate(-50%, -50%) scale(1);
  }
  50% {
    filter: drop-shadow(0 0 18px rgba(180, 255, 100, 0.7));
    transform: translate(-50%, -50%) scale(1.04);
  }
}

@keyframes alleyUnlockBurst {
  0% {
    filter: drop-shadow(0 0 0 rgba(180, 255, 100, 0));
    transform: translate(-50%, -50%) scale(1);
  }
  40% {
    filter: drop-shadow(0 0 30px rgba(180, 255, 100, 1));
    transform: translate(-50%, -50%) scale(1.18);
  }
  100% {
    filter: drop-shadow(0 0 18px rgba(180, 255, 100, 0.7));
    transform: translate(-50%, -50%) scale(1.04);
  }
}

/* Toast — bottom-centered notification for locked-alley taps on
   mobile (and desktop, as a complement to the tooltip). */
.alley-locked-toast {
  position: fixed;
  /* V26: clear the footer control bar (~3rem) plus a gap. */
  bottom: 4rem;
  left: 50%;
  transform: translateX(-50%) translateY(150%);
  background: rgba(10, 10, 10, 0.95);
  border: 1px solid rgba(255, 255, 255, 0.15);
  color: var(--color-text);
  padding: 0.75rem 1rem;
  border-radius: 0.375rem;
  font-family: var(--font-ui);
  font-size: 0.8125rem;
  line-height: 1.4;
  letter-spacing: 0.02em;
  text-align: center;
  z-index: 400;
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms ease,
    transform 260ms cubic-bezier(0.32, 0.72, 0, 1);
  max-width: min(26rem, calc(100vw - 2rem));
}

.alley-locked-toast[data-state='visible'] {
  transform: translateX(-50%) translateY(0);
  opacity: 1;
}

/* Per-pin top/left positioning is now driven by venues.js mapCoordinates
   via main.js setupPinPositions() — applied as inline style on each
   .pin element at DOMContentLoaded. */

.pin-tooltip {
  position: fixed;
  width: 440px;
  max-width: calc(100vw - 32px);
  background: var(--color-bg);
  color: var(--color-text);
  border: 1px solid rgba(184, 255, 0, 0.25);
  padding: 0.75rem;
  z-index: 1000;
  opacity: 0;
  pointer-events: none;
  transition: opacity 100ms linear;
  text-align: left;
  left: -9999px;
  top: -9999px;
  /* V49: per-venue tilt — main.js sets --peek-rotate from the pin
     id so hover peeks lean left or right instead of all tilting
     the same way. V59: drops in with a small settle. */
  transform: translateY(6px) scale(0.985) rotate(var(--peek-rotate, -1.2deg));
  transform-origin: center center;
  transition: opacity 120ms linear,
    transform 180ms cubic-bezier(0.2, 0.9, 0.3, 1);
}

.pin-tooltip.is-visible {
  transform: translateY(0) scale(1) rotate(var(--peek-rotate, -1.2deg));
}

/* ASSETS §1.3 photo treatment, finally applied: every photograph
   (venue shots, selfies, peek photos) reads as newsprint-grade —
   slightly desaturated, a touch of contrast. Pin art and UI are
   untouched. */
/* (see .photo-treatment group below the tooltip rules) */

/* V63: teaser peek rows — title + aggregate stack on the left,
   CTA chip centered against the whole stack on the right. */
.pin-tooltip-name-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}

.pin-tooltip-title-stack {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  min-width: 0;
}

.pin-tooltip-rating {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}

.pin-tooltip-review-count {
  font-family: var(--font-data);
  font-size: 0.6875rem;
  opacity: 0.7; /* V66: AA floor */
}

.pin-tooltip-tally {
  margin-top: 0.35rem;
  font-family: var(--font-data);
  font-size: 0.6875rem;
  opacity: 0.7; /* V66: AA floor */
}

.pin-tooltip-photo,
.modal-venue-photo img,
.rat-selfie {
  filter: saturate(0.7) contrast(1.08);
}

.pin-tooltip.is-visible {
  opacity: 1;
}

.pin-tooltip-photo {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  /* Matches the card's 0.75rem padding so the footer strip is
     symmetric — photo→row gap equals row→card-edge gap. */
  margin-bottom: 0.75rem;
  background: rgba(197, 191, 174, 0.05);
}

.pin-tooltip-name {
  font-family: var(--font-data);
  font-size: 0.8125rem;
  color: var(--color-text);
}

/* V62: CTA chip rides the name line's right edge. */
.pin-tooltip-cta {
  display: inline-block;
  flex-shrink: 0;
  font-family: var(--font-ui);
  text-transform: lowercase;
  letter-spacing: 0.08em;
  font-size: 0.6875rem;
  color: var(--color-accent);
  border: 1px solid rgba(184, 255, 0, 0.45);
  padding: 0.3rem 0.7rem;
}

@media (hover: none), (pointer: coarse) {
  .pin-tooltip {
    display: none;
  }
}

.modal {
  position: fixed;
  inset: 0;
  z-index: 200;
  visibility: hidden;
  opacity: 0;
  background: rgba(10, 10, 10, 0.88);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-lg);
}

.modal[data-state='open'] {
  visibility: visible;
  opacity: 1;
}

.modal-card {
  position: relative;
  background: var(--color-bg);
  color: var(--color-text);
  border: 1px solid rgba(184, 255, 0, 0.2);
  width: 100%;
  /* V36: wider card uses the desktop viewport's spare width so
     review text wraps into fewer lines — most reviews then fit
     inside 90vh with no scrolling at all. */
  max-width: 700px;
  max-height: 90vh;
  overflow-y: auto;
  /* Residual overflow (long review × short viewport) scrolls
     without scrollbar chrome — same treatment as .map-wrapper. */
  scrollbar-width: none;
  -ms-overflow-style: none;
  padding: var(--space-lg);
}

.modal-card::-webkit-scrollbar {
  display: none;
}

/* Close button lives inside .modal-venue-headline as a flex item.
   Static positioning (not absolute) on all viewports — the flex
   layout in .modal-venue-headline aligns it to the row end. The
   mobile sticky-title pattern (V5) thus carries the close with it
   automatically. */
.modal-close {
  position: static;
  flex-shrink: 0;
  background: none;
  border: none;
  color: var(--color-text);
  font-size: 1.75rem;
  line-height: 1;
  padding: var(--space-sm);
  margin: -0.25rem -0.25rem 0 0;
  cursor: pointer;
  transition: color 150ms ease;
}

.modal-close:hover,
.modal-close:focus-visible {
  color: var(--color-accent);
}

.modal-close:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* Headline is a flex column: drag handle (mobile only) on top,
   title-row (title text + close button) below. Desktop keeps the
   handle hidden via .modal-sheet-handle { display: none } so the
   visual layout matches the prior single-row look. */
.modal-venue-headline {
  display: flex;
  flex-direction: column;
  font-family: var(--font-editorial);
  font-style: italic;
  font-weight: normal;
  font-size: 2.25rem;
  line-height: 1.1;
  margin: 0 0 var(--space-sm) 0;
  color: var(--color-text);
}

.modal-venue-headline-row {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 0.75rem;
  width: 100%;
}

/* Drag handle: rendered in the DOM for every sheet but hidden
   on desktop. Mobile media query un-hides and adds pill styling. */
.modal-sheet-handle {
  display: none;
}

/* Alley headline flows like every other modal title — static
   position, no sticky pinning. V14 dropped the V13 sticky pattern
   because (a) it sat the title flush against the card top while
   other modals' titles sit 32px down (card padding), making the
   alley header read visually shorter and offset; (b) combined
   with the sticky .alley-header below it created a double-sticky
   scroll where the photo pinned under the title and the rat cards
   scrolled past it — users perceived this as a bug. Horizontal
   inset comes from the rule below since .modal-card.alley-modal
   zeroes its horizontal padding so .alley-cards can run full
   bleed. Mobile padding override lives in the mobile media query. */
.modal-card.alley-modal .modal-venue-headline {
  padding-left: var(--space-lg);
  padding-right: var(--space-lg);
}

.reviewer-block {
  /* V44: the info stack (name row + oscilloscope) is the only
     in-flow child, so IT defines the row height; the selfie is
     absolutely positioned to fill the reserved left column
     exactly. This is the only arrangement where the portrait can
     match the component's height with zero feedback loops — any
     in-flow image ends up negotiating the row height with the
     text column. */
  position: relative;
  display: block;
  padding-left: calc(200px + var(--space-lg));
  min-height: 200px;
  margin-bottom: 0;
}

.reviewer-info {
  /* V43/V44: column — top row (meta + play), then the full-width
     oscilloscope beneath. Fills the selfie's 200px height with
     the scope pushed to the bottom edge so both columns end on
     the same line. */
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--space-md);
  min-height: 200px;
  flex: 1 1 auto;
  min-width: 0;
}

.reviewer-meta {
  /* Name column holds its natural width; the play/osc column
     flexes instead. */
  flex: 0 0 auto;
  min-width: 0;
}

.rat-selfie {
  /* V44: square 200×200 in the reserved left column; the info
     stack beside it space-betweens to the same 200px (see
     .reviewer-info) so both columns bottom out together. */
  position: absolute;
  left: 0;
  top: 0;
  width: 200px;
  height: 200px;
  aspect-ratio: 1 / 1;
  border-radius: 0;
  object-fit: cover;
  object-position: center;
  flex-shrink: 0;
  background: rgba(197, 191, 174, 0.05);
  display: block;
  transform: rotate(-1.2deg);
  transform-origin: center center;
}

.reviewer-name {
  font-family: var(--font-editorial);
  font-style: italic;
  font-weight: normal;
  font-size: 1.25rem;
  margin: 0 0 0.25rem;
  color: var(--color-text);
}

.reviewer-handle {
  font-family: var(--font-data);
  font-size: 0.875rem;
  margin: 0 0 0.25rem;
  opacity: 0.7;
}


.reviewer-elite {
  /* V35: Yelp Elite in the house hand — gold chip, rat mark, year. */
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.6rem;
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.6875rem;
  padding: 0.15rem 0.5rem;
  border: 1px solid #c9a227;
  color: #c9a227;
}

/* Alley card keeps the vertical card padding (matches other modals
   so the title sits 32px down from the card top) but zeroes
   horizontal padding so .alley-cards can run full-bleed to the
   card edges. Non-bleeding children (.modal-venue-headline,
   .alley-header, .alley-card) carry their own var(--space-lg)
   horizontal padding so title, photo, and per-card content all
   align at 32px from the card edge — matching the inset that
   location modals inherit from uniform card padding. */
.modal-card.alley-modal {
  /* No bottom padding: with the desktop flex partition, .alley-
     cards is the scroll region and runs flush to the card's
     bottom edge. A bottom inset here exposed a 32px black band
     under the last visible card whenever the list scrolled past
     the container height. */
  padding: var(--space-lg) 0 0;
}

/* Alley header (photo + framing). margin-top: 1.5rem matches the
   .review-content margin-top on location modals so the gap
   between the title bottom and the alley photo reads identically
   to location-modal title → content spacing. Horizontal padding
   aligns the photo + framing line with the title above and the
   rat-card content below.
   V14 stripped the desktop sticky behavior on .alley-header.
   Combined with the sticky headline above, the V11 sticky was the
   "scroll stickiness bug" — the photo region pinned at top:3.25rem
   while rat cards scrolled past it, and users perceived that as
   broken rather than as a reordering affordance. */
.alley-header {
  margin-top: 1.5rem;
  padding: 0 var(--space-lg) var(--space-md);
}

/* Desktop-only: title + photo + framing + hint stay anchored in
   the card frame while only the rat-card list scrolls. Achieved
   by making the card itself a flex column with overflow:hidden
   (so the card no longer scrolls as a whole) and letting
   .alley-cards absorb remaining vertical space with overflow:auto.
   Replaces the V11/V13 position:sticky approach, which created
   the double-stick scroll bug — flex partitioning gives a true
   fixed-header region without per-element sticky offsets.
   Mobile keeps the single-scroll-context pattern (card scrolls
   end-to-end with a sticky headline pinning the title) since
   sheet height is already constrained by viewport and a
   nested scroll region inside a draggable sheet reads awkwardly. */
@media (min-width: 769px) {
  .modal-card.alley-modal {
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  .modal-card.alley-modal > .modal-venue-headline,
  .modal-card.alley-modal > .alley-header,
  .modal-card.alley-modal > .alley-cards-hint {
    flex: 0 0 auto;
  }

  .modal-card.alley-modal > .alley-cards {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* Boundary divider between the fixed top region (title +
       photo + framing + hint) and the scrolling list. Matches
       the site-wide subtle-divider weight used by .alley-card
       inter-row borders, .review-content section dividers, and
       .page-header bottom — so the boundary reads as part of the
       same divider system, not a one-off rule. Border lives on
       the scroll container's box so it stays anchored at the
       boundary as the list scrolls. */
    border-top: 1px solid rgba(184, 255, 0, 0.15);
  }
}

.alley-cards {
  display: flex;
  flex-direction: column;
  /* V12 P2: removed module-scope horizontal padding (was
     `0 var(--space-lg) var(--space-lg)`). The parent
     .modal-card.alley-modal already pads horizontally by 2rem on
     desktop; alley-cards adding another 2rem stacked to 4rem
     total inset — excessive vs location modal cards. Mobile rule
     at line ~1412 keeps its own horizontal padding since mobile
     card has padding:0 and children carry their own inset. */
  padding: 0 0 var(--space-lg);
}

.alley-card {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 14px;
  width: 100%;
  /* Horizontal padding matches .modal-card padding on other modals
     and the title/photo inset on the alley sheet, so selfies and
     text align with the title above. The row itself spans the full
     card width (.alley-cards has padding:0) so hover/focus tints
     and the bottom rule run edge-to-edge — the full-bleed-with-
     inset-content pattern from the mobile sheet. Vertical padding
     gives the selfie + name + stars + text block breathing room
     against the row above and below; 12px read as cramped against
     the ~40px selfie height. */
  padding: 1rem var(--space-lg);
  background: transparent;
  border: none;
  border-bottom: 1px solid rgba(184, 255, 0, 0.15);
  text-align: left;
  cursor: pointer;
  color: var(--color-text);
  transition:
    background 120ms linear,
    transform 350ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* FLIP inverse phase: snap to translated origin without animating,
   then the play phase removes this class and the card transitions
   smoothly back to identity. */
.alley-card.is-flipping {
  transition: none;
}

.alley-card:last-child {
  border-bottom: none;
}

.alley-card:hover,
.alley-card:focus-visible {
  background: rgba(184, 255, 0, 0.04);
  outline: none;
}

.alley-card:focus-visible {
  outline: 1px solid var(--color-accent);
  outline-offset: -1px;
}

.alley-card::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--color-accent);
  opacity: 0;
  transition: opacity 250ms ease;
}

.alley-card.is-active::before { opacity: 1; }
.alley-card.is-active[data-rank='1']::before { opacity: 0.7; }
.alley-card.is-active[data-rank='2']::before { opacity: 0.5; }
.alley-card.is-active[data-rank='3']::before { opacity: 0.3; }
.alley-card.is-active[data-rank='4']::before { opacity: 0.15; }

.alley-card-selfie {
  width: 64px;
  height: 64px;
  flex-shrink: 0;
  border-radius: 0;
  border: 1px solid rgba(184, 255, 0, 0.3);
  object-fit: cover;
  background: rgba(197, 191, 174, 0.05);
  display: block;
}

.alley-card-body {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}

.alley-card-name {
  font-family: var(--font-editorial);
  font-style: italic;
  font-size: 1rem;
  color: var(--color-text);
}

.alley-card-stars {
  font-size: 0.875rem;
  color: var(--color-accent);
  letter-spacing: 1px;
  line-height: 1;
}

.alley-card-text {
  font-family: var(--font-data);
  font-size: 0.875rem;
  line-height: 1.4;
  color: var(--color-text);
  opacity: 0.85;
}

.alley-card-speaker {
  position: absolute;
  top: 10px;
  right: 12px;
  opacity: 0.4;
  color: var(--color-accent);
  pointer-events: none;
  display: inline-flex;
  transition: opacity 200ms ease;
}

.alley-card:hover .alley-card-speaker,
.alley-card:focus-visible .alley-card-speaker {
  opacity: 0.85;
}

.alley-card.is-active .alley-card-speaker {
  opacity: 1;
}

/* First-visit hint between the alley header and the cards list.
   Sits as a sibling of both, with its own vertical breathing room
   so it reads as a divider rather than a caption on either zone. */
.alley-cards-hint {
  font-family: var(--font-editorial);
  font-style: italic;
  font-size: 0.9rem;
  opacity: 0.65;
  margin: 1.25rem 0 1rem;
  padding: 0 var(--space-lg);
  letter-spacing: 0.01em;
  text-align: left;
  color: var(--color-text);
}

.alley-cards-hint.is-fading {
  opacity: 0;
  transition: opacity 800ms ease;
}

.footer-reset {
  background: transparent;
  border: none;
  font-family: inherit;
  font-size: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: var(--color-text);
  /* V66: quietest of the always-on controls, but never below the
     0.7 muted floor — cream under ~0.63 effective opacity on the
     page black drops under WCAG AA (4.5:1). Hover restores full
     opacity. */
  opacity: 0.7;
  cursor: pointer;
  padding: 0;
  transition: opacity 100ms linear, color 100ms linear;
}

.footer-reset:hover,
.footer-reset:focus-visible {
  opacity: 1;
  color: var(--color-accent);
  outline: none;
}

/* V37: pagination is the card's pinned footer bar — sticky at the
   scroll context's bottom edge, styled like the page footer
   (opaque bar + hairline top rule) so the chrome grammar stays
   consistent. Full-bleed via negative margins against the card's
   2rem padding. */
.review-pagination {
  position: sticky;
  bottom: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  /* Full-bleed sides only; the bottom flush comes from the card
     dropping its own bottom padding (below) — a negative bottom
     margin here would push the bar past its sticky containing
     block and clamp it up over the reactions. */
  margin: var(--space-md) calc(-1 * var(--space-lg)) 0;
  padding: 0.5rem var(--space-lg);
  background: var(--color-bg);
  border-top: 1px solid rgba(184, 255, 0, 0.15);
}

/* When the card ends in the pinned pagination bar, the bar IS the
   bottom edge — the card's own bottom padding would leave a
   floating strip beneath it. */
.modal-card:has(.review-pagination) {
  padding-bottom: 0;
}

.pagination-arrow {
  font-family: var(--font-ui);
  /* V12 P3: bumped from 0.875rem to 1.25rem for readability on
     desktop multi-review modals. Mobile rule (line ~1705) sets a
     larger 3rem touch target — this is the desktop default. */
  font-size: 1.25rem;
  line-height: 1;
  background: transparent;
  color: var(--color-accent);
  border: none;
  padding: 0.375rem 0.625rem;
  cursor: pointer;
  opacity: 0.85;
  transition: opacity 100ms linear;
}

.pagination-arrow:hover,
.pagination-arrow:focus-visible {
  opacity: 1;
  outline: none;
}

.pagination-arrow:focus-visible {
  outline: 1px solid var(--color-accent);
  outline-offset: 2px;
}

.pagination-counter {
  font-family: var(--font-ui);
  /* V12 P3: bumped slightly for desktop readability alongside the
     larger arrows. */
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  font-size: 0.6875rem;
  color: var(--color-text);
  font-variant-numeric: tabular-nums;
  opacity: 0.65;
  min-width: 4ch;
  text-align: center;
}

/* Spacing above the rating row: single-review sheets have no
   pagination so .review-content sits flush against the title
   bar's underline border — visually too tight. Top margin on
   .review-content gives the rating breathing room. When
   pagination IS present (multi-review sheets), the adjacent-
   sibling rule cancels the margin since pagination already
   provides vertical space. .review-content + .review-pagination
   are direct siblings inside .modal-card. */
.review-content {
  margin-top: 1.5rem;
}

/* V42: stars alone under the handle. */
.review-rating {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  margin: 1rem 0 0;
}

.star-rating {
  display: inline-flex;
  gap: 3px;
}

/* V35: Yelp's signature is the star knocked out of a filled colored
   box, not a floating glyph. V64: box in the site accent, star
   cutout in the page black — the rating reads as part of the map's
   visual language rather than imported Yelp chrome. Alternating
   per-star tilt keeps it hand-set. */
.star {
  position: relative;
  display: inline-block;
  width: 21px;
  height: 21px;
  background: var(--color-accent);
  border-radius: 2px;
  transform: rotate(-2.5deg);
}

.star::after {
  content: '';
  position: absolute;
  inset: 12%;
  background: var(--color-bg);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}

.star:nth-child(even) {
  transform: rotate(1.5deg) translateY(0.5px);
}

.star-rating-small .star {
  width: 15px;
  height: 15px;
  border-radius: 1.5px;
}

.star-empty {
  opacity: 0.18;
}

.review-date {
  font-family: var(--font-data);
  font-size: 0.75rem;
  opacity: 0.7; /* V66: AA floor */
}

/* V36: natural height — no inner scrollbox. The card is the single
   scroll context on every breakpoint (mobile already worked this
   way); the old fixed 280px box put a second scrollbar inside the
   panel and clipped review text behind a fade. */
.review-body {
  height: auto;
}

/* V43: one combined meta line ("April 2026 · Translated from 134
   squeaks"), bottom-right under the review text, in the date's
   style — no rule of its own (the reactions row and pagination
   bar carry the only two rules below the text). */
.review-meta-row {
  display: flex;
  justify-content: flex-start;
  margin: 0.5rem 0 0;
}

.review-reactions {
  display: flex;
  gap: 0.375rem;
  flex-wrap: wrap;
  /* V53: no divider above — the meta line and the icon row read
     as one footer zone. */
  margin: 0.75rem 0 0;
}

/* V57: one bordered chip per reaction — icon, label, count inline
   inside the box, fit-content width. The .reaction-label::after
   trick reserves the longest state-swapped copy's width so
   toggling can't reflow the row. Active fills the box accent. */
.review-reaction {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.6875rem;
  padding: 0.45rem 0.7rem;
  white-space: nowrap;
  background: transparent;
  color: var(--color-text);
  border: 1px solid rgba(197, 191, 174, 0.45);
  opacity: 0.85;
  cursor: pointer;
  transition: color 150ms ease, opacity 150ms ease,
    background 150ms ease, border-color 150ms ease;
}

.review-reaction:hover:not(.is-active),
.review-reaction:focus-visible:not(.is-active) {
  border-color: var(--color-accent);
  color: var(--color-accent);
  opacity: 1;
  outline: none;
}

.review-reaction:focus-visible {
  outline: none;
}

.review-reaction.is-active {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: var(--color-bg);
  opacity: 1;
}

/* Active hover swaps the token roles: the border goes page-black
   against the accent fill (text/icon already are), so the hover
   reads on a filled chip without accent-on-accent text. */
.review-reaction.is-active:hover,
.review-reaction.is-active:focus-visible {
  border-color: var(--color-bg);
  color: var(--color-bg);
}

.reaction-icon {
  display: inline-flex;
  align-items: center;
}

.reaction-label {
  /* inline-block so the ::after width reservation actually
     establishes the element's min width. */
  display: inline-block;
}

.reaction-label::after,
.review-report::after {
  content: attr(data-alt);
  display: block;
  height: 0;
  overflow: hidden;
  visibility: hidden;
}

.reaction-count {
  /* V66: 0.8 because it multiplies with the chip's 0.85 —
     0.8 × 0.85 ≈ 0.68 effective, just above the AA floor. */
  opacity: 0.8;
  display: inline-block;
  font-variant-numeric: tabular-nums;
}

/* V59 micro-interactions: press squish + a small pop when a chip
   arms. */
.review-reaction:active {
  transform: scale(0.96);
}

.review-reaction.is-active .reaction-icon {
  animation: chipPop 260ms ease;
}

@keyframes chipPop {
  40% { transform: rotate(-8deg) scale(1.18); }
}

/* V52: the report analogue — quiet text action at the row's far
   edge, Yelp's "Report review" in city terms. */
.review-report {
  /* V56: lives in the chip row — far right, centered against the
     chips. Width reserved for the longer state (::after data-alt)
     with text right-anchored, so toggling to 'reported' changes
     neither size nor the right edge. */
  margin-left: auto;
  align-self: center;
  display: inline-block;
  text-align: right;
  background: transparent;
  border: none;
  padding: 0;
  font-family: var(--font-data);
  font-size: 0.6875rem;
  letter-spacing: 0.02em;
  color: var(--color-text);
  opacity: 0.7; /* V66: AA floor — resting, but still interactive */
  cursor: pointer;
  transition: opacity 150ms ease, color 150ms ease;
}

.review-report:hover,
.review-report:focus-visible {
  opacity: 1;
  color: var(--color-stars);
  outline: none;
}

.review-report.is-active {
  /* Full opacity: the red token's contrast budget assumes it —
     0.85 here dragged the effective ratio back under AA. */
  opacity: 1;
  color: var(--color-stars);
}


.word {
  transition: color 80ms ease, font-weight 80ms ease;
}

.word.is-active {
  color: var(--color-accent);
  font-weight: 600;
}

.venue-framing {
  font-family: var(--font-data);
  font-size: 0.9375rem;
  margin: 0 0 var(--space-md);
  opacity: 0.7;
}

.venue-closed-note {
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.8125rem;
  margin: 0;
  opacity: 0.7; /* V66: AA floor */
}

/* Tombstone epitaph: a few paragraphs that follow the [CLOSED]
   label. Body serif, slightly muted; the last paragraph lands at
   full weight as the closing punchline (see :last-child rule). */
.tombstone-epitaph {
  margin-top: 1.5rem;
  max-width: 36rem;
}

.tombstone-epitaph p {
  font-family: var(--font-editorial);
  font-size: 0.9375rem;
  line-height: 1.6;
  color: var(--color-text);
  opacity: 0.85;
  margin: 0 0 1rem 0;
}

.tombstone-epitaph p:last-child {
  margin-bottom: 0;
  opacity: 1;
}

/* V65: Rash tombstone — the review anatomy, dead. Empty aggregate
   under the headline, a stamped closure notice on the photo, an
   obit-halftone photo treatment, and a pay-respects chip. */
/* Rides the headline row between title and close: hugs the title
   (auto margin pushes the close away), centered on the title's
   line. Star size tracks the existing responsive .star rules. */
.tombstone-rating {
  display: inline-flex;
  align-items: center;
  align-self: center;
  margin-right: auto;
}

.modal-card-tombstone .modal-venue-photo {
  position: relative;
}

/* Obituary halftone: nearly gray, a trace of the truck red
   surviving. Overrides the site-wide newsprint filter. */
.modal-card-tombstone .modal-venue-photo img {
  filter: grayscale(0.85) contrast(1.12);
}

/* The closure label stamped across the photo's lower corner —
   condemnation-notice red, tilted like the peeks. Opaque page-black
   backing: the label must stay readable over any photo. */
.tombstone-closed-stamp {
  position: absolute;
  left: 1rem;
  bottom: 1rem;
  font-family: var(--font-ui);
  letter-spacing: 0.08em;
  font-size: 0.8125rem;
  color: var(--color-stars);
  border: 2px solid var(--color-stars);
  padding: 0.35rem 0.65rem;
  transform: rotate(-3deg);
  background: var(--color-bg);
}

.tombstone-respects {
  margin-top: 1.25rem;
}

.modal-card .oscilloscope {
  display: block;
  width: 100%;
  height: 80px;
  background: rgba(197, 191, 174, 0.04);
  border: 1px solid rgba(197, 191, 174, 0.2);
}

.modal-venue-photo {
  width: 100%;
  margin: var(--space-sm) 0 var(--space-md);
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border: 1px solid rgba(197, 191, 174, 0.2);
  transform: rotate(-0.6deg);
  transform-origin: center center;
  background: rgba(197, 191, 174, 0.04);
}

.modal-venue-photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* V45: SoundCloud-embed-style player — the waveform canvas is the
   player surface; the transport button overlays its left edge,
   vertically centered. Solid accent square with a bg-color icon
   (the piece's active-control treatment). */
.osc-player {
  position: relative;
}

.play-button {
  position: absolute;
  top: 50%;
  left: 12px;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  background: var(--color-accent);
  color: var(--color-bg);
  border: none;
  padding: 0;
  cursor: pointer;
  z-index: 2;
  transition: opacity 150ms ease;
}

.play-button:hover:not(:disabled) {
  opacity: 0.85;
}

.play-button:active:not(:disabled) {
  transform: translateY(-50%) scale(0.92);
}

.play-button:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

.play-button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.page-header {
  position: sticky;
  top: 0;
  /* V21: dropped V19's transform:translateZ(0) + will-change. The
     compositor-layer promotion fixed a hard-flick momentum-scroll
     flicker, but the map page body is now locked (overflow:hidden;
     height:100dvh) — nothing scrolls past the header, no flicker
     to fix. About-header keeps its promotion. */
  /* z-index 100: above .map-wrapper (z-index 1) so the wide
     scrolling map can't bleed past the wrapper edge into the
     header area on momentum-scroll overshoot. */
  z-index: 100;
  background: var(--color-bg);
  border-bottom: 1px solid rgba(184, 255, 0, 0.15);
  /* env(safe-area-inset-top) resolves to 0 on devices without a
     notch; on iPhone X+ it pads the header past the status bar
     so the title doesn't overlap. Requires viewport-fit=cover
     on the meta tag (set in index.html / about.html). The 44px
     max() floor for env-returns-0 devices lives in the mobile
     media query (V15 scope-down) so chromium desktop, which
     correctly resolves env() to 0, doesn't pick up an
     unnecessary 44px header push. */
  padding: calc(var(--chrome-pad-y) + env(safe-area-inset-top)) var(--chrome-pad-x) var(--chrome-pad-y);
  display: flex;
  /* V30: centered on the shared chrome midline. Centering was
     unsafe when the mode/volume controls lived here (their wrap
     between ~769-1000px grew the row and shoved the title), but
     the header is title + about only now — nothing can outgrow
     the bar. The loading header uses the same tokens, so title,
     about link, status text, and the enter button all share one
     centerline through the loading→map fade. */
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  min-height: var(--chrome-bar-h);
}

.page-title {
  font-family: var(--font-editorial);
  font-style: italic;
  font-weight: normal;
  font-size: 1.25rem;
  /* Fixed line box; see .page-header comment. Keep in sync with
     .loading-title. */
  line-height: 1.75rem;
  letter-spacing: 0.01em;
  color: var(--color-text);
  opacity: 0.85;
  margin: 0;
  white-space: nowrap;
}

.page-controls {
  display: flex;
  align-items: center;
  /* Center controls within the same 1.75rem row as the title, so
     the unwrapped rendering matches the old align-items: center
     look exactly. */
  min-height: 1.75rem;
  gap: 0.875rem;
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.75rem;
}

.header-about,
.page-controls a {
  color: var(--color-text);
  text-decoration: none;
  border-bottom: 1px solid currentColor;
}

.header-about:hover,
.header-about:focus-visible,
.page-controls a:hover,
.page-controls a:focus-visible {
  color: var(--color-accent);
  border-bottom-color: var(--color-accent);
  outline: none;
}

/* ===== Footer control bar (V26) =====
   The playback-mode toggle, volume/mute, and reset moved out of
   the header into a dedicated bar at the bottom of the body flex
   column (header / main / footer). This restores STRATEGY's
   original "footer toggle" placement, frees the header from the
   mode-set wrap that inflated it between ~769-1000px viewports,
   and gives the map page a deliberate bottom edge on iOS Safari
   (body is 100dvh, so the bar sits above the URL-bar pill zone;
   safe-area padding keeps controls clear of the home indicator).
   Same visual grammar as the header: bg, hairline border, UI
   font. z-index 100 matches .page-header, above the map (1),
   below modals (1000) and the loading screen (9999). */
.page-footer {
  z-index: 100;
  background: var(--color-bg);
  border-top: 1px solid rgba(184, 255, 0, 0.15);
  /* V33: shared chrome tokens — same bar geometry as the loading
     screen's footer-as-button, so the two footers hold position
     through the loading→map fade. */
  padding: var(--chrome-pad-y) var(--chrome-pad-x) calc(var(--chrome-pad-y) + env(safe-area-inset-bottom));
  min-height: calc(var(--chrome-bar-h) + env(safe-area-inset-bottom));
  display: flex;
  align-items: center;
}

.page-footer .footer-controls {
  flex: 1;
}

.footer-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 1.75rem;
  gap: 0.875rem;
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.75rem;
}

.footer-divider {
  opacity: 0.7; /* V66: AA floor for muted cream on black */
}

.footer-mode-set {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem;
  text-transform: none;
  letter-spacing: 0.04em;
  color: var(--color-text);
}

.footer-mode-prefix {
  font-family: var(--font-editorial);
  font-style: italic;
  opacity: 0.7;
}

.footer-mode {
  font-family: inherit;
  font-size: inherit;
  letter-spacing: inherit;
  background: transparent;
  border: none;
  padding: 0;
  cursor: pointer;
  color: var(--color-text);
  opacity: 0.7; /* V66: AA floor — inactive mode is still a control */
  transition: opacity 100ms linear, color 100ms linear;
}

.footer-mode:hover:not(.is-active),
.footer-mode:focus-visible:not(.is-active) {
  opacity: 0.85;
  outline: none;
}

.footer-mode.is-active {
  color: var(--color-accent);
  opacity: 1;
}

.footer-mode-pipe {
  opacity: 0.7; /* V66: AA floor */
}

/* V71: three modes can wrap on narrow widths — each pipe travels
   with its following button so a line never ends on a bare pipe. */
.footer-mode-pair {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
}

.footer-audio-set {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
}

.footer-volume {
  -webkit-appearance: none;
  appearance: none;
  /* V26: widened from the header-era 80px — the footer bar has
     the horizontal room, and a longer track gives finer dB
     control. */
  width: 180px;
  height: 14px;
  background: transparent;
  cursor: pointer;
  --fill: 100%;
}

.footer-volume::-webkit-slider-runnable-track {
  height: 2px;
  background: linear-gradient(
    to right,
    var(--color-accent) 0%,
    var(--color-accent) var(--fill),
    rgba(197, 191, 174, 0.3) var(--fill),
    rgba(197, 191, 174, 0.3) 100%
  );
}

.footer-volume::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 8px;
  height: 12px;
  background: var(--color-accent);
  border: none;
  border-radius: 0;
  margin-top: -5px;
  cursor: pointer;
}

.footer-volume::-moz-range-track {
  height: 2px;
  background: linear-gradient(
    to right,
    var(--color-accent) 0%,
    var(--color-accent) var(--fill),
    rgba(197, 191, 174, 0.3) var(--fill),
    rgba(197, 191, 174, 0.3) 100%
  );
  border: none;
}

.footer-volume::-moz-range-thumb {
  width: 8px;
  height: 12px;
  background: var(--color-accent);
  border: none;
  border-radius: 0;
  cursor: pointer;
}

.footer-volume:focus-visible {
  outline: 1px solid var(--color-accent);
  outline-offset: 4px;
}

.footer-mute {
  background: transparent;
  border: none;
  padding: 0.2rem;
  margin: 0;
  cursor: pointer;
  color: var(--color-text);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: color 100ms linear;
}

.footer-mute:hover,
.footer-mute:focus-visible {
  color: var(--color-accent);
  outline: none;
}

.footer-mute[aria-pressed='true'] {
  color: var(--color-accent);
}

.footer-mute[aria-pressed='false'] .footer-mute-x {
  display: none;
}

.footer-mute[aria-pressed='true'] .footer-mute-bars {
  display: none;
}

/* Mobile header reset button: two text variants. Full string on
   desktop, single 'forget' word on mobile to fit the compressed
   two-row layout. */
.footer-reset-short { display: none; }
.footer-mode-short { display: none; }

@media (max-width: 768px) {
  /* V21: dropped main { min-height: calc(100vh - 4.25rem) }.
     Map page main is sized by flex:1 1 0 inside the body lock;
     about page main controls its own height. */

  /* Two-row header: title on row 1, controls on row 2. V21
     restored env(safe-area-inset-top) in the top padding — the
     prior shorthand silently overrode the base rule's safe-area
     math, leaving the title under the iPhone notch when the body
     lock didn't push the page down. */
  /* V33: header/footer bar geometry comes from the mobile chrome
     token override at :root — no per-bar padding overrides needed.
     Only the type scale changes here. */
  .page-title {
    font-size: 0.875rem;
    line-height: 1.2;
  }

  /* Footer controls, phone sizing. Volume slider stays hidden
     (hardware buttons own volume on phones); reset shortens to
     one word; the mode prefix drops. V34: mode toggle anchors
     left, mute + reset cluster right (dividers dropped) — mirrors
     the loading footer's text-left / action-right grammar instead
     of scattering items and orphan dots across the bar. */
  .footer-controls {
    gap: 0.75rem;
    font-size: 0.6875rem;
  }

  .footer-divider {
    display: none;
  }

  .footer-audio-set {
    margin-left: auto;
  }

  .footer-volume,
  .footer-mode-prefix {
    display: none;
  }

  .footer-reset-full { display: none; }
  .footer-reset-short { display: inline; }

  /* V71b: modes shorten to one word each on phones (same pattern
     as the reset button above), so the whole footer keeps a single
     row: moment · record · tongue. */
  .footer-mode-full { display: none; }
  .footer-mode-short { display: inline; }

  /* V25: map pan + height-anchored sizing moved out of this media
     query — the wrapper's overflow/pan rules live on the base
     .map-wrapper, and anchoring switches via the @container
     aspect-ratio rule next to .map-bg. Phone portrait wrappers are
     far narrower than 1920/1334, so the container query covers the
     old mobile behavior identically. */

  /* Modal becomes a bottom sheet on mobile. Backdrop is dimmer
     (sheet doesn't cover full viewport) and the card slides up
     from the bottom edge. */
  .modal {
    align-items: flex-end;
    padding: 0;
    /* Sheet fills the viewport so no backdrop is visible; the dark
       fallback is for the brief slide-in window only. */
    background: rgba(10, 10, 10, 0.95);
  }

  /* V23: extend the modal to 100lvh for full-extending sheets
     (location, alley). The body-locked map page keeps iOS Safari's
     URL-bar in its expanded-pill state for the session, so 100dvh
     stops above the pill (~40px short of the device bottom on iOS
     18.7). With inset:0 from module scope still in effect, setting
     height:100lvh over-constrains the bottom property; per CSS spec
     bottom is ignored and the modal extends top:0 → y=100lvh.
     With align-items:flex-end and the card sized to 100lvh below,
     card top lands at y=0 and card bottom lands at y=100lvh.
     Tombstone variant excluded: its content-sized card needs to
     stay bottom-aligned at innerHeight (= 100dvh) so the last
     line doesn't get pushed into the URL-bar zone. :has() is
     already in active use on this codebase (Safari 16.4+ full
     support; user on iOS 18.7). */
  .modal:not(:has(.modal-card-tombstone)) {
    height: 100lvh;
  }

  .modal-card {
    width: 100%;
    max-width: 100%;
    /* V23: card extends to 100lvh so it paints through the URL-bar
       zone (~40px on iOS 18.7 in body-locked state). Combined with
       the .modal:not(:has(.modal-card-tombstone)) { height: 100lvh }
       rule above, the modal's flex-end alignment places card top at
       y=0 and card bottom at y=100lvh — visual surface reaches the
       device's physical bottom edge.
       100vh fallback for legacy browsers without lvh (Safari <15.4).
       Tombstone overrides this back to height:auto below. */
    height: 100vh;
    height: 100lvh;
    max-height: 100lvh;
    /* Prevent scroll-chaining: when the user scrolls past the sheet
       content (especially on content-sized Rash where there's empty
       space below the card), the scroll shouldn't propagate to the
       body and move the map behind the sheet. */
    overscroll-behavior: contain;
    margin: 0;
    /* No horizontal padding — each section (headline, review
       body, alley header, etc.) carries its own 1rem inset so
       the sticky headline's opaque background can extend to the
       sheet edges without leaving a strip of card content
       visible alongside it. */
    padding: 0;
    background: var(--color-bg);
    border-radius: 1rem 1rem 0 0;
    border-left: none;
    border-right: none;
    border-bottom: none;
    box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.5);
    transform: translateY(100%);
    transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }

  .modal[data-state='open'] .modal-card {
    transform: translateY(0);
  }

  /* V24 supersedes V23: V23's premise (paint reaches lvh) was
     wrong — iOS Safari clips position:fixed paint to innerHeight
     (= dvh) regardless of declared height. V24 fades the visible
     card bottom via mask-image (below) and uses fixed 64px
     padding-bottom (16 base + 48 fade clearance) so the last
     content sits clear of the fade zone. 64px fixed across
     viewports; chromium's bottom 32px softens harmlessly.
     Scoped away from .alley-modal: alley's content lives in
     .alley-cards which gets matching clearance via its own
     padding-bottom rule (see .modal-card.alley-modal .alley-cards
     below). Modal-card itself stays at padding:0 for alley so the
     scrolling rat list runs flush. */
  .modal-card:not(.modal-card-tombstone):not(.alley-modal) {
    padding-bottom: calc(var(--space-md) + 48px);
  }

  /* V24: camouflage the iOS Safari position:fixed paint clip at
     dvh. The card's bounding box extends to lvh (V23) but Safari
     clips paint to dvh, creating a hard horizontal transition at
     the dvh line that reads as a broken sheet edge above the URL
     chrome. The mask fades the card's visible bottom 32px into
     body bg, so the transition reads as "content recedes into
     chrome" rather than "sheet cut off at hard line."

     Mask coord system spans the card's lvh-height bounding box;
     stops computed in dvh so the fade lands at the visible-bottom
     position regardless of layout extent. On chromium/desktop
     where lvh === dvh, the fade lands at the card's bottom edge
     (small visual softening; not a regression).

     border-bottom:none is idempotent (mobile rule above already
     drops it). Restated here for V24's record.

     V24 is camouflage, NOT fix. Paint still stops at dvh on iOS.
     The architectural fix (switch .modal to position:absolute) is
     deferred — see "Mobile sheet sizing" in CLAUDE.md.

     References:
     - MUI material-ui#46953 (root cause)
     - Devon Govett, Sept 2025 (architectural fix recommendation)
     - Apple Community thread 256138682 (workaround landscape)
     - Safari 26.1 release notes radar 158055568 (partial fix) */
  .modal-card:not(.modal-card-tombstone) {
    border-bottom: none;
    mask-image: linear-gradient(
      to bottom,
      black 0,
      black calc(100dvh - 32px),
      transparent calc(100dvh)
    );
    -webkit-mask-image: linear-gradient(
      to bottom,
      black 0,
      black calc(100dvh - 32px),
      transparent calc(100dvh)
    );
  }

  /* With .modal-card padding zeroed, each non-sticky child gets
     its own 1rem horizontal inset so the sticky headline's
     opaque background can run edge-to-edge without a card-
     padding strip beside it. .modal-venue-headline carries its
     own 1rem padding via its mobile rule. */
  .modal-card > *:not(.modal-venue-headline) {
    padding-left: 1rem;
    padding-right: 1rem;
  }

  /* Bottom padding for the last child so content doesn't hug the
     sheet bottom edge. */
  .modal-card > *:last-child {
    padding-bottom: 1rem;
  }

  /* Drag handle is now a real DOM element inside .modal-venue-
     headline (built in modal.js). Pseudo-element approach removed
     because it competed with the sticky headline for top:0 and
     caused visible jitter / disappearance on body scroll. */

  /* Sticky title bar — carries drag handle, venue name, AND close
     button. All three pin to the top of the scrolling sheet. The
     headline is a flex column on mobile so the drag handle sits
     above the title/close row. Explicit horizontal padding matches
     other sheet content (.modal-card has 1rem horizontal); without
     this, photos that pad to the card edges would bleed past the
     title's right edge. */
  .modal-venue-headline {
    position: sticky;
    top: 0;
    z-index: 10;
    background: var(--color-bg);
    flex-direction: column;
    margin: 0 0 0.5rem;
    padding: 0.75rem 1rem 0.5rem;
    font-size: 1.375rem;
    line-height: 1.15;
    border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  }

  /* Drag handle sits at the top of the sticky title bar (above the
     title/close row). Inert (no pointer-events) — it's an
     affordance signaling drag-to-dismiss, not an interactive
     control. Gesture dismissal itself is Phase 2. Display block
     overrides the module-scope display:none that keeps the handle
     hidden on desktop. */
  .modal-sheet-handle {
    display: block;
    flex-shrink: 0;
    width: 2.5rem;
    height: 0.25rem;
    margin: 0 auto 0.5rem;
    background: rgba(255, 255, 255, 0.2);
    border-radius: 999px;
    pointer-events: none;
  }

  /* The title text + close button form the second row of the
     headline flex column. Inner flex row aligns title-left,
     close-right.
     V25: align-items:center vertically centers the title text
     against the close button. With title at ~25px tall (1.375rem
     × 1.15 line-height) and close box at ~44px tall (1.75rem font
     + 16px padding, min-height 40px), flex-start aligned tops but
     left visual centers ~9.5px apart — title read low against the
     X glyph. center aligns the visual midlines. */
  .modal-venue-headline-row {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 0.75rem;
    width: 100%;
  }

  .modal-close {
    min-width: 2.5rem;
    min-height: 2.5rem;
    margin-top: -0.25rem;
  }

  /* Alley card padding aligned with other mobile sheets. The
     module-scope rule sets 2rem on desktop; this override gives
     mobile alley zero padding so the universal child-padding
     rule above handles the 1rem inset.
     V8: explicit horizontal padding on alley children too, as
     belt-and-suspenders — the universal rule should already
     cover them, but the V7 ship reported the alley content
     edge-to-edge regardless. */
  .modal-card.alley-modal {
    padding: 0;
  }

  /* V13: restore horizontal padding on the alley headline for
     mobile only. Module-scope rule above zeroes horizontal
     padding so desktop can rely on card padding; mobile card
     padding is 0 so headline needs its own 1rem horizontal to
     match the mobile location modal title inset. */
  .modal-card.alley-modal .modal-venue-headline {
    padding: 0.75rem 1rem 0.5rem;
  }

  /* V9: rewrote as shorthand padding declarations to defeat any
     module-scope shorthand override (V8's longhand padding-left/
     right should have won by cascade order but ship reports
     showed alley still edge-to-edge — using shorthand makes the
     full padding rectangle explicit). */
  .alley-header {
    padding: 0 1rem var(--space-sm);
  }

  /* Rat cards span the full sheet width — no horizontal padding
     on .alley-cards container. Each .alley-card carries its own
     1rem L/R padding aligning the selfie/text/speaker icons with
     the photo and title above (all at 1rem from sheet edge).
     Bumped to .modal-card.alley-modal .alley-cards (specificity
     0,3,0) to beat the universal `.modal-card > *:not(.modal-
     venue-headline) { padding-left/right: 1rem }` rule (0,2,0).
     V24: bottom value bumped from var(--space-lg) (32px) to 64px
     to match the non-alley fade-clearance buffer. Last mini-card
     now ends above the V24 mask's fade zone. */
  .modal-card.alley-modal .alley-cards {
    padding: 0 0 calc(var(--space-md) + 48px);
  }

  .alley-card {
    padding: 1rem;
  }

  .alley-cards-hint {
    padding: 0 1rem;
  }

  /* Photo inside alley-header fills the padded container width
     without adding its own margin. */
  .alley-header .modal-venue-photo {
    width: 100%;
    margin: 0;
  }

  /* Sheet fills the viewport; remove the page header and footer
     from layout entirely so the sheet sits flush at the top edge
     and no control bar competes with the sheet's own controls. */
  body:has(.modal[data-state='open']) .page-header,
  body:has(.modal[data-state='open']) .page-footer {
    display: none;
  }

  /* Lock body scroll when a modal is open. Without this, content-
     sized sheets (Rash tombstone) leave empty space below the card
     where touch-drag can scroll the map behind the sheet. */
  body:has(.modal[data-state='open']) {
    overflow: hidden;
    touch-action: none;
  }

  /* Modal itself still accepts touch (close button taps, sheet
     drag affordance, etc.) — the touch-action lock on body only
     blocks the propagation path through body to .map-wrapper. */
  body:has(.modal[data-state='open']) .modal {
    touch-action: auto;
  }

  /* Tighten body typography for the sheet. */
  .reviewer-handle,
  .reviewer-location,
  .modal-rat-handle,
  .modal-rat-location {
    font-size: 0.8125rem;
    line-height: 1.35;
  }

  .review-text,
  .modal-review {
    font-size: 0.875rem;
    line-height: 1.55;
  }

  /* V36 made natural-height review text the base behavior; this
     block now only clears the desktop tail-rhythm margins. */
  .review-body {
    height: auto;
    -webkit-mask-image: none;
  }

  /* Rat metadata centers under the centered rat photo. Body text
     stays left-aligned (long prose reads better that way) — only
     name/handle/location/elite cluster. */
  .reviewer-info,
  .reviewer-meta {
    text-align: center;
  }

  .review-rating {
    justify-content: center;
  }

  /* V7: removed tombstone height overrides — with the Rash
     epitaph added in V6, the sheet fills 100dvh naturally via
     the default .modal-card rule. The .modal-card-tombstone
     class remains on the element for any future variant styling. */

  /* V45: same overlay transport on mobile — the scope is the
     full-width player, button riding its right edge. */
  .play-button {
    width: 44px;
    height: 44px;
    left: 10px;
  }

  .modal-card .oscilloscope,
  .modal-oscilloscope {
    height: 72px;
  }

  .squeak-count,
  .reaction-buttons {
    font-size: 0.6875rem;
  }

  /* Pin labels (mobile only); alley pin stays unlabeled. Label
     z-index sits above neighbor pins (z-index 1) so it doesn't get
     obscured. Active pin (hover/focus) jumps to z-index 3 above
     everything. */
  .pin {
    overflow: visible;
    z-index: 1;
  }

  .pin:hover,
  .pin:focus-visible {
    z-index: 3;
  }

  /* Pill-style label: dark background, thin border, blurred
     backdrop. Serif body to read as hand-drawn annotation rather
     than UI chrome. */
  .pin-label {
    display: block;
    position: absolute;
    top: 100%;
    left: 50%;
    transform: translateX(-50%);
    margin-top: 0.3rem;
    white-space: nowrap;
    font-family: var(--font-editorial);
    font-style: normal;
    font-weight: normal;
    font-size: 0.6875rem;
    line-height: 1;
    color: var(--color-text);
    background: rgba(10, 10, 10, 0.85);
    padding: 0.25rem 0.55rem;
    border-radius: 999px;
    border: 1px solid rgba(255, 255, 255, 0.08);
    letter-spacing: 0;
    text-transform: none;
    pointer-events: none;
    backdrop-filter: blur(2px);
    -webkit-backdrop-filter: blur(2px);
    z-index: 2;
  }

  /* Per-venue position overrides via data-position attribute set
     in setupPinPositions(). Default (no attribute) keeps the
     bottom layout above. */
  .pin-label[data-position='top'] {
    top: auto;
    bottom: 100%;
    left: 50%;
    transform: translateX(-50%);
    margin-top: 0;
    margin-bottom: 0.3rem;
  }

  .pin-label[data-position='left'] {
    top: 50%;
    bottom: auto;
    left: auto;
    right: calc(100% + 0.3rem);
    transform: translateY(-50%);
    margin: 0;
  }

  .pin-label[data-position='right'] {
    top: 50%;
    bottom: auto;
    left: calc(100% + 0.3rem);
    right: auto;
    transform: translateY(-50%);
    margin: 0;
  }

  .pin[data-pin-id='alley'] .pin-label {
    display: none;
  }

  /* Toast lift on mobile so it doesn't sit under any fixed CTA. */
  .alley-locked-toast {
    bottom: 5rem;
    font-size: 0.75rem;
  }
}

/* Pin tooltip — locked-alley variant. Wider wrap for the longer
   gating message; centered text; no photo/CTA (populate() bails
   early for the locked case). */
.pin-tooltip.pin-tooltip-locked {
  width: auto;
  max-width: 18rem;
  white-space: normal;
  text-align: center;
  padding: 0.75rem 0.875rem;
}

.pin-tooltip-locked-message {
  font-family: var(--font-ui);
  font-size: 0.8125rem;
  line-height: 1.4;
  letter-spacing: 0.02em;
  color: var(--color-text);
}

/* Second mobile media block: review-card body + pin chrome.
   Originally lost its enclosing @media wrapper at some point in
   the file's history; wrapped explicitly here so the rules apply
   only on mobile as intended. */
@media (max-width: 768px) {
  .reviewer-block {
    /* V44: back to a simple stacked flow on phones — the selfie
       returns to the normal flow, centered above the meta. */
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-md);
    padding-left: 0;
    padding-right: 0;
    min-height: 0;
  }

  .reviewer-info {
    width: 100%;
    /* V48: the desktop 200px floor exists to meet the square
       portrait's height; the stacked mobile flow sizes naturally. */
    min-height: 0;
  }

  /* V48 spacing pass: tighter top chrome (pagination now occupies
     a row), and the content carries its own bottom inset since
     the DOM-last pagination moved visually to the top. */
  .review-content {
    /* V49: clear air between the pagination bar and the portrait. */
    margin-top: 1rem;
    padding-bottom: 1.5rem;
  }

  .review-rating {
    margin: 0.5rem 0 0;
  }

  /* Rat photo: 70% sheet width centered, square aspect, no
     radius (V7: rounded corners removed across photos). */
  .rat-selfie {
    position: static;
    /* V48: trimmed from 70% — the portrait was the sheet's
       biggest scroll cost; 58% keeps it a portrait moment while
       pulling the player and text a full viewport-line closer. */
    width: 58%;
    max-width: 58%;
    height: auto;
    aspect-ratio: 1 / 1;
    object-fit: cover;
    border-radius: 0;
    margin: 0.25rem auto 0.5rem;
    display: block;
  }

  .reviewer-name {
    font-size: 1.125rem;
  }

  .reviewer-elite {
    margin-top: var(--space-sm);
  }

  .star {
    width: 18px;
    height: 18px;
  }

  .pin img {
    width: 70px;
    height: 70px;
  }

  /* Alley + tombstone photos go full-width on mobile. Square
     corners per V7. */
  .modal-venue-photo,
  .modal-venue-photo img {
    width: 100%;
    max-width: 100%;
    height: auto;
    border-radius: 0;
    margin: 0.5rem 0;
  }

  /* Tombstone (Rash) photo: V9 dropped aspect-ratio. V11 also
     removes the module-scope wrapper's rotation/border/bg since
     those are stylistic chrome that doesn't fit a tombstone
     marker. The photo displays at natural ratio with the same
     1rem horizontal inset as other sheet content (via the
     universal child-padding rule). Accent border on the img
     itself rather than the wrapper. */
  .modal-card-tombstone .modal-venue-photo {
    aspect-ratio: auto;
    width: 100%;
    overflow: visible;
    /* Top margin matches the .review-content top margin used by
       location modals (1.5rem) so the gap between sticky title
       bar bottom and the photo's top reads consistently across
       tombstone and location sheets. */
    margin: 1.5rem 0 1rem;
    transform: none;
    border: none;
    background: none;
  }

  .modal-card-tombstone .modal-venue-photo img {
    width: 100%;
    height: auto;
    object-fit: initial;
    border-radius: 0;
    margin: 0;
    display: block;
    border: 1px solid var(--color-accent);
  }

  /* V11: Rash tombstone sheet sizes to content, not full viewport.
     Slides up from bottom but stops where content ends. Feels
     like a card rather than a full-height interactive sheet. */
  .modal-card.modal-card-tombstone {
    height: auto;
    min-height: auto;
    max-height: 100dvh;
  }

  /* V58: chips take the mobile pagination buttons' treatment —
     chunky bordered touch targets sharing the row width (2×2
     grid), bigger text. Report wraps to its own line beneath. */
  .review-reactions {
    gap: 0.5rem;
  }

  .review-reaction {
    flex: 1 1 calc(50% - 0.25rem);
    justify-content: center;
    padding: 0.75rem 0.5rem;
    font-size: 0.8125rem;
    min-height: 3rem;
  }

  .review-report {
    flex-basis: 100%;
    margin: 0.5rem 0 0;
    text-align: center;
    font-size: 0.75rem;
  }

  /* V48: pagination rides directly under the sticky headline on
     mobile — flip between reviews the moment the sheet opens, no
     scroll-to-the-end required. The card becomes a flex column so
     `order` can lift the DOM-last pagination to the top without
     touching the desktop pinned-bar markup. */
  .modal-card:has(.review-pagination) {
    display: flex;
    flex-direction: column;
  }

  .modal-venue-headline {
    order: -2;
  }

  .review-pagination {
    order: -1;
    position: static;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    padding: 0.375rem 1rem 0.5rem;
    margin: 0;
    gap: 1rem;
    border-top: none;
    border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  }

  .pagination-arrow {
    min-width: 3rem;
    min-height: 3rem;
    font-size: 1.25rem;
    padding: 0.5rem 1rem;
    background: transparent;
    color: var(--color-accent);
    border: 1px solid var(--color-accent);
    border-radius: 0;
  }

  .pagination-arrow:disabled {
    opacity: 0.3;
    cursor: not-allowed;
  }

  .pagination-counter {
    font-size: 0.75rem;
    opacity: 0.7;
  }

  /* V11: alley photo displays at natural ratio. V10's 16/9
     forced ratio caused black banding because the source isn't
     16/9. V13: also stripped the module-scope polaroid styling
     (transform:rotate(-0.6deg) + border + background) which was
     producing visible bands at the wrapper edges where the
     rotated photo didn't fill its AABB. The alley photo wants
     a clean rectangular display, not the polaroid treatment
     used on review-card venue photos. */
  .alley-modal .modal-venue-photo,
  .alley-photo,
  .alley-hero-image {
    aspect-ratio: auto;
    overflow: visible;
    transform: none;
    border: none;
    background: none;
  }

  .alley-modal .modal-venue-photo img {
    width: 100%;
    height: auto;
    object-fit: initial;
    object-position: center 30%;
    display: block;
  }

  /* V11: clearance between alley photo and the "where the rats
     meet" framing line below. Matches location-sheet rhythm. */
  .alley-modal .modal-venue-photo {
    margin-bottom: 1.5rem;
  }

  /* Tombstone epitaph: tighter type for the sheet width. */
  .tombstone-epitaph {
    max-width: 100%;
    margin-top: 1.25rem;
  }

  .tombstone-epitaph p {
    font-size: 0.875rem;
    line-height: 1.55;
  }
}

.headphones-overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-md);
  background: rgba(10, 10, 10, 0.85);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
}

.headphones-overlay-card {
  width: 100%;
  max-width: 480px;
  background: var(--color-bg);
  border: 1px solid var(--color-text);
  padding: var(--space-lg);
  text-align: center;
}

.headphones-overlay-title {
  font-family: var(--font-editorial);
  font-style: italic;
  font-weight: normal;
  font-size: 1.5rem;
  margin: 0 0 var(--space-md);
  color: var(--color-text);
}

.headphones-overlay-text {
  font-family: var(--font-data);
  font-size: 0.9375rem;
  line-height: 1.5;
  margin: 0 0 var(--space-lg);
  color: var(--color-text);
  opacity: 0.9;
}

.headphones-overlay-button {
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.875rem;
  background: transparent;
  color: var(--color-text);
  border: 1px solid var(--color-accent);
  padding: var(--space-sm) var(--space-lg);
  cursor: pointer;
}

.headphones-overlay-button:hover,
.headphones-overlay-button:focus-visible {
  background: var(--color-accent);
  color: var(--color-bg);
  outline: none;
}

.headphones-badge {
  position: fixed;
  bottom: var(--space-md);
  right: var(--space-md);
  z-index: 100;
  font-family: var(--font-ui);
  font-size: 0.6875rem;
  letter-spacing: 0.12em;
  color: var(--color-text);
  padding: 0.4rem 0.75rem;
  background: rgba(10, 10, 10, 0.7);
  border: 1px solid var(--color-text);
  cursor: pointer;
}

.headphones-badge:hover,
.headphones-badge:focus-visible {
  border-color: var(--color-accent);
  color: var(--color-accent);
  outline: none;
}

.about-page {
  background: var(--color-bg);
  color: var(--color-text);
  /* No body-level safe-area padding. Applying env(safe-area-inset-
     top) to body stacked with .about-header's own env() padding —
     paying the inset cost twice and exposing body background as a
     visible strip between the notch and the header content. The
     html/body shared background var(--color-bg) fills the notch
     zone, and the .about-header below carries its own env() so
     content lands cleanly under the status bar. */
}

/* About-header stays sticky on desktop too so the back-to-map
   link is always reachable during long-page scroll, but with no
   background — the user wanted no black band over scrolling
   content. The V17 ::before pseudo inherits the parent bg
   (transparent here on desktop) so it paints nothing visible.
   Mobile adds the var(--color-bg) so the link reads clearly
   against scrolling phone content and the safe-area-inset-top
   keeps the link clear of the iPhone status bar. */
.about-header {
  /* V34: shared chrome tokens — same bar geometry as the map and
     loading headers (padding, min-height, centered midline), so
     the back link sits on the same line the map title does.
     Background stays the deliberate split: transparent on desktop
     (content scrolls under bare), solid on mobile. */
  padding: calc(var(--chrome-pad-y) + env(safe-area-inset-top)) var(--chrome-pad-x) var(--chrome-pad-y);
  min-height: var(--chrome-bar-h);
  display: flex;
  align-items: center;
  position: sticky;
  top: 0;
  z-index: 100;
  /* Compositor-layer promotion: see .page-header rule for full
     rationale. Same hard-flick flicker risk applies. */
  transform: translateZ(0);
  will-change: transform;
}

.about-back {
  display: inline-block;
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.75rem;
  color: var(--color-text);
  text-decoration: none;
  border-bottom: 1px solid currentColor;
}

.about-back:hover,
.about-back:focus-visible {
  color: var(--color-accent);
  border-bottom-color: var(--color-accent);
  outline: none;
}


.about-main {
  /* Override the global `main { display: flex }` rule (which the
     index page needs for map centering). The about page's main
     should be normal block flow so its sections stack vertically
     instead of becoming flex items in a row. */
  display: block;
  max-width: 640px;
  margin: 0 auto;
  padding: var(--space-lg);
  font-family: var(--font-editorial);
  font-size: 1.0625rem;
  line-height: 1.6;
}

.about-title {
  font-family: var(--font-editorial);
  font-style: italic;
  font-weight: normal;
  font-size: 2rem;
  margin: 0 0 var(--space-lg);
}

.about-epigraph {
  margin: 0 0 var(--space-lg);
  padding: var(--space-md) var(--space-lg);
  border-left: 2px solid var(--color-accent);
  font-style: italic;
}

.about-epigraph p {
  margin: 0 0 var(--space-sm);
  font-size: 1.125rem;
}

.about-epigraph cite {
  display: block;
  font-family: var(--font-data);
  font-style: normal;
  font-size: 0.8125rem;
  letter-spacing: 0.05em;
  opacity: 0.8;
}

.about-section {
  margin-bottom: var(--space-lg);
}

/* V61: the About page ends with the loading screen's scuttle —
   a strip the rat crew crosses periodically (about-rats.js reuses
   the .loading-rat classes and keyframes). */
.about-rat-footer {
  position: relative;
  height: 4.5rem;
  overflow: hidden;
}

.about-rat-footer .loading-rat {
  bottom: 1.5rem;
}

.about-section h2 {
  font-family: var(--font-ui);
  letter-spacing: 0.02em;
  font-size: 0.8125rem;
  font-weight: normal;
  color: var(--color-accent);
  margin: 0 0 var(--space-md);
}

.about-section p {
  margin: 0 0 var(--space-md);
}

.about-section ul {
  margin: 0;
  padding-left: var(--space-lg);
}

.about-section li {
  margin-bottom: var(--space-sm);
}

/* Credits — block-flow dl with green dt headers above each dd. The
   prior grid layout (max-content 1fr columns) cramped long URLs in
   the right cell; switching to single-column block flow lets long
   Freesound URLs and citation blocks breathe. */
.credit-list {
  margin: 0;
  font-family: var(--font-data);
  font-size: 0.9375rem;
}

.credit-list dt {
  color: var(--color-accent);
  font-family: var(--font-ui);
  font-size: 0.85rem;
  letter-spacing: 0.02em;
  margin-top: 1.5rem;
  margin-bottom: 0.4rem;
}

.credit-list dd {
  color: var(--color-text);
  margin-left: 0;
  line-height: 1.5;
}

.credit-list a {
  color: var(--color-text);
  text-decoration: underline;
  text-decoration-color: var(--color-accent);
}

/* About page links: V10 reverted from accent-green default to
   body-text-colored default. The all-green treatment from V7 was
   visually overwhelming. Underline is the affordance; accent
   green only on hover/focus. */
.about-page a,
.about-page a:visited {
  color: var(--color-text);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
}

.about-page a:hover,
.about-page a:focus-visible {
  color: var(--color-accent);
  text-decoration-thickness: 2px;
  outline: none;
}

/* Override .credit-list and .about-back which set their own color
   so the about-page rule above is the single source of truth. */
.about-page .credit-list a,
.about-page .credit-list a:visited,
.about-page .about-back,
.about-page .about-back:visited {
  color: var(--color-text);
}

.about-page .credit-list a:hover,
.about-page .about-back:hover {
  color: var(--color-accent);
}

/* V10: subheader spacing — paragraph above section headers needed
   more clearance. ~2rem top margin on h2/h3 inside about content. */
.about-section h2,
.about-section h3,
.about-section .freesound-subhead {
  /* V12: bumped specificity to .about-section .freesound-subhead
     (0,2,0) to beat .about-section p (0,1,1) which was setting
     margin-top:0 via the shorthand. V10's grouped rule with bare
     .freesound-subhead (0,1,0) lost the cascade. */
  margin-top: 2rem;
}

.about-section > h2:first-child,
.about-section > h3:first-child {
  margin-top: 0;
}

.footnote {
  font-size: 0.85em;
  opacity: 0.7;
  font-style: italic;
}

/* Credit-list footnotes (e.g. "Real Bushwick venue names are
   used. Reviews and rat personae are fictional.") need a real
   paragraph gap above. The preceding line is inline (no margin-
   bottom), and .about-section p has margin-top: 0 — without an
   explicit top margin here, the footnote reads as one more line
   in the same credit entry rather than a separate paragraph. */
.credit-list .footnote {
  margin-top: 1.5rem;
}

.freesound-list ul {
  list-style: none;
  padding-left: 0;
  margin: 0.4rem 0 1rem;
  font-size: 0.85rem;
  line-height: 1.6;
}

.freesound-list li {
  margin-bottom: 0.3rem;
}

.about-section .freesound-subhead {
  color: var(--color-accent);
  font-family: var(--font-ui);
  font-size: 0.75rem;
  letter-spacing: 0.02em;
  /* V12: bumped to .about-section .freesound-subhead (0,2,0) so
     this rule beats .about-section p (0,1,1) which was setting
     margin-top:0 via shorthand. Spacing from preceding paragraph
     is the load-bearing property here. */
  margin-top: 2rem;
  margin-bottom: 0.4rem;
  opacity: 0.85;
}

.freesound-tachibana {
  font-size: 0.9rem;
  line-height: 1.5;
  margin-top: 0.4rem;
}

.epigraph blockquote {
  border-left: 2px solid var(--color-accent);
  padding-left: 1.25rem;
  margin: 3rem 0;
}

.epigraph blockquote p {
  font-style: italic;
  font-size: 1.15rem;
  margin: 0 0 0.5rem;
}

.epigraph cite {
  display: block;
  margin-top: 0.5rem;
  font-family: var(--font-data);
  font-size: 0.9rem;
  font-style: normal;
  letter-spacing: 0.02em;
  opacity: 0.8;
}

@media (max-width: 768px) {
  .about-main {
    padding: var(--space-md);
    font-size: 1rem;
  }

  .about-title {
    font-size: 1.625rem;
  }

  .about-credits dl {
    grid-template-columns: 1fr;
    gap: 0;
  }

  .about-credits dt {
    margin-top: var(--space-sm);
  }

  .about-credits dd {
    margin-bottom: var(--space-sm);
  }

  .headphones-overlay-card {
    padding: var(--space-md);
  }

  .headphones-overlay-title {
    font-size: 1.25rem;
  }

  .headphones-badge {
    font-size: 0.625rem;
    bottom: var(--space-sm);
    right: var(--space-sm);
  }
}

/* ===== Loading screen (paste-up field-notes overlay) ===== */

.loading-screen {
  position: fixed;
  inset: 0;
  z-index: 9999;
  background: var(--color-bg);
  color: var(--color-text);
  overflow: hidden;
  width: 100%;
  /* 100dvh covers the dynamic mobile viewport (address-bar
     collapse aware); 100vh fallback for browsers without dvh.
     Safe-area-inset-bottom keeps the background through the
     iOS home-indicator strip so map content underneath can't
     peek through at the bottom edge. */
  height: 100vh;
  height: 100dvh;
  /* V30: no container padding — the header carries the full
     .page-header bar geometry itself (shared chrome tokens), and
     cards/returning content self-center. No bottom chrome; the
     scuttle rats own the bottom band. */
  padding: 0;
  display: flex;
  flex-direction: column;
  opacity: 1;
  transition: opacity 700ms ease-out;
}

.loading-screen[data-state="entering"] {
  opacity: 0;
  pointer-events: none;
}

.loading-noise {
  position: absolute;
  inset: -10%;
  pointer-events: none;
  opacity: 0.06;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.95' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.77  0 0 0 0 0.75  0 0 0 0 0.68  0 0 0 0.5 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
  animation: loadingNoiseShift 0.55s steps(6) infinite;
}

@keyframes loadingNoiseShift {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(-3%, 2%); }
  40%  { transform: translate(2%, -3%); }
  60%  { transform: translate(-2%, -1%); }
  80%  { transform: translate(3%, 1%); }
  100% { transform: translate(0, 0); }
}

.loading-header {
  position: relative;
  z-index: 2;
  display: flex;
  justify-content: space-between;
  /* V30: this IS the .page-header bar — same tokens, same border,
     same centered midline — so every piece of top chrome (title,
     status, button here; title, about there) holds its position
     through the loading→map fade. */
  align-items: center;
  gap: 1rem;
  padding: calc(var(--chrome-pad-y) + env(safe-area-inset-top)) var(--chrome-pad-x) var(--chrome-pad-y);
  min-height: var(--chrome-bar-h);
  border-bottom: 1px solid rgba(184, 255, 0, 0.15);
}

.loading-title {
  font-family: var(--font-editorial);
  font-style: italic;
  font-weight: normal;
  /* V12 P6: bumped from 1.125rem to 1.25rem to match .page-title
     so the title doesn't visibly resize during the fade-out
     transition from loading screen to map view. Italic added for
     the same reason — page-title is italic by design. */
  font-size: 1.25rem;
  /* Fixed line box matching .page-title; see .page-header. */
  line-height: 1.75rem;
  letter-spacing: 0.01em;
  margin: 0;
  opacity: 0.85;
}

.loading-cards {
  position: relative;
  z-index: 2;
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

.loading-card {
  --card-rotate: 0deg;
  --card-x: 0rem;
  --card-y: 0rem;
  --drag-x: 0px;
  --drag-y: 0px;
  position: absolute;
  width: min(440px, 90vw);
  background: var(--color-text);
  color: var(--color-bg);
  padding: 1.75rem 2rem;
  box-shadow:
    0 1px 0 rgba(0, 0, 0, 0.3),
    0 8px 18px rgba(0, 0, 0, 0.45);
  opacity: 0;
  cursor: grab;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  transform:
    translate(
      calc(var(--card-x) + var(--drag-x)),
      calc(var(--card-y) + var(--drag-y) + 12px)
    )
    rotate(var(--card-rotate));
  transition:
    opacity 600ms cubic-bezier(0.4, 0, 0.2, 1),
    transform 600ms cubic-bezier(0.4, 0, 0.2, 1);
}

.loading-card[data-state="visible"] {
  opacity: 1;
  transform:
    translate(
      calc(var(--card-x) + var(--drag-x)),
      calc(var(--card-y) + var(--drag-y))
    )
    rotate(var(--card-rotate));
}

/* V32: cards track their viewport-relative positions instantly
   during a live resize — the 600ms reveal transition would restart
   on every resize frame and ease toward stale targets (jitter,
   then a snap when the resize stops). loading-screen.js debounces
   the class off 150ms after the last resize event. */
.loading-screen.is-resizing .loading-card {
  transition: none;
}

.loading-card.is-dragging {
  cursor: grabbing;
  transition: none;
  box-shadow:
    0 2px 0 rgba(0, 0, 0, 0.4),
    0 18px 36px rgba(0, 0, 0, 0.6);
}

/* V32: scatter offsets scale with the viewport (vw for the spread,
   vh for the vertical drift) instead of fixed rem distances — the
   paste-up breathes apart on wide screens and pulls together on
   narrow or short ones. clamp() bounds keep every card fully
   on-canvas at the extremes. The old fixed values are the clamp
   midpoints at a ~1280×900 viewport. */
.loading-card[data-card-index="0"] {
  --card-rotate: -2deg;
  --card-x: clamp(-16rem, -13vw, -6rem);
  --card-y: clamp(-9rem, -14vh, -4rem);
  z-index: 1;
}

.loading-card[data-card-index="1"] {
  --card-rotate: 1.2deg;
  --card-x: clamp(5rem, 12vw, 15rem);
  --card-y: 1rem;
  z-index: 2;
}

.loading-card[data-card-index="2"] {
  --card-rotate: -1deg;
  --card-x: clamp(-5rem, -2.5vw, -1rem);
  --card-y: clamp(4rem, 16vh, 10rem);
  z-index: 3;
}

.loading-card-label {
  font-family: var(--font-data);
  font-size: 0.6875rem;
  letter-spacing: 0.04em;
  opacity: 0.65;
  margin-bottom: 0.875rem;
  border-bottom: 1px solid rgba(10, 10, 10, 0.18);
  padding-bottom: 0.625rem;
}

.loading-card-body p {
  font-family: var(--font-editorial);
  font-size: 0.9375rem;
  line-height: 1.5;
  margin: 0 0 0.5rem 0;
}

.loading-card-body p:last-child {
  margin-bottom: 0;
}

.loading-card-body p.is-quote {
  font-style: italic;
  margin: 0.75rem 0;
  padding-left: 0.875rem;
  border-left: 2px solid var(--color-accent);
}

.loading-rat {
  position: absolute;
  /* V33: run just above the footer-as-button bar. Token-derived,
     so the track follows the bar across breakpoints. */
  bottom: calc(var(--chrome-bar-h) + env(safe-area-inset-bottom) + 2px);
  left: 0;
  right: 0;
  height: 16px;
  /* Opaque stand-in for text-at-50%-over-bg: rats run at full
     opacity (see the scuttle keyframes) so overlapping crew
     members don't double up darker where they cross — the subtle
     tint is baked into the color instead of an alpha channel. */
  color: color-mix(in srgb, var(--color-text) 50%, var(--color-bg));
  pointer-events: none;
  z-index: 2;
}

.loading-rat svg {
  position: absolute;
  bottom: 0;
  left: 0;
  opacity: 0;
}

/* Scuttle: rats cross in dart-pause-dart bursts, not a linear
   marquee. The intermediate keyframe pairs (e.g. 15% → 21% barely
   moving) are the sniff-stops; ease-in-out makes each dash
   accelerate out of the stop and brake into the next one. JS sets
   per-rat animation-delay/-duration inline so a crew desyncs. */
.loading-rat.is-running[data-direction="ltr"] svg {
  animation: ratScuttleLTR 6s ease-in-out forwards;
}

.loading-rat.is-running[data-direction="rtl"] svg {
  animation: ratScuttleRTL 6s ease-in-out forwards;
}

@keyframes ratScuttleLTR {
  0%   { transform: translateX(-8vw) scaleX(1); opacity: 0; }
  6%   { opacity: 1; }
  15%  { transform: translateX(16vw) scaleX(1); }
  21%  { transform: translateX(17.5vw) scaleX(1); }
  36%  { transform: translateX(46vw) scaleX(1); }
  41%  { transform: translateX(47vw) scaleX(1); }
  56%  { transform: translateX(72vw) scaleX(1); }
  63%  { transform: translateX(73.5vw) scaleX(1); }
  94%  { opacity: 1; }
  100% { transform: translateX(112vw) scaleX(1); opacity: 0; }
}

@keyframes ratScuttleRTL {
  0%   { transform: translateX(112vw) scaleX(-1); opacity: 0; }
  6%   { opacity: 1; }
  15%  { transform: translateX(88vw) scaleX(-1); }
  21%  { transform: translateX(86.5vw) scaleX(-1); }
  36%  { transform: translateX(58vw) scaleX(-1); }
  41%  { transform: translateX(57vw) scaleX(-1); }
  56%  { transform: translateX(32vw) scaleX(-1); }
  63%  { transform: translateX(30.5vw) scaleX(-1); }
  94%  { opacity: 1; }
  100% { transform: translateX(-8vw) scaleX(-1); opacity: 0; }
}

/* Body bob rides on the inner group so it composes with the svg's
   translate keyframes: a quick squash-and-hop cycle around the
   ground line (fill-box origin at the feet). It keeps running
   through the sniff-stops, which is what sells them as sniffing.
   Per-rat rhythm via --bob-duration/--bob-delay set inline. */
.loading-rat .rat-body {
  transform-box: fill-box;
  transform-origin: 50% 100%;
}

/* Hairline bg-colored outline so crew members stay readable as
   separate animals where they overlap. paint-order:stroke keeps
   the stroke behind the fill, so the silhouette's shape is not
   thinned — the outline grows outward only. Width is in viewBox
   units (438-wide box drawn at ~28px ≈ 1/16 scale): 16 units ≈
   half a rendered pixel of visible rim. */
.loading-rat path {
  stroke: var(--color-bg);
  stroke-width: 16;
  stroke-linejoin: round;
  paint-order: stroke;
}

.loading-rat.is-running .rat-body {
  animation: ratBob var(--bob-duration, 240ms) ease-in-out var(--bob-delay, 0ms) infinite alternate;
}

@keyframes ratBob {
  from { transform: translateY(0) scaleY(1) rotate(0.6deg); }
  to   { transform: translateY(-4%) scaleY(0.93) rotate(-0.8deg); }
}

/* V33: the footer bar IS the button (a <button> element — see
   buildFooter). Same bar geometry as .page-footer via the shared
   chrome tokens, so the loading footer dissolves into the map
   footer on the same lines. Status message left, action label
   right; tapping anywhere on the bar acts. States:
   skip (dim label, acts as skip) → waiting (disabled) →
   ready (solid accent bar, bg-color text). V74: .is-late (preload
   slow or failed) deliberately has no rules — it renders as
   waiting, just enabled, so it never reads as ready. */
.loading-footer {
  position: relative;
  z-index: 2;
  margin-top: auto;
  appearance: none;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  background: transparent;
  border: none;
  border-top: 1px solid rgba(184, 255, 0, 0.15);
  color: var(--color-text);
  text-align: left;
  padding: var(--chrome-pad-y) var(--chrome-pad-x) calc(var(--chrome-pad-y) + env(safe-area-inset-bottom));
  min-height: calc(var(--chrome-bar-h) + env(safe-area-inset-bottom));
  cursor: pointer;
  transition: background 300ms ease, border-color 300ms ease, color 300ms ease;
}

.loading-footer:disabled {
  cursor: default;
}

.loading-footer:focus-visible {
  outline: none;
  border-top-color: var(--color-accent);
}

.loading-status {
  font-family: var(--font-data);
  font-size: 0.8125rem;
  letter-spacing: 0.02em;
  opacity: 0.7; /* V66: AA floor */
  margin: 0;
  min-height: 1.2em; /* reserve line height during typewriter clears */
}

.loading-footer-label {
  font-family: var(--font-ui);
  font-size: 0.9375rem;
  letter-spacing: 0.04em;
  opacity: 0.35;
  white-space: nowrap;
  transition: opacity 300ms ease;
}

.loading-footer.is-skip .loading-footer-label {
  font-family: var(--font-data);
  font-size: 0.75rem;
  letter-spacing: 0.05em;
  opacity: 0.7; /* V66: AA floor — skip is interactive */
}

.loading-footer.is-skip:hover .loading-footer-label,
.loading-footer.is-skip:focus-visible .loading-footer-label {
  opacity: 1;
}

/* Ready: the whole bar lights up — accent background, no border,
   text in the page bg color. */
.loading-footer.is-ready {
  background: var(--color-accent);
  border-top-color: transparent;
  color: var(--color-bg);
}

.loading-footer.is-ready .loading-status {
  opacity: 0.7;
}

.loading-footer.is-ready .loading-footer-label {
  opacity: 1;
}

/* Returning-visitor variant */
.loading-screen.is-returning .loading-returning {
  position: relative;
  z-index: 2;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  text-align: center;
}

.loading-greeting {
  font-family: var(--font-editorial);
  font-size: 1.25rem;
  letter-spacing: 0.01em;
  margin: 0;
  opacity: 0.85;
}

@media (max-width: 768px) {
  .loading-screen {
    padding: 0;
  }

  .loading-title {
    /* Match mobile .page-title metrics exactly. */
    font-size: 0.875rem;
    line-height: 1.2;
  }

  /* Cards area scrolls independently; status + CTA stay fixed at
     bottom. V9: removed the mask-image fade — V8 sizing made
     all three cards fit without scrolling, so the fade is now
     misleading (suggests scrollable content where none exists). */
  .loading-cards {
    flex: 1 1 auto;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    gap: 1rem;
    padding: 1rem 0 1rem;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    mask-image: none;
    -webkit-mask-image: none;
  }

  /* V7 tightened the three loading cards but V8 listen-test
     reported the last paragraph of card 03 still clipping on
     iPhone 13. Pushing sizes down another notch (Option A from
     the V8 prompt — content trim is the next escalation if
     this still doesn't fit). */
  .loading-card {
    position: relative;
    --card-rotate: 0deg;
    --card-x: 0rem;
    --card-y: 0rem;
    /* Drag offsets are tracked but not consumed on mobile — the
       overrides below use simple translateY for the stacked layout,
       so any drag deltas the JS writes are visually inert here. */
    --drag-x: 0px;
    --drag-y: 0px;
    width: min(380px, 100%);
    transform: translateY(12px);
    padding: 0.75rem 1rem;
    margin-bottom: 0.375rem;
  }

  .loading-card[data-state="visible"] {
    transform: translateY(0);
  }

  .loading-card-label {
    font-size: 0.5625rem;
    letter-spacing: 0.05em;
    margin-bottom: 0.375rem;
    padding-bottom: 0.375rem;
  }

  .loading-card-body p {
    font-size: 0.875rem;
    line-height: 1.4;
    margin-bottom: 0.375rem;
  }

  .loading-card-body p:last-child {
    margin-bottom: 0;
  }

  .loading-card-body p.is-quote {
    font-size: 0.8125rem;
    margin: 0.375rem 0;
    padding-left: 0.625rem;
  }

  .loading-greeting {
    font-size: 1rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .loading-noise { animation: none; }
  .loading-card { transition-duration: 200ms; }
  .loading-rat.is-running[data-direction="ltr"] svg,
  .loading-rat.is-running[data-direction="rtl"] svg {
    animation-duration: 9s;
  }
  /* No squash-and-hop under reduced motion; the slow glide above
     is the whole show. (The 9s duration is also enforced from JS
     since per-rat inline durations would override this rule.) */
  .loading-rat .rat-body {
    animation: none;
  }
}

/* ===== V11 desktop-only overrides ===== */
@media (min-width: 769px) {
  /* Locked-alley toast: desktop has hover tooltip carrying the
     same gating message — toast on click is redundant. Suppress
     on wide viewports; mobile keeps it (no hover state). */
  .alley-locked-toast {
    display: none !important;
  }

  /* Tombstone (Rash) on desktop: no special height override.
     V11 added height:auto + max-height:80vh, which made the
     sheet shorter than other modals (which use the default
     .modal-card { max-height: 90vh }). V13 removes the override
     so tombstone matches location modal height behavior.
     The photo still gets a max-height cap to prevent unusually
     tall sources from dominating the modal. */
  .modal-card-tombstone .modal-venue-photo {
    margin: 1.5rem auto 1rem;
  }

  .modal-card-tombstone .modal-venue-photo img {
    max-height: 50vh;
    object-fit: contain;
  }

  /* Desktop location-modal tail rhythm: balances breathing room
     with scroll reduction.
       reviewer-block → review-body: 24px (more than UA-default
         16px so the profile pic has clear separation from the
         review text)
       review-body → squeaks border: 12px margin-bottom
       squeaks padding: 12/12 (both top and bottom inset around
         the "in rat" translation line)
       reactions padding-top: 12px after the border
     Margins are zeroed where padding handles the gap so the
     spacing reads as a single value rather than collapsed-margin
     arithmetic. Mobile keeps the looser rhythm for touch targets. */
  .review-content > .review-body {
    margin-top: 1.5rem;
    margin-bottom: 0.75rem;
  }
  .review-content > .review-meta-row {
    margin: 0;
    padding: 0.5rem 0 0.75rem;
  }
  .review-content > .review-reactions {
    margin: 0;
    padding: var(--space-md) 0;
  }
}

/* ===== V17 safe-area overscroll fix =====
   V15 confirmed iOS Safari deliberately returns env(safe-area-
   inset-top) = 0 with viewport-fit=cover declared correctly. The
   visible "bleed" Shoro reports is iOS rubber-band overscroll —
   pull-up scroll briefly exposes the page content (map, paragraphs,
   loading screen contents) above each sticky/fixed surface. Each
   surface only paints its bg from its top edge down, so the
   safe-area zone above shows whatever sits underneath.

   Fix: a position:absolute pseudo-element pinned at bottom:100% of
   each surface (i.e. directly above its top edge), 100vh tall,
   inheriting the parent's bg. The pseudo extends each surface's
   visual bg upward into the safe-area zone so rubber-band exposes
   the surface color rather than the underlying content.

   100vh extension distance: rubber-band overshoot on iPhone is
   bounded at ~screen-height; 100vh covers it with margin while
   future-proofing against larger inset zones on new devices.

   Each surface inherits the parent's position context (sticky/
   sticky/fixed) so the pseudo anchors correctly. Each has a
   shorthand `background: var(--color-bg)` parent, so
   `background: inherit` pulls the full background longhand set. */
/* V21: pseudo dropped from .page-header — map page body is locked
   (overflow:hidden; height:100dvh) so the page can't rubber-band-
   scroll past the top edge, and the upward bg extension was paint-
   ing invisibly off-screen forever. About page still scrolls, so
   the pseudo stays on .about-header for pull-down rubber-band. */
.about-header::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: 100vh;
  background: inherit;
  pointer-events: none;
}

/* ===== V18 reverts V14/V15 max() padding floor =====
   With V17's ::before pseudo extending each surface's bg upward
   into the safe-area zone, the padding floor is excess chrome.
   env() resolving to 0 in iOS Safari (URL bar pre-insets the page)
   is correct — the pseudo handles the visual. Base rules with
   plain env() reverted to V11-era values:
     - Safari (env=0): page-header 14px, about-header 12px,
       loading 14/32px
     - PWA standalone (env > 0): env value adds to base, original
       intent of the env() math preserved
   Only the mobile-only overscroll-behavior-y rule from V17 stays
   in this block. */
@media (max-width: 768px) {
  /* V21: dropped overscroll-behavior:none on html/body. Map page
     body has overflow:hidden so it isn't a scroll container — the
     rule did nothing. About page main is the scroll surface; if
     rubber-band ever needs suppression there, scope to
     .about-page main rather than the global mobile selector. */

  /* Mobile about-header: adds the var(--color-bg) that desktop
     drops (padding/height come from the mobile chrome tokens),
     plus the hairline the other mobile bars carry. Sticky/
     z-index/compositor are inherited from the module-scope rule. */
  .about-header {
    background: var(--color-bg);
    border-bottom: 1px solid rgba(184, 255, 0, 0.15);
  }
}
