/* /map — slice 1. Full-bleed map under a slim bar. */
/* ⚠ --map-top IS OURS; --nav-h IS THE NAV'S. The controls below offset from
   the top of the map area, which is the nav's height normally and ZERO in full
   screen. That could not be done by setting --nav-h, and the note at
   .modeswitch says why in detail: --nav-h is the token css/nav.css sizes the
   NAV from, so writing it fed back — nav 68 -> map top 69 -> --nav-h 69 ->
   nav 69 -> … growing to 700px over successive renders. Read it, never write
   it. This is a separate variable that merely DEFAULTS to it. */
.map-body { margin: 0; height: 100dvh; display: flex; flex-direction: column; overflow: hidden;
  --map-top: var(--nav-h); }

/* ── full screen ────────────────────────────────────────────────────────────
   ⚠ IN-PAGE, NOT THE FULLSCREEN API — and the layout is most of the reason.
   .map-body is already a flex column with the nav in flow and #map at
   `flex: 1 1 auto`, so hiding the nav reflows the map to the whole viewport
   with no positioning arithmetic at all. What the API would add on top of that
   is an OS-level window takeover whose exit affordance is the BROWSER's, not
   ours, plus a user-gesture requirement and no support for non-video elements
   on iOS Safari — on a surface whose readers are largely on phones.

   The map still needs resize() when this class lands; see js/map.js. */
/* ⚠ THE STATES ARE ENUMERATED, NOT NEGATED — AND :not() SHIPPED THE NAV HIDDEN
   ON EVERY FRESH LOAD. This was `:not([data-chrome='none'])`, chosen so the
   rule would cover cinema without a second edit. It does something else too:
   an ABSENT attribute is also "not none", and data-chrome is absent until the
   first toggle. So a plain page load matched, the nav went, and the control
   stack sat at its full-screen offset (measured: options at y126 with nothing
   ever pressed).

   Enumerating both states is the fix, and the risk that motivated :not() —
   forgetting to add cinema here — is answered by adding it NOW, in the same
   commit that names the value, rather than by a selector that is true for
   states nobody has defined. */
.map-body[data-chrome='screen'] #nav,
.map-body[data-chrome='cinema'] #nav { display: none; }
.map-body[data-chrome='screen'],
.map-body[data-chrome='cinema'] { --map-top: 0px; }

.mapbar {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-4);
  background: var(--surface); border-bottom: 1px solid var(--line);
  flex: 0 0 auto;
}
.mapbar__back { color: var(--accent-text); text-decoration: none; font-weight: var(--fw-label); }
.mapbar__back:hover { text-decoration: underline; }

#map { flex: 1 1 auto; min-height: 0; }

.mapstatus {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  margin: 0; padding: var(--sp-3) var(--sp-4);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-md); color: var(--text-muted);
  font-size: var(--fs-sm); pointer-events: none;
}
.mapstatus:empty { display: none; }
/* The hint fades rather than vanishing; failures never reach this state
   because only hint() sets data-fading. */
.mapstatus { transition: opacity 320ms ease; }
.mapstatus[data-fading='1'] { opacity: 0; }
/* ⚠ NO TRANSITION UNDER REDUCED MOTION — the message still goes, it just goes
   at once. A fade is decoration here; the disappearance is the behaviour. */
@media (prefers-reduced-motion: reduce) {
  .mapstatus { transition: none; }
}

/* Shown when ?mode= names a map that is not live yet. Dismissible: it is an
   explanation, not an error, and it must not sit on the map forever. */
.mapnote {
  display: flex; align-items: center; gap: var(--sp-3);
  margin: 0; padding: var(--sp-3) var(--sp-4);
  background: var(--accent-tint); color: var(--accent-text);
  font-size: var(--fs-sm); flex: 0 0 auto;
  border-bottom: 1px solid var(--line);
}
.mapnote[hidden] { display: none; }
.mapnote__x {
  margin-left: auto; border: 0; background: none; cursor: pointer;
  color: inherit; font-size: 20px; line-height: 1; padding: 0 var(--sp-2);
}

/* The map paywall. NOT a modal that blocks the map — the field behind it is
   the offer, so the backdrop is light and the card sits centred over it. */
.paywall {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: rgba(10, 22, 40, 0.45); z-index: 5;
}
.paywall[hidden] { display: none; }
.paywall__card {
  max-width: 27rem; margin: var(--sp-4);
  background: var(--surface); border-radius: var(--r-lg, 14px);
  padding: var(--sp-5, 24px); text-align: center;
  box-shadow: 0 12px 40px rgba(0,0,0,.28);
}
.paywall__title { margin: 0 0 var(--sp-2); font-size: var(--fs-lg, 1.25rem); }
.paywall__body { margin: 0 0 var(--sp-4); color: var(--text-secondary); }
.paywall__actions { display: flex; gap: var(--sp-3); justify-content: center; flex-wrap: wrap; }

/* ── in-map mode switcher ────────────────────────────────────────────────────
   Sits over the map rather than in the bar: the bar is fixed-height chrome and
   ten modes will not fit there on a phone. Horizontal scroll with the scrollbar
   hidden — the overflow is the design, not a fallback. */
.modeswitch {
  position: absolute;
  /* This read calc(var(--mapbar-h, 52px) + 8px), and --mapbar-h IS DEFINED
     NOWHERE — it was the height of a .mapbar header since deleted from
     map.html, so the row positioned itself against a phantom element and fell
     back to 52px while the nav is 68. The pills sat under it, clipped along
     their top edge.

     ⚠ --nav-h IS THE NAV'S OWN TOKEN (css/tokens.css) AND css/nav.css SETS
     THE NAV'S HEIGHT FROM IT. Read it; never write it. A first attempt at
     this fix measured #map's top in js and wrote it back to --nav-h, which
     made a feedback loop — nav 68 -> map top 69 -> --nav-h 69 -> nav 69 -> …
     — and the nav grew 68 -> 152 -> 262 -> 420 -> 700px over successive
     renders until the map was pushed off screen entirely. The variable
     already existed and already held exactly the right number; the whole bug
     was pointing at the wrong one. */
  top: calc(var(--map-top) + 8px);
  left: 8px;
  right: 8px;
  z-index: 3;
  display: flex;
  gap: 6px;
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  padding-bottom: 2px;
}
.modeswitch::-webkit-scrollbar { display: none; }

.modeswitch__btn {
  flex: 0 0 auto;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(10, 22, 40, 0.72);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--text-on-dark-2);
  font: 500 13px/1 var(--font-sans, system-ui, sans-serif);
  padding: 8px 12px;
  border-radius: 999px;
  cursor: pointer;
  white-space: nowrap;
  transition: background 120ms ease, border-color 120ms ease;
}
.modeswitch__btn:hover { background: rgba(20, 40, 68, 0.82); }
.modeswitch__btn:focus-visible {
  outline: 2px solid var(--brand-teal-bright, #3DE0C4);
  outline-offset: 2px;
}
.modeswitch__btn[aria-current='true'] {
  background: var(--brand-teal-bright, #3DE0C4);
  border-color: var(--brand-teal-bright, #3DE0C4);
  color: var(--ink-on-bright);
}
/* Not-yet-live modes stay VISIBLE and disabled rather than being hidden: the
   ten-mode set is the product, and a switcher that silently shows five reads as
   a smaller product rather than as an in-progress one. */
.modeswitch__btn[disabled] {
  opacity: 0.42;
  cursor: not-allowed;
}
/* While a switch is in flight the old field stays on screen — see the swap
   comment in js/map.js. This only dims the control. */
.modeswitch[data-busy='1'] .modeswitch__btn { opacity: 0.6; pointer-events: none; }

/* ── timeline ────────────────────────────────────────────────────────────────
   Sits over the map, anchored to the bottom, for the same reason the mode
   switcher sits over it: #map is flex:1 and the bar is fixed-height chrome, so
   anything added to the column steals map. Overlaying costs nothing and keeps
   the field full-bleed. */
.timeline {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 4;
  display: flex; flex-direction: column; gap: var(--sp-2);
  /* ⚠ SAFE AREA ON THE BOTTOM. On a phone the home indicator occupies the
     last ~34px of the viewport, so a bar at bottom:0 has its last row — here
     the day ticks — underneath it. Desktop is unaffected: env() resolves to 0
     where there is no inset, so this is additive rather than a breakpoint. */
  padding: var(--sp-3) var(--sp-4) calc(var(--sp-4) + env(safe-area-inset-bottom, 0px));
  background: linear-gradient(to top, var(--surface) 62%, transparent);
  color: var(--text);
}
.timeline[hidden] { display: none; }

.timeline__head { display: flex; align-items: center; gap: var(--sp-3); }
.timeline__when {
  font-size: var(--fs-sm); font-weight: var(--fw-label);
  font-variant-numeric: tabular-nums;
}
.timeline__now {
  margin-left: auto; border: 1px solid var(--line); background: var(--surface);
  color: var(--accent-text); border-radius: var(--r-md);
  padding: 2px var(--sp-3); font-size: var(--fs-sm); cursor: pointer;
}
.timeline__now:hover { background: var(--accent-tint); }

/* The range and the locked tail share one row and are sized by flex-grow, set
   in js from the slot counts — so the tail is literally in proportion to the
   days it withholds rather than a decorative stub. */
.timeline__track { display: flex; align-items: center; gap: 2px; }

/* The scrubber takes ALL the leftover width; the locked strip is sized to its
   own content and capped below. This replaced a proportional split driven by
   flex-grow from the slot counts — see the note in js/map.js's renderTimeline
   for why proportionality lost. */
/* ⚠ ONE SCROLLER FOR THE RANGE AND ITS TICKS. See the note in map.html: they
   were siblings at different widths, which put one control in two coordinate
   spaces and scaled every click by the ratio between them.

   scrollbar-width is THIN, not none. The tick row used to be its own 12 px
   scroller with the bar hidden — technically scrollable, and unreachable in
   practice: nothing to grab, no bar to drag, and on a phone a 12 px target.
   The lane is 34 px and shows its bar. */
.timeline__scroll {
  flex: 1 1 0; min-width: 0;
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: thin;
  overscroll-behavior-x: contain;   /* don't hand a horizontal fling to the page */
}
.timeline__scroll::-webkit-scrollbar { height: 6px; }
.timeline__scroll::-webkit-scrollbar-thumb {
  background: var(--line); border-radius: 3px;
}
/* The one element that widens. Both children are 100% of IT, never of the
   viewport, so the thumb and the labels share a single geometry. */
.timeline__lane { min-width: 100%; }

.timeline__range {
  display: block; width: 100%; margin: 0; height: 22px;
  accent-color: var(--accent-text);   /* native thumb + filled track, both themes */
  cursor: pointer;
}
.timeline__range:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 3px; }

.timeline__locked {
  /* 0 0 auto: content-sized, never grows into the scrubber. The cap is a
     ceiling for a long date pair, not a target. */
  flex: 0 0 auto; max-width: 28%;
  display: flex; align-items: center; gap: 4px;
  height: 20px; padding: 0 6px;
  border: 1px solid var(--line);
  /* ⚠ THE LEFT EDGE IS THE BOUNDARY MARKER, and it is why the strip still
     reads as a limit at a fraction of its old width. Losing 49% of the track
     lost the lock most of its AREA, so the weight had to come from somewhere:
     a solid accent rule exactly where the scrubbable range ends. */
  border-left: 3px solid var(--accent-text);
  border-radius: 0 var(--r-md) var(--r-md) 0;
  background: var(--accent-tint); color: var(--accent-text);
  font-size: 10px; line-height: 1; white-space: nowrap; overflow: hidden;
  cursor: pointer;
}
.timeline__locked[hidden] { display: none; }
.timeline__locked:hover { background: var(--surface); border-color: var(--accent-text); }
.timeline__locked:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.timeline__lockicon { width: 9px; height: 11px; flex: 0 0 auto; }
.timeline__lockdates { overflow: hidden; text-overflow: ellipsis; }

/* ⚠ NO LONGER A SCROLLER, AND NO LONGER HOLDS AN INNER STRIP. Both are gone
   on purpose: this row scrolling independently of the range input is exactly
   the defect above. It is now a plain positioning context at 100% of the lane,
   and the lane is the single thing that widens and scrolls. */
.timeline__ticks {
  position: relative; height: 12px; width: 100%;
  font-size: 10px; color: var(--text-muted);
  pointer-events: none;   /* labels never eat a click meant for the scrubber */
}
.timeline__tick {
  position: absolute; top: 0; transform: translateX(-50%);
  white-space: nowrap;
}
/* ⚠ THE END TICKS ARE CLAMPED, NOT CENTRED. Every tick is centred on its own
   position, which puts the FIRST one — at 0% — half its width off the left
   edge: measured at x = -2 on a 390 px screen, clipping "Today". Pre-existing,
   and it got wider the moment the labels gained a date. The two ends align to
   their edge instead; only the ends move, so nothing in the middle drifts off
   the position it marks. */
.timeline__tick:first-child { transform: none; }
.timeline__tick:last-child { transform: translateX(-100%); }

/* ── the locked tail ────────────────────────────────────────────────────────
   ⚠ THIS IS AN UPSELL, NOT MISSING DATA, and the styling has to carry that
   difference on its own — the reader cannot see WHY a day is dim. Dimmed for
   "not yours", the brand accent for "this is Fetch+", and a padlock so it does
   not rely on colour alone (a grey tick and a disabled tick look identical to
   anyone who cannot pick the hue out).

   ⚠ THE TICK HOLDS A DATE AND NOTHING ELSE. js/map.js never appends the
   forecast to a locked tick and applySlot refuses to load its pack, so this
   rule dims a LABEL rather than masking a value — the Daily Report's rule
   (js/daily-report.js:204), where the locked cell is empty rather than
   covered. Never style this with opacity over real data. */
.timeline__tick[data-locked] {
  color: var(--accent-text);
  opacity: 0.55;
}
.timeline__tick[data-locked]::before {
  content: '\1F512';           /* padlock — not colour alone */
  margin-right: 2px;
  font-size: 9px;
  opacity: 0.9;
}
/* The first locked tick carries the wordmark, so the tail reads as one offer
   rather than as a row of individually broken days. */
.timeline__tick[data-locked][data-first-locked]::after {
  content: ' Fetch+';
  font-weight: var(--fw-label);
  opacity: 1;
}

/* Busy is a whole-control state, not a spinner: the field is still the previous
   hour until the new pack decodes, and dimming the control says "not yet"
   without covering the map that is the reason anyone is here. */
.timeline[data-busy] .timeline__track { opacity: .55; }

@media (max-width: 640px) {
  .timeline { padding: var(--sp-2) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom, 0px)); }
  /* ⚠ NOTHING IS HIDDEN AT 390 ANY MORE. This used to drop every other label —
     the honest fix at the time, when the strip could not scroll and five labels
     in 175px genuinely collided. The strip scrolls now (see .timeline__strip),
     so hiding half the week is crunching a problem that no longer exists, and
     Grant's standing instruction is to scroll instead. A 16-day horizon makes
     it worse either way: alternating would leave eight of sixteen days
     unnamed. */
}

/* ⚠ ATTRIBUTION MUST STAY VISIBLE — the timeline is a full-width bottom
   overlay and MapLibre puts the Stadia / OpenMapTiles / OSM credit bottom-right
   by default, so it would sit underneath. --timeline-h is measured in js
   (ResizeObserver) rather than hard-coded, because the control's height moves
   with the note bar, the tick row, and the 640px breakpoint. */
.map-body .maplibregl-ctrl-bottom-right,
.map-body .maplibregl-ctrl-bottom-left {
  bottom: var(--timeline-h, 0px);
}

/* ── play / pause ────────────────────────────────────────────────────────────
   One button, two glyphs, swapped by [data-state]. Both live in the markup and
   CSS chooses — building the icon in js would put the control's appearance in
   a second place from the control's behaviour. */
.timeline__play {
  flex: 0 0 auto; display: grid; place-items: center;
  width: 30px; height: 30px; padding: 0;
  border: 1px solid var(--line); border-radius: 999px;
  background: var(--surface); color: var(--accent-text); cursor: pointer;
}
.timeline__play:hover { background: var(--accent-tint); }
.timeline__play:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.timeline__playicon { width: 16px; height: 16px; display: block; }

/* Paused shows PLAY (the action available), playing shows PAUSE. The state
   named by data-state is what the timeline IS doing, so the icon is the
   opposite of it — a transport control offers the next action, it does not
   report the current one. */
.timeline__play .timeline__bars { display: none; }
.timeline__play[data-state="playing"] .timeline__tri  { display: none; }
.timeline__play[data-state="playing"] .timeline__bars { display: inline; }

/* Warming is a REAL state and it gets its own look, because it is the one
   moment the button is pressed and nothing is moving yet. Without it the
   pre-load reads as a dead button. */
.timeline__play[data-state="warming"] { opacity: .6; }
.timeline__play[data-state="warming"] .timeline__tri  { display: none; }
.timeline__play[data-state="warming"] .timeline__bars { display: inline; }
@media (prefers-reduced-motion: no-preference) {
  .timeline__play[data-state="warming"] { animation: tl-pulse 1s ease-in-out infinite; }
  @keyframes tl-pulse { 50% { opacity: .3; } }
}

/* ── map options: one button, two pages ───────────────────────────────────
   ⚠ THE MODE ROW IS INSET TO CLEAR THIS BUTTON rather than the button being
   placed inside it. .modeswitch scrolls horizontally — ten modes do not fit a
   phone — so anything inside it scrolls away, and this is the control you
   most want reachable when the row is longest. */
.modeswitch { right: 54px; }

/* ⚠ THE BUTTON AND MapLibre's ZOOM STACK SHARE THIS CORNER, so they are
   stacked rather than placed independently. Measured before the fix: the
   button sat at top 76-110 / right 1394-1432 and the zoom control at 69-137 /
   1401-1440 — fully overlapping, at 1440, 1024, 768 AND 390. Both offsets now
   derive from --mapopt-btn-h so moving one cannot leave the other behind. */
:root { --mapopt-btn-h: 34px; }
.mapopt__btn {
  position: absolute;
  /* slot 4 = layers (#map-options-btn), which uses this base; every other
     .mapopt__btn overrides its own slot below. */
  top: var(--stack-4);
  right: 8px;
  z-index: 4;
  width: 38px; height: var(--mapopt-btn-h);
  display: grid; place-items: center;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(10, 22, 40, 0.72);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  color: var(--text-on-dark-2); border-radius: 999px; cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.mapopt__btn:hover { background: rgba(20, 40, 68, 0.82); }
.mapopt__btn:focus-visible {
  outline: 2px solid var(--brand-teal-bright, #3DE0C4); outline-offset: 2px;
}
.mapopt__btn[aria-expanded='true'] {
  background: var(--brand-teal-bright, #3DE0C4);
  border-color: var(--brand-teal-bright, #3DE0C4);
  color: var(--ink-on-bright);
}

/* ── ⚠ THE PANEL IS TALLER THAN THE SCREEN, AND HAD NO SCROLLER AT ALL ─────
   Not the timeline's mistake (a scroller with its bar hidden) and not
   .dr__scroll's (a flex item that shrank so its overflow clipped). A third
   kind: overflow-y visible, max-height none, scrollHeight === clientHeight, so
   the panel simply grew past the viewport and the surplus spilled out of the
   document with nothing to scroll. Measured on Overlays: 738 px of content
   anchored at y 228 in an 844 px viewport — bottom at 966, the last two rows
   at y 826 and 883, unreachable. It overflowed at 1440 too, by 74 px.

   ⚠ THE CEILING IS DERIVED FROM THE ANCHOR, NOT A FIXED NUMBER. This panel
   hangs off the layers button, and that button moved when the right stack was
   reordered on 2026-08-26 — a max-height written against the old y would have
   been silently wrong the moment it did. --mapopt-top is the single expression
   both `top` and `max-height` read, so they cannot disagree and both follow
   the stack.

   ⚠ AND IT IS SIZED BY THE VIEWPORT, NOT BY TODAY'S ROW COUNT. The panel is
   expected to grow — cinema mode brings the mode switcher into it, and Basemap
   gains a row per new style. A ceiling counted in rows would need editing
   every time; 100dvh minus the anchor never does. */
.map-body {
  /* Each panel hangs off ITS button's slot — layers at 4, legend at 6 now. */
  --mapopt-top: calc(var(--stack-4) + var(--mapopt-btn-h) + var(--stack-gap));
  --maplegend-top: calc(var(--stack-6) + var(--maplegend-btn-h) + var(--stack-gap));
}
.mapopt {
  position: absolute;
  /* Hangs off the layers button it belongs to — same reason as .maplegend. */
  top: var(--mapopt-top);
  /* 16px keeps it clear of the viewport's bottom edge rather than flush to it. */
  max-height: calc(100dvh - var(--mapopt-top) - 16px);
  /* The tabs stay put; only the page under them scrolls. */
  display: flex; flex-direction: column;
  right: 8px;
  z-index: 5;
  width: min(268px, calc(100vw - 16px));
  padding: 10px;
  border-radius: 14px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(10, 22, 40, 0.94);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
  color: var(--text-on-dark-2);
  font: 400 13px/1.4 var(--font-sans, system-ui, sans-serif);
}
/* ⚠ flex: 0 0 auto — THE TABS MUST NOT SHRINK. This is .dr__scroll's lesson
   from the other direction: there a flex item shrank and clipped nine rows to
   one; here the tabs would compress toward nothing as the page grows. */
.mapopt__tabs { display: flex; gap: 4px; margin-bottom: 10px; flex: 0 0 auto; }

/* ⚠ min-height: 0 IS LOAD-BEARING, NOT TIDINESS. A flex item's default
   min-height is auto — it refuses to shrink below its content — so overflow-y
   here would never engage and the panel would grow past its own max-height
   exactly as before. This one declaration is the difference between a scroller
   and a taller box. */
.mapopt__page {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  /* ⚠ A VISIBLE BAR. scrollbar-width: none on a 12px lane is what made the
     timeline's tick row "technically scrollable, practically not"; a panel
     that scrolls without saying so is the same defect with more rows behind
     it. Thin, and coloured so it reads on the panel's dark ground. */
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.28) transparent;
}
.mapopt__page::-webkit-scrollbar { width: 8px; }
.mapopt__page::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.28); border-radius: 4px;
}
.mapopt__page::-webkit-scrollbar-track { background: transparent; }
.mapopt__tab {
  flex: 1 1 0; font: inherit; font-size: 12px; padding: 5px 8px;
  border-radius: 8px; cursor: pointer;
  border: 1px solid rgba(255, 255, 255, 0.14);
  background: transparent; color: var(--text-on-dark-2);
}
.mapopt__tab[aria-selected='true'] {
  background: rgba(255, 255, 255, 0.12); color: #fff;
  border-color: rgba(255, 255, 255, 0.28);
}
.mapopt__cap {
  margin: 0 0 6px; font-size: 10.5px; letter-spacing: 0.08em;
  text-transform: uppercase; opacity: 0.66;
}
.mapopt__chips { display: flex; gap: 5px; flex-wrap: wrap; }
.mapopt__chip {
  font: inherit; font-size: 12.5px; padding: 6px 11px; border-radius: 999px;
  cursor: pointer; display: inline-flex; align-items: center; gap: 5px;
  border: 1px solid rgba(255, 255, 255, 0.2);
  background: transparent; color: var(--text-on-dark-2);
}
.mapopt__chip:hover { background: rgba(255, 255, 255, 0.08); }
.mapopt__chip[aria-pressed='true'] {
  background: var(--brand-teal-bright, #3DE0C4);
  border-color: var(--brand-teal-bright, #3DE0C4);
  color: var(--ink-on-bright);
}
/* ⚠ LOCKED IS DIMMED, NOT DISABLED. A disabled chip cannot be tapped, and the
   tap is what opens the offer — the app's own rule (requirePremium on tap). */
.mapopt__chip[data-locked] { opacity: 0.6; }
.mapopt__lock { font-size: 10px; }
.mapopt__note { margin: 8px 0 0; font-size: 11.5px; line-height: 1.45; opacity: 0.74; }
.mapopt__row {
  width: 100%; display: flex; align-items: center; justify-content: space-between;
  gap: 10px; padding: 7px 2px; background: none; border: 0; cursor: pointer;
  color: inherit; font: inherit; text-align: left;
}
.mapopt__rowlbl { display: block; font-size: 13px; }
.mapopt__rowsub { display: block; font-size: 11px; opacity: 0.62; margin-top: 1px; }
.mapopt__track {
  flex: 0 0 auto; width: 34px; height: 20px; border-radius: 999px;
  background: rgba(255, 255, 255, 0.18); position: relative;
  transition: background 140ms ease;
}
.mapopt__knob {
  position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
  border-radius: 50%; background: #fff; transition: transform 140ms ease;
}
.mapopt__row[aria-checked='true'] .mapopt__track { background: var(--brand-teal-bright, #3DE0C4); }
.mapopt__row[aria-checked='true'] .mapopt__knob { transform: translateX(14px); }
.mapopt__row:focus-visible { outline: 2px solid var(--brand-teal-bright, #3DE0C4); outline-offset: 2px; }

/* ⚠ pointer-events:none — the particle canvas sits over the map and must never
   eat a drag, a tap or a scroll. It is decoration on top of an interactive
   surface. */
.flowparticles {
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
}

/* MapLibre's top-right stack drops below the options button — same corner,
   stacked, which is the conventional arrangement and keeps both reachable.

   ⚠ AND BELOW THE LEGEND BUTTON TOO, WHICH IT DID NOT. #map-legend-btn arrived
   in 055e240 and landed in this same column: measured 1349..1432 against the
   zoom stack's 1401..1440, an overlap of 29x23 px AT EVERY WIDTH, with the
   zoom buttons sitting on the legend button's right end. Neither map-clip nor
   a11y could see it — both assert that a control renders and is reachable, and
   neither compares one control's box to another's. tools/map-controls.mjs is
   the sweep that does, and it is what found this.

   --maplegend-btn-h is the button (34) plus the gap (8); the stack clears
   both chips rather than one. */
/* ── ⚠ THE RIGHT-HAND STACK, DECLARED ONCE AS A CHAIN ──────────────────────
   Top to bottom (2026-08-29): FULL SCREEN, cinema, search, layers, zoom,
   legend. Each slot is the previous one plus its height plus the gap, so an
   order change is an edit to the chain block and the six reads that follow —
   and nowhere else.

   ⚠ THE ZOOM IS MAPLIBRE'S NavigationControl AND WE DO NOT OWN ITS LAYOUT. It
   lives in .maplibregl-ctrl-top-right, which MapLibre positions; ours are
   absolutely positioned siblings. So the stack is ordered by arithmetic around
   it, and its HEIGHT is the one number in the chain that belongs to somebody
   else. It used to be hard-coded here at 68px — correct for two 29px buttons
   and their borders today, and silently wrong the day MapLibre changes them,
   which would slide layers and legend under or over the zoom with nothing to
   catch it. js/map.js now measures that container and publishes --mapzoom-h;
   the 68px here is only the value used before the first measurement lands.

   The alternative — mounting ours INTO MapLibre's container as custom
   controls — would let MapLibre stack all four and remove the arithmetic
   entirely. It is the more durable shape and a bigger change than this
   reorder: the legend and layers PANELS anchor to their buttons' positions,
   and both buttons are static markup that the panels' CSS reads. Worth doing
   if this corner is touched again; recorded here rather than left implicit. */
.map-body {
  --maplegend-btn-h: 42px;
  --mapzoom-h: 68px;                /* replaced at runtime by measurement */
  --stack-gap: 8px;
  /* ⚠ NEW ORDER 2026-08-29: full screen, cinema, search, layers, zoom, legend.
     The tall MEASURED zoom (--mapzoom-h) moved from slot 2 to slot 5, so the
     one place its height enters the chain moved with it — from --stack-3 to
     --stack-6. Every other slot adds --mapopt-btn-h; only the slot AFTER the
     zoom adds --mapzoom-h. Reorder = edit these six lines and the six reads
     below, nothing else. */
  --stack-1: calc(var(--map-top) + var(--stack-gap));                        /* full screen */
  --stack-2: calc(var(--stack-1) + var(--mapopt-btn-h) + var(--stack-gap));  /* cinema      */
  --stack-3: calc(var(--stack-2) + var(--mapopt-btn-h) + var(--stack-gap));  /* search      */
  --stack-4: calc(var(--stack-3) + var(--mapopt-btn-h) + var(--stack-gap));  /* layers      */
  --stack-5: calc(var(--stack-4) + var(--mapopt-btn-h) + var(--stack-gap));  /* zoom        */
  --stack-6: calc(var(--stack-5) + var(--mapzoom-h) + var(--stack-gap));     /* legend      */
}

/* Slot 6. Chains off --mapopt-btn-h like every slot after the legend — see the
   note at slot 5, where reaching for --maplegend-btn-h double-counted the gap
   and put the tile 8px low. */
#map-search-btn { top: var(--stack-3); }   /* slot 3 */

/* ⚠ SLOT 5 CHAINS OFF --mapopt-btn-h LIKE EVERY OTHER SLOT. It was written
   against --maplegend-btn-h first, on the theory that the legend is a taller
   pill needing extra clearance. It is not — it renders 34px like the rest, and
   --maplegend-btn-h is ALREADY the button plus the gap (see the note at the
   token). Adding --stack-gap to it double-counted the gap and pushed cinema
   8px low: measured 16px where every other gap is 8.

   ⚠ AND map-controls.mjs PASSED ON IT, at three widths in both themes. It
   asserts no-overlap and on-screen; a tile that is too far DOWN violates
   neither. The stack's rhythm is not currently under test — only its
   collisions are. Measure the gaps when adding slot 6. */
#map-cinema-btn { top: var(--stack-2); }   /* slot 2 */

#map-full-btn { top: var(--stack-1); }
/* One button, two glyphs: which one shows is the pressed state, so the icon
   cannot fall out of step with what the press will do. */
#map-full-btn .mapfull__out { display: none; }
#map-full-btn[aria-pressed='true'] .mapfull__in { display: none; }
#map-full-btn[aria-pressed='true'] .mapfull__out { display: block; }
.map-body .maplibregl-ctrl-top-right {
  /* margin-top is measured from #map's top, so the slot has --map-top removed.
     ⚠ slot 5 since the 2026-08-29 reorder — was slot 2. The measured
     --mapzoom-h still enters the chain at --stack-6 (the slot below zoom). */
  margin-top: calc(var(--stack-5) - var(--map-top));
}

/* ── radar page ─────────────────────────────────────────────────────────────
   ⚠ THE SITE LABEL IS NOT DECORATION. docs/map-radar.md records the app's
   model — "the active site changes ONLY via the site picker or the locate
   arrow — panning does NOT change it" — and this page deliberately does the
   other thing, choosing the nearest site to the map centre. A page that
   silently switches antennas while you pan would be worse than either model,
   so which site is drawn is stated permanently and prominently. */
.radarbar {
  position: absolute; top: 12px; left: 12px; z-index: 5;
  display: flex; flex-direction: column; gap: 2px;
  padding: 8px 12px; border-radius: 10px;
  background: var(--surface); color: var(--text);
  box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
  font: 600 13px/1.3 var(--font-sans, system-ui, sans-serif);
  max-width: min(70vw, 320px);
}
.radarbar__time { font-weight: 400; font-size: 12px; color: var(--text-muted); }

/* CC-BY-4.0 obligation — see radar.html. Sits with the basemap attribution so
   it cannot be scrolled away or covered by the status line. */
.radarattrib {
  position: absolute; right: 8px; bottom: 26px; z-index: 5; margin: 0;
  padding: 3px 7px; border-radius: 6px;
  background: var(--surface); color: var(--text-muted);
  font: 400 11px/1.35 var(--font-sans, system-ui, sans-serif);
}
.radarattrib a { color: inherit; }

/* ── radar loop controls ────────────────────────────────────────────────────
   Sits where the map's timeline does, so the two pages feel like one product
   even though their axes are different — the map scrubs 5 days of forecast,
   this scrubs the last ~25 minutes of observation. */
/* ⚠ HALF-WIDTH AT DESKTOP, FULL WHERE THE ROW STOPS FITTING — and the number
   is derived, not chosen. Measured at 1440 with the real strings:

     play 36 + clock 124 + stale badge 71 + site 75 + locate 38 + gear 38
       + five 10px gaps + 24px card padding            = 456 px

   That is the binding constraint, not the legend. The longest title
   ("Specific Differential Phase KDP (°/km)") is 239 px, and the tick labels
   are positioned by FRACTION so they add no width at all — their own limit is
   collision, worst on CC where 0.95 and 1.0 sit 8.3% apart and need ~373 px to
   stay clear. Both are comfortably under the row.

   The stale badge is in the sum deliberately: it appears on its own when a
   sweep passes 20 minutes, and a bar that only fits while the data is fresh
   would break exactly when someone is looking hardest.

   So: 50% of the viewport, never below 480 (456 + slack), never wider than the
   viewport less its gutters. The crossover to full width is therefore
   ~504px — below that, 100% - 24 is less than 480 and the min() takes over.
   One expression, no breakpoint to keep in step with the content.

   ⚠ THE APP OFFERS NO GUIDANCE HERE, which is why this is derived from scratch:
   its bar is simply the phone's width, and its tick list is a fixed const
   positioned by fraction with a clamp — it never thins at narrow widths
   because it never gets one. */
.radarctl {
  position: absolute; bottom: 12px; z-index: 5;
  left: 50%; transform: translateX(-50%);
  width: min(calc(100% - 24px), max(480px, 50%));
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px 12px 10px; border-radius: 12px;
  background: var(--surface); color: var(--text);
  box-shadow: 0 2px 12px rgb(0 0 0 / 20%);
}
.radarctl__play {
  flex: 0 0 auto; width: 36px; height: 36px; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 50%; cursor: pointer;
  background: var(--accent-tint); color: var(--accent-text);
}
.radarctl__play:disabled { opacity: 0.45; cursor: default; }
.radarctl__play svg { width: 22px; height: 22px; }
/* One button, two glyphs — the state decides which is drawn, so there is no
   second element to keep in sync with the loop. */
.radarctl__play .radarctl__icon-pause { display: none; }
.radarctl__play[data-playing="true"] .radarctl__icon-play { display: none; }
.radarctl__play[data-playing="true"] .radarctl__icon-pause { display: inline; }

.radarctl__clock { display: flex; flex-direction: column; line-height: 1.2; min-width: 92px; }
.radarctl__time { font: 600 14px/1.2 var(--font-sans, system-ui, sans-serif); font-variant-numeric: tabular-nums; }
.radarctl__when { font: 400 11px/1.2 var(--font-sans, system-ui, sans-serif); color: var(--text-muted); }

/* ── the legend, above the row, per the app's card ─────────────────────────
   ⚠ THE BAR IS PAINTED FROM THE LUT, not described in CSS. See paintLegend:
   the gradient is sampled out of the same table the shader uses, so the key
   cannot drift from the picture it explains. */
.radarleg { display: flex; flex-direction: column; gap: 4px; padding: 2px 2px 8px; }
.radarleg__title {
  font: 600 12px/1.3 var(--font-sans, system-ui, sans-serif);
  color: var(--text-muted); letter-spacing: 0.03em;
}
.radarleg__bar { display: block; height: 10px; border-radius: 3px; }
.radarleg__ticks { position: relative; display: block; height: 14px; }
.radarleg__tick {
  position: absolute; transform: translateX(-50%);
  font: 500 11px/1.2 var(--font-sans, system-ui, sans-serif);
  color: var(--text-muted); font-variant-numeric: tabular-nums;
}
.radarctl__row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* ⚠ NOTHING IN THIS ROW MAY SHRINK. Flex items shrink by default, and a
   38x38 button with only `width` set is not a 38px button — it is a button
   that is 38px when there is room. Measured at 390: the locate and settings
   buttons rendered 5x38, i.e. a 5-pixel-wide tap target, because the row
   overflowed and flexbox took the difference out of whatever would give. The
   clock may shrink; the controls may not. */
.radarctl__play, .radarctl__icon, .radarctl__site, .radarctl__stale { flex: 0 0 auto; }
.radarctl__clock { flex: 0 1 auto; min-width: 0; }
.radarctl__spacer { flex: 1 1 auto; }
.radarctl__site {
  display: flex; align-items: center; gap: 6px;
  padding: 7px 12px; border: 1px solid var(--line); border-radius: 999px;
  background: none; color: var(--text); cursor: pointer;
  font: 600 13px/1.2 var(--font-sans, system-ui, sans-serif);
}
.radarctl__site[aria-pressed="true"] {
  background: var(--accent-tint); color: var(--accent-text); border-color: transparent;
}
.radarctl__icon {
  width: 38px; height: 38px; display: grid; place-items: center; padding: 0;
  border: 1px solid var(--line); border-radius: 50%;
  background: none; color: var(--text); cursor: pointer;
}
.radarctl__icon[aria-pressed="true"] {
  background: var(--accent-tint); color: var(--accent-text); border-color: transparent;
}

.radarctl__stale {
  padding: 2px 8px; border-radius: 999px;
  background: var(--warn-tint, var(--line)); color: var(--text-muted);
  font: 600 11px/1.5 var(--font-sans, system-ui, sans-serif);
}

/* ── radar mode selector ────────────────────────────────────────────────────
   The app puts Mode behind a settings sheet; on the web it is one tap from the
   map, because the web has no bottom tab bar competing for that corner. */
.radarmode__btn {
  position: absolute; top: 12px; right: 56px; z-index: 5;
  display: flex; align-items: center; gap: 4px;
  padding: 8px 10px 8px 12px; border: 0; border-radius: 10px; cursor: pointer;
  background: var(--surface); color: var(--text);
  box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
  font: 600 13px/1.3 var(--font-sans, system-ui, sans-serif);
}
.radarmodes {
  position: absolute; inset: 0; z-index: 20;
  display: flex; align-items: flex-end; justify-content: center;
  background: rgb(0 0 0 / 32%);
}
.radarmodes__sheet {
  width: min(560px, 100%); max-height: 82%; overflow-y: auto;
  border-radius: 16px 16px 0 0; padding: 16px 16px 24px;
  background: var(--surface); color: var(--text);
}
.radarmodes__head { display: flex; align-items: center; justify-content: space-between; }
.radarmodes__title { margin: 0; font: 700 20px/1.3 var(--font-sans, system-ui, sans-serif); }
.radarmodes__x {
  border: 0; background: none; cursor: pointer; color: var(--text-muted);
  font-size: 26px; line-height: 1; padding: 0 4px;
}
.radarmodes__group {
  margin: 18px 0 6px; font: 600 12px/1.4 var(--font-sans, system-ui, sans-serif);
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted);
}
.radarmode {
  display: flex; align-items: flex-start; gap: 12px; width: 100%;
  padding: 10px 8px; border: 0; border-radius: 10px; cursor: pointer;
  background: none; color: inherit; text-align: left;
}
.radarmode:hover { background: var(--accent-tint); }
.radarmode[data-on="true"] { background: var(--accent-tint); }
.radarmode[data-on="true"] .radarmode__name { color: var(--accent-text); }
/* Unavailable rows stay READABLE — they are information, not noise. Dimming
   them to illegibility would be the same as hiding them. */
.radarmode[aria-disabled="true"] { cursor: default; }
.radarmode[aria-disabled="true"]:hover { background: none; }
.radarmode[aria-disabled="true"] .radarmode__name,
.radarmode[aria-disabled="true"] .radarmode__desc { color: var(--text-muted); }
.radarmode__text { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; }
.radarmode__name { font: 600 15px/1.3 var(--font-sans, system-ui, sans-serif); }
.radarmode__desc { font: 400 13px/1.35 var(--font-sans, system-ui, sans-serif); color: var(--text-muted); }
.radarmode__chip {
  flex: 0 0 auto; align-self: center; white-space: nowrap;
  padding: 3px 10px; border: 1px solid var(--line); border-radius: 999px;
  font: 600 11px/1.5 var(--font-sans, system-ui, sans-serif); color: var(--text-muted);
}
/* A feed that may come back reads differently from one that never will. */
.radarmode__chip[data-reason="no-data"] { border-style: dashed; }

/* ── radar settings sheet ───────────────────────────────────────────────── */
.radarsettings__btn {
  position: absolute; top: 12px; right: 12px; z-index: 5;
  width: 36px; height: 36px; display: grid; place-items: center;
  border: 0; border-radius: 10px; cursor: pointer;
  background: var(--surface); color: var(--text);
  box-shadow: 0 2px 10px rgb(0 0 0 / 18%);
}
.radarsheet {
  position: absolute; inset: 0; z-index: 20;
  display: flex; align-items: flex-end; justify-content: center;
  background: rgb(0 0 0 / 32%);
}
.radarsheet__panel {
  width: min(560px, 100%); max-height: 86%; overflow-y: auto;
  border-radius: 16px 16px 0 0; padding: 16px 16px 28px;
  background: var(--surface); color: var(--text);
}
.radarsheet__head { display: flex; align-items: center; justify-content: space-between; }
.radarsheet__title { margin: 0; font: 700 20px/1.3 var(--font-sans, system-ui, sans-serif); }
.radarsheet__x {
  border: 0; background: none; cursor: pointer; color: var(--text-muted);
  font-size: 26px; line-height: 1; padding: 0 4px;
}
.radarsheet__group {
  margin: 18px 0 6px; font: 600 12px/1.4 var(--font-sans, system-ui, sans-serif);
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted);
}
/* Stated once, above the rows it explains — not repeated on each. */
.radarsheet__note {
  margin: 0 0 8px; padding: 8px 10px; border-radius: 8px;
  background: var(--accent-tint); color: var(--accent-text);
  font: 400 13px/1.4 var(--font-sans, system-ui, sans-serif);
}
.radarrow {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 10px 8px; border: 0; border-radius: 10px;
  background: none; color: inherit; text-align: left; cursor: pointer;
  font: inherit;
}
.radarrow:hover { background: var(--accent-tint); }
.radarrow__text { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; }
.radarrow__name { font: 600 15px/1.3 var(--font-sans, system-ui, sans-serif); }
.radarrow__desc { font: 400 13px/1.35 var(--font-sans, system-ui, sans-serif); color: var(--text-muted); }
.radarrow__value { font: 400 14px/1.3 var(--font-sans, system-ui, sans-serif); color: var(--text-muted); }
.radarrow__chev { color: var(--text-muted); font-size: 18px; }
/* ⚠ A REAL PILL SWITCH, NOT A CHECKBOX. The two products should read as one
   product, and the app's Radar Settings uses a pill. The input stays a real
   <input type=checkbox> underneath — it keeps the keyboard behaviour, the
   focus ring and the screen-reader semantics that a div-with-a-click throws
   away — and is painted rather than replaced. */
.radarrow__switch {
  appearance: none; -webkit-appearance: none;
  position: relative; flex: 0 0 auto; cursor: pointer;
  width: 46px; height: 27px; margin: 0;
  border: 0; border-radius: 999px;
  background: var(--line);
  transition: background-color 140ms ease;
}
.radarrow__switch::after {
  content: ''; position: absolute; top: 3px; left: 3px;
  width: 21px; height: 21px; border-radius: 50%;
  background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 28%);
  transition: transform 140ms ease;
}
.radarrow__switch:checked { background: var(--accent); }
.radarrow__switch:checked::after { transform: translateX(19px); }
.radarrow__switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  .radarrow__switch, .radarrow__switch::after { transition: none; }
}
.radarrow--toggle { cursor: default; }
.radarrow--toggle:hover { background: none; }
/* A disabled toggle stays legible: the row is telling you something. */
.radarrow__switch:disabled { opacity: 0.4; cursor: not-allowed; }
/* A locked Fetch+ row stays readable and focusable — it is an offer, not noise. */
.radarrow--style[data-locked="true"] .radarrow__name { color: var(--text-muted); }
.radarrow--style[data-locked="true"] .radarrow__desc {
  color: var(--accent-text); font-weight: 600; letter-spacing: 0.04em;
}
.radarrow--style[data-on="true"] { background: var(--accent-tint); }
.radarrow--style[data-on="true"] .radarrow__name { color: var(--accent-text); }

/* ⚠ BELOW THE CROSSOVER THE ROW REFLOWS — TAKING THE FULL WIDTH IS NOT ENOUGH.
   The measured minimum is 456px WITH the stale badge and 375 without; a 390px
   phone gives the bar 366. So the two things that can yield, yield: the gap
   tightens, and the badge — the only item that appears unpredictably — moves
   to its own line instead of squeezing the controls off the end.

   With a 6px gap the row needs 36+124+75+38+38 + four gaps + 24 padding =
   359px, inside 366. The buttons keep their full tap targets at every width,
   which is the part that was actually broken. */
@media (max-width: 560px) {
  .radarctl__row { gap: 6px; }
  .radarctl__stale { order: 9; flex-basis: 100%; align-self: flex-start; }
  /* ⚠ THE SPACER GOES AWAY, AND IT IS WORTH TWO PIXELS. An empty flex item
     with base 0 still sits between two others, so it costs a GAP even when it
     costs no width. Measured at 390: the row needed 344px against 342
     available and the settings button wrapped to a second line — then the
     spacer grew into the hole it had just made, which is why the row looked
     like it had room. Removing it removes one 6px gap and the row fits with
     4px to spare; `margin-left: auto` does the same pushing without being an
     element. */
  .radarctl__spacer { display: none; }
  .radarctl__site { margin-left: auto; }
}

/* ── Map legend ───────────────────────────────────────────────────────────
   ⚠ THE PILL REUSES .mapopt__btn RATHER THAN RESTATING IT. The app's legend
   deliberately shares the station rail's tile treatment "so the two controls
   can't drift apart"; the web's equivalent neighbour is the options button, so
   the legend takes its class and overrides only width and content. One hover
   state, one focus ring, one radius, one place to change them. */
.maplegend__btn {
  /* ⚠ --stack-4, AND IT USED TO READ --nav-h. That was missed when full screen
     introduced --map-top: in full screen --nav-h keeps the nav's real height
     (it is the nav's own token and must never be written), so the legend
     button and its panel stayed 69px down while the rest of the stack rose to
     the top of the viewport. Nothing caught it — map-controls only sweeps the
     normal state, and the full-screen harness asserted the OPTIONS button had
     moved without checking this one.
     ⚠ slot 6 (bottom) since the 2026-08-29 reorder — was slot 4. */
  top: var(--stack-6);
  width: auto;
  gap: 4px;                       /* the app's kLegendLabelGap */
  grid-auto-flow: column;
  padding: 0 10px;
  font: 600 12px/1 var(--font-sans, system-ui, sans-serif);
  letter-spacing: 0.02em;
}
.maplegend__word { pointer-events: none; }
/* 180° on expand, matching the app's 0 -> 0.5-turn controller. */
.maplegend__chev { transition: transform 200ms ease; }
.maplegend__btn[aria-expanded='true'] .maplegend__chev { transform: rotate(180deg); }

.maplegend {
  top: var(--maplegend-top);
  /* Same ceiling rule, its own anchor — the legend is the taller of the two on
     a 14-stop ramp and would have overflowed next, not eventually. */
  max-height: calc(100dvh - var(--maplegend-top) - 16px);
  width: max-content;
  min-width: 132px;
  max-width: min(200px, calc(100vw - 16px));
}
.maplegend__title {
  margin: 0 0 8px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  opacity: 0.75;
}
/* ⚠ VERTICAL, one row per stop — see the note on the markup. */
.maplegend__rows { display: flex; flex-direction: column; gap: 3px; }
.maplegend__row { display: flex; align-items: center; gap: 8px; }
.maplegend__sw {
  width: 18px; height: 12px; flex: none;
  border-radius: 2px;
  /* A hairline so a pale stop still reads as a swatch on the dark card. */
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.maplegend__lbl { font-variant-numeric: tabular-nums; font-size: 12px; }

@media (max-width: 460px) {
  /* The panel is narrow already; keep it clear of the zoom control. */
  .maplegend { max-width: calc(100vw - 16px); }
}


/* ══ CINEMA ════════════════════════════════════════════════════════════════
   All of Fetch's chrome hidden — just the field. The third value of
   data-chrome; 'screen' hides only the site nav.

   ⚠ display: none, NEVER opacity: 0. An opacity-0 control keeps its box and
   stays in the tab order, so a "hidden" chrome would still be reachable by
   Tab and still announced — hidden to the eye and present to everything else.

   ⚠ AND .maplibregl-ctrl-bottom-right IS NOT IN THIS LIST. The Stadia /
   OpenMapTiles / OpenStreetMap credit is a LICENCE REQUIREMENT — covered
   attribution is absent attribution, which css/map.css and js/map.js both
   already say in capitals. It measures 332.5 x 44, about 85% of a 390px bottom
   strip, so it is not a detail that hides quietly. It stays. */
.map-body[data-chrome='cinema'] #map-modeswitch,
.map-body[data-chrome='cinema'] #map-options-btn,
.map-body[data-chrome='cinema'] #map-options,
.map-body[data-chrome='cinema'] #map-legend-btn,
.map-body[data-chrome='cinema'] #map-legend,
.map-body[data-chrome='cinema'] .maplibregl-ctrl-top-right,
/* ⚠ THE FULL-SCREEN TOGGLE GOES TOO, and it was missed on the first pass —
   the derived chrome roster caught it. Cinema already hides more than screen
   does, so a control offering to enter screen is both redundant and a second
   way out that says the wrong thing; the exit chip is the one visible
   control. */
.map-body[data-chrome='cinema'] #map-full-btn,
/* The way in is not needed once you are in — and leaving it would offer to
   enter a state the reader is already in. */
.map-body[data-chrome='cinema'] #map-cinema-btn,
.map-body[data-chrome='cinema'] #map-search-btn,
.map-body[data-chrome='cinema'] #map-note { display: none; }

/* ⚠ THE TIMELINE COLLAPSES, IT DOES NOT HIDE. Hiding it would take
   --timeline-h to zero and drop the attribution to the viewport floor; the
   ResizeObserver on #map-timeline republishes the height either way, so
   collapsing to the nub keeps the credit correctly lifted in BOTH states with
   no new code. */
.map-body[data-chrome='cinema'] .timeline__head,
.map-body[data-chrome='cinema'] .timeline__track,
.map-body[data-chrome='cinema'] .timeline__ticks { display: none; }
.map-body[data-chrome='cinema'] .timeline { padding: 0; background: none; border: 0; }

/* Panel up: the timeline returns, and brings the mode switcher with it —
   the mode row is hidden in cinema, so the panel is the only place mode can
   live, which is the real reason for the panel to exist beyond the timeline. */
.map-body[data-chrome='cinema'][data-cinema-panel='up'] .timeline__head,
.map-body[data-chrome='cinema'][data-cinema-panel='up'] .timeline__track,
.map-body[data-chrome='cinema'][data-cinema-panel='up'] .timeline__ticks { display: flex; }
.map-body[data-chrome='cinema'][data-cinema-panel='up'] .timeline__ticks { display: block; }
.map-body[data-chrome='cinema'][data-cinema-panel='up'] .timeline {
  padding: 0 10px 8px; background: var(--timeline-bg, rgba(8, 18, 32, 0.86));
}
.map-body[data-chrome='cinema'][data-cinema-panel='up'] #map-modeswitch {
  display: flex; position: static; margin: 0 0 6px;
}

/* ── the pull-up nub ─────────────────────────────────────────────────────── */
.timeline__nub { display: none; }
.map-body[data-chrome='cinema'] .timeline__nub {
  display: flex; align-items: center; gap: 6px;
  /* ⚠ CENTRED — both bottom CORNERS belong to the attribution. */
  margin: 0 auto; padding: 6px 12px;
  border: 1px solid rgba(255, 255, 255, 0.22);
  background: rgba(10, 22, 40, 0.72);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  color: var(--text-on-dark-2); border-radius: 999px;
  font: 600 11px/1 var(--font-sans, system-ui, sans-serif);
  cursor: pointer;
  /* ⚠ touch-action: none ON THE NUB ONLY. The tick strip is 2,352px in a 262px
     window at 390 — nine screens through a porthole, the most gesture-hungry
     surface on the page. Claiming the vertical axis here and leaving pan-x to
     the scroller keeps the two orthogonal: a drag-anywhere-to-dismiss design
     would fight the scroll on every horizontal gesture. */
  touch-action: none;
}
.map-body[data-chrome='cinema'] .timeline__ticks { touch-action: pan-x; }
.timeline__nubbar {
  width: 26px; height: 3px; border-radius: 2px;
  background: currentColor; opacity: 0.75;
}
/* The word shows on first entry then the pill shrinks to the bar alone. */
.timeline__nub[data-labelled='0'] .timeline__nubword { display: none; }

/* ── the exit chip ───────────────────────────────────────────────────────── */
.mapexit {
  position: absolute; top: var(--stack-1); right: 8px; z-index: 6;
  display: flex; align-items: center; gap: 5px;
  height: var(--mapopt-btn-h); padding: 0 11px;
  border: 1px solid rgba(255, 255, 255, 0.22);
  background: rgba(10, 22, 40, 0.72);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  color: var(--text-on-dark-2); border-radius: 999px;
  font: 600 11px/1 var(--font-sans, system-ui, sans-serif); cursor: pointer;
  transition: opacity 240ms ease;
}
.mapexit[hidden] { display: none; }
/* Fades back rather than away — it must never become unfindable. */
.mapexit[data-idle='1'] { opacity: 0.35; }
.mapexit:hover, .mapexit:focus-visible { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .mapexit { transition: none; } }

/* ── the search pin's label ─────────────────────────────────────────────────
   The app's shape (lib/map/swell_map_page.dart): a rounded plate carrying the
   NAME and a dismiss ×, sitting above the dot.

   ⚠ THE NAME IS A BUTTON, NOT A DIV WITH A HANDLER. The pin used to be
   aria-hidden with its name in title= only — visible to a sighted reader and
   absent to a screen reader, the same gap as an unresolvable × in the other
   direction. Real buttons are focusable and announced; nothing here relies on
   a title attribute to carry meaning. */
.mapsearch__pin { display: flex; flex-direction: column; align-items: center; }
.mapsearch__chip {
  display: flex; align-items: center; gap: 4px;
  max-width: 220px;
  padding: 5px 6px 5px 9px;
  border-radius: 10px;
  background: var(--surface, #fff);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.mapsearch__name {
  border: 0; background: none; padding: 0;
  font: 600 12px/1.25 var(--font-sans, "Inter", system-ui, sans-serif);
  color: var(--text, #16202C);
  text-align: left; cursor: pointer;
  /* Two lines then ellipsis, as the app does — a long spot name must not turn
     the chip into a billboard over the field it is pinned to. */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}
.mapsearch__name:hover { text-decoration: underline; }
.mapsearch__x {
  flex: 0 0 auto;
  width: 22px; height: 22px;          /* the app's 22px dismiss */
  border: 0; border-radius: 6px; background: none;
  font-size: 15px; line-height: 1;
  color: var(--text-secondary, #5B6B7C);
  cursor: pointer;
}
.mapsearch__x:hover { background: var(--surface-sunken, rgba(0,0,0,.06)); }
.mapsearch__dot {
  width: 10px; height: 10px; margin-top: 3px;
  border-radius: 50%;
  background: var(--accent, #12A594);
  box-shadow: 0 0 0 2px var(--surface, #fff);
}

/* The sheet's conditional action. Built only when the report is for a resolved
   entity — see setSheetAction; never present-and-hidden. */
.drsheet__full {
  margin-left: auto; margin-right: var(--sp-3, 12px);
  font: 600 13px/1 var(--font-sans, "Inter", system-ui, sans-serif);
  color: var(--accent-text, #0E7A66);
  text-decoration: none; white-space: nowrap;
}
.drsheet__full:hover { text-decoration: underline; }

/* ── storm-cell callout (radar tap-to-reveal) ──────────────────────────────
   Deliberately dark in both themes: it floats over the radar's own hard-coded
   semantic palette (near-white tracks, dark halos), not over page chrome. */
.stormpop__wrap .maplibregl-popup-content {
  /* The reference callout: translucent dark card, well rounded, the echo
     reading through it. */
  background: rgba(14, 18, 14, .78);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  color: #fff; border-radius: 14px;
  padding: 12px 14px; box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
}
.stormpop__wrap .maplibregl-popup-tip {
  border-top-color: rgba(14, 18, 14, .78); border-bottom-color: rgba(14, 18, 14, .78);
  border-left-color: rgba(14, 18, 14, .78); border-right-color: rgba(14, 18, 14, .78);
}
.stormpop { display: flex; align-items: center; gap: 14px;
  font-size: 14px; line-height: 1.4; }
.stormpop__title { margin: 0 0 2px; font-size: 17px; font-weight: 700; }
.stormpop__clock { margin: 0; }
.stormpop__row { margin: 0; }
.stormpop__row b { font-weight: 700; }
.stormpop__info { color: #4FC3F7; display: inline-flex; }
