/* SmokeCast — smoke-country palette: charcoal night, smoke-filtered daylight,
   ember amber, and the blue you're hoping for. */

@font-face {
  font-family: 'InterTight';
  src: url('fonts/intertight.woff2') format('woff2-variations');
  font-weight: 200 800;
  font-display: swap;
}

:root {
  /* Without this, Chrome on Android may decide the page is a LIGHT site
     and apply its own "auto dark theme", which flattens exactly the
     subtle dark-on-dark fills and translucent borders these notice
     cards rely on. Declaring the scheme tells it to keep its hands off. */
  color-scheme: dark;
  --ink: #14161b;
  --panel: #1d2129;
  --panel2: #242a35;
  --line: #2f3644;
  /* an hour nobody has read yet: dim neutral, never clean-air green */
  --rib-gap: #262c37;
  /* The floating pill's surface. A touch more see-through than the .82 it
     started at, which Kyle liked on a one-line pill. NOT used by the fire
     card: see the note there. */
  --glass: rgba(20,22,27,.76);
  --paper: #eceae2;
  --haze: #99a1ae;
  --ember: #e59043;
  --ember-deep: #c96f2e;
  --rust: #c15332;
  --clear: #86b9d6;
  --radius: 14px;
}

* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
/* On the ROOT only. overscroll-behavior propagates to the viewport from
   html and not from body, so this still stops the iOS rubber-band, while
   leaving none of it on body -- where, if body ever became a scroll box
   again, it would silently block scroll chaining. See the overflow-x note
   below: that is exactly how Android lost its scrolling for months. */
html { overscroll-behavior: none; }  /* no iOS rubber-band page drift */
body {
  margin: 0;
  background: var(--ink);
  color: var(--paper);
  font: 16px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  padding-bottom: env(safe-area-inset-bottom);
}
.mono { font-family: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace; }

/* Home-screen mode fills the entire screen, so scrolled content would
   slide under iOS's translucent status bar (Kyle: the clock and
   battery overlaid the app). This opaque strip catches it; in a
   browser tab the inset is 0 and the strip has no height. */
.statusbar {
  position: fixed; top: 0; left: 0; right: 0; z-index: 50;
  height: env(safe-area-inset-top, 0px);
  background: var(--ink);
  pointer-events: none;
}

a { color: var(--clear); text-decoration-color: color-mix(in srgb, var(--clear) 40%, transparent); }
button {
  font: inherit; color: var(--paper);
  background: var(--panel2); border: 1px solid var(--line);
  border-radius: 10px; padding: 10px 14px; cursor: pointer;
  min-height: 44px;
}
button:active { transform: translateY(1px); }
:focus-visible { outline: 2px solid var(--ember); outline-offset: 2px; }

/* ---------- header ---------- */
.top {
  display: flex; align-items: center; justify-content: space-between;
  padding: 4px 16px 6px;
  padding-top: calc(4px + env(safe-area-inset-top, 0px)); /* clear the iOS status bar in home-screen mode */
  max-width: 1100px; margin: 0 auto;
}
.brand { display: flex; flex-direction: column; gap: 2px; }
.name {
  /* an h1 now, for AT landmarks - margin 0 so the tag change is invisible */
  margin: 0;
  font-family: 'InterTight', sans-serif; font-weight: 750;
  font-size: 24px; letter-spacing: .2px; line-height: 1;
}
.tagline { color: var(--haze); font-size: 12px; letter-spacing: .03em; }

/* ---------- layout ---------- */
.app { max-width: 560px; margin: 0 auto; padding: 0 12px 32px; }

/* ---------- product tabs + their description, one panel ---------- */
.productbox {
  background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
}
.tabs { display: flex; gap: 6px; padding: 4px; }
.tab {
  flex: 1; border: 0; background: transparent;
  color: rgba(153, 161, 174, 0.8);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--haze) 80%, transparent);
  border-radius: 9px; font-weight: 600; min-height: 40px;
  transition: color .15s, background .15s;
}
.tab.active {
  background: #2b323f; color: #fff;
  box-shadow: inset 0 0 0 1px #3a4250, 0 1px 0 rgba(0,0,0,.25);
}
.tab.active::after { content: ''; display: block; height: 3px; margin: 3px auto 0; width: 40px; border-radius: 2px; background: var(--ember); }
.blurb {
  margin: 0; padding: 9px 13px 11px;
  border-top: 1px solid var(--line);
  color: rgba(236, 234, 226, 0.8);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--paper) 80%, transparent);
  font-size: 13px; line-height: 1.45; min-height: 2.6em;
}

/* Browsers with no color-mix() take the plain fallbacks above, but a
   translucent border can still come out nearly invisible there. Give
   those browsers solid, unmistakably orange edges — Kyle's Android
   testers kept seeing these cards with no box at all. */
@supports not (color: color-mix(in srgb, red 50%, white)) {
  .a2hs, .stripnote, .pinnote { border-color: #b8763c; background: #241f1b; }
  .stalebox { border-color: #a04e34; background: #241c1c; }
}

/* ---------- stage ---------- */
.stage {
  position: relative; overflow: hidden;
  /* The map layers inside this box are enormous -- at the 96x ceiling the
     smoke frame alone is scaled past 100,000 device px on a 3x screen.
     Without these, scrolling a 1871px page or moving a finger makes the
     browser re-rasterise that subtree, which is exactly what Kyle's phone
     reports: 844ms frames holding 0ms of our javascript, while a scripted
     zoom driving the identical code runs at 17ms. `contain` promises the
     browser nothing inside affects layout or paint outside, and the
     backface/translateZ pair keeps the box on its own compositing layer
     so a page scroll moves it instead of redrawing it. */
  contain: layout paint;
  transform: translateZ(0);
  backface-visibility: hidden;
  touch-action: pan-y;   /* vertical swipes scroll the PAGE, always */
  -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;
  border-radius: var(--radius); border: 1px solid var(--line);
  background: #e9edf2; /* map paper shows while frames decode */
  aspect-ratio: 5 / 6;   /* tall viewport — the map is the app */
  max-height: 68vh;
  margin-inline: auto;
  transition: box-shadow .5s ease;
}
/* home-city pin */
/* z 2 + after labels in DOM: paints over label dots, under the loading
   overlay (also z 2, later still) and the z-3 buttons/pill */
.citypin { position: absolute; width: 0; height: 0; z-index: 2; pointer-events: none; }
/* The blue dot, when the place came from the device rather than a search
   (Kyle: "like in Google Maps"). Same anchor as the pin, which stands down
   while this is up -- two markers on one point would just read as an
   error. The halo breathes rather than strobes: it is telling you where
   you are, not demanding anything. */
/* The long-press ring. Cased dark under bright for the same reason
   everything else on this map is: no single colour carries both parchment
   and deep smoke. It grows to full over the 550ms hold, so the wait reads
   as progress rather than as nothing happening. */
.pressring { position: absolute; width: 0; height: 0; z-index: 2; pointer-events: none; }
.pressring[hidden] { display: none; }
.pressring:not(.go)::after { animation: none; opacity: 0; }
/* A GLOW, not a ring. The bordered version read as a mistake under a
   finger (Kyle) -- a hard circle on a map is a claim about a boundary, and
   this is only saying "I felt that". Two tones for the same reason the
   marker highlight has two: a dark pool carries it over parchment, a light
   core carries it over deep smoke, and no single colour does both. */
.pressring::after {
  content: ''; position: absolute; left: 0; top: 0;
  /* Wide enough to be seen AROUND a fingertip. At 66px almost all of it was
     under the finger that triggered it (Kyle), which is the one place a
     glow cannot be looked at. The falloff carries real weight out past
     50px now, so what shows is the part beyond the skin. */
  width: 120px; height: 120px; margin: -60px 0 0 -60px;
  border-radius: 50%;
  background:
    radial-gradient(circle, rgba(255,255,255,.5) 0%, rgba(255,255,255,.38) 45%,
                            rgba(255,255,255,.2) 70%, rgba(255,255,255,0) 92%),
    radial-gradient(circle, rgba(28,16,6,.3) 0%, rgba(28,16,6,.2) 50%,
                            rgba(28,16,6,.1) 74%, rgba(28,16,6,0) 94%);
  transform: scale(.3); opacity: 0;
  animation: pressgrow .55s cubic-bezier(.25,.8,.3,1) forwards;
}
@keyframes pressgrow {
  0%   { transform: scale(.3); opacity: 0; }
  30%  { opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .pressring:not(.go)::after { animation: none; opacity: 0; }
.pressring::after { animation-duration: .01s; }
}

.geodot { position: absolute; width: 0; height: 0; z-index: 2; pointer-events: none; }
.geodot[hidden] { display: none; }
.geodot i, .geodot b, .geodot s {
  position: absolute; left: 0; top: 0; border-radius: 50%;
  transform: translate(-50%, -50%);
}
/* The pulse. It peaked at 15% effective alpha, which is invisible over
   the pale blue smoke it most often sits on -- blue on blue (Kyle: "very
   subtle, hard to see"). Brighter fill, a defined edge so it reads as a
   shape against a similar hue rather than relying on the fill alone, and
   less dead air at the end of the cycle. Still breathing, not strobing. */
.geodot i {
  width: 54px; height: 54px; background: rgba(58,132,255,.32);
  box-shadow: inset 0 0 0 2.5px rgba(90,158,255,.85), 0 0 0 1px rgba(255,255,255,.45);
  animation: geopulse 2s ease-out infinite;
}
.geodot b {                       /* the dot */
  width: 15px; height: 15px; background: #3a84ff;
  box-shadow: 0 0 0 3px #fff, 0 2px 5px rgba(0,0,0,.45);
}
@keyframes geopulse {
  0%   { transform: translate(-50%, -50%) scale(.3); opacity: .95; }
  80%  { transform: translate(-50%, -50%) scale(1);  opacity: 0; }
  100% { transform: translate(-50%, -50%) scale(1);  opacity: 0; }
}

/* STANDING ON ONE OF YOUR OWN SAVED PLACES.
   ----------------------------------------
   Two things are wrong when the blue dot lands under a pill, and only one
   of them is the dot.

   The dot itself is 15px plus a 3px casing, so 21px, under a disc that is
   26px plus its own casings, so about 29px. It is not partly covered, it
   is entirely covered, at every zoom, always. Drawing it costs a
   composited layer to show nobody anything, so it goes.

   The pulse is the one worth rescuing. It breathes from scale .3 to 1 of
   54px, which is 16px out to 54px, and the disc sitting on top of it is
   29px -- so the whole first HALF of every cycle happened inside the pill
   where it could not be seen, and what escaped read as a weak flicker
   rather than a signal. Starting it just outside the disc puts the entire
   animation on screen. That is the whole fix: a blue ring breathing out
   from behind your pill, with the heading beam leaving it.

   Nothing about the pill changes. Not its size, not its number, not the
   ember ring the selected place already wears. A fourth ring colour on a
   mark that is already saying "saved", "selected" and "21" would be soup,
   and the ring that moves is the one the eye finds anyway. */
.stage.geo-on-pin .geodot b { display: none; }
/* A RING, NOT A DISC. Kyle compared three renders of this and picked it.
   The pulse is a filled translucent circle everywhere else, which is right
   when it is the only thing there -- but under a pill the fill only tints
   the map blue behind a number that has to stay readable, and the pill
   already owns the centre. Dropping the fill leaves the edge, which is the
   part that was carrying the movement anyway. */
.stage.geo-on-pin .geodot i {
  animation-name: geopulse-pin;
  background: none;
  box-shadow: inset 0 0 0 3px rgba(90, 158, 255, .95), 0 0 0 1px rgba(255, 255, 255, .5);
}
@keyframes geopulse-pin {
  0%   { transform: translate(-50%, -50%) scale(.58); opacity: .95; }
  80%  { transform: translate(-50%, -50%) scale(1.18); opacity: 0; }
  100% { transform: translate(-50%, -50%) scale(1.18); opacity: 0; }
}
/* A person who has asked for less motion still gets the position, just
   without the breathing. The beam and the pill carry it. */
@media (prefers-reduced-motion: reduce) {
  .stage.geo-on-pin .geodot i { animation: none; opacity: .5;
    transform: translate(-50%, -50%) scale(.72); }
}
/* Which way the phone is pointing (Kyle: "the cone coming out of the blue
   dot"). conic-gradient measures from 12 o'clock clockwise, which is
   exactly how a compass bearing reads, and this map is always north-up --
   so the beam is the heading, rotated, with no projection maths at all.
   Hidden until the compass actually reports: a beam pointing nowhere in
   particular is worse than no beam. */
.geodot s {
  width: 88px; height: 88px;
  transform: translate(-50%, -50%) rotate(var(--hdg, 0deg));
  /* Pale rims either side of the blue. Maps can use plain blue because its
     ground is always neutral grey; ours is most often BLUE SMOKE, which is
     the one case that matters, and a blue wedge on it disappeared
     completely when measured against the real basemap. The rims carry the
     shape on pale smoke, dark smoke and bare tan alike.

     The rims are held bands with short blends, not long ramps. A wide ramp
     from white to blue interpolates through a washed-out pale band that is
     neither colour, and it read as a GAP between the blue and its own edge
     (Kyle, who spotted it zoomed in). Matching alphas either side of the
     handoff keeps that junction clean. The softness worth having is
     radial, further from the dot, and the mask below does that. */
  background: conic-gradient(from -33deg,
    rgba(255,255,255,0) 0deg,
    rgba(255,255,255,.92) 1.3deg,
    rgba(255,255,255,.92) 4.6deg,
    rgba(56,128,246,.92) 5.3deg,
    rgba(56,128,246,.92) 60.7deg,
    rgba(255,255,255,.92) 61.4deg,
    rgba(255,255,255,.92) 64.7deg,
    rgba(255,255,255,0) 66deg);
  /* Hold full strength well past the dot before fading. An earlier falloff
     erased the beam over exactly the band where the dot was not already
     covering it, which read as no beam at all. */
  -webkit-mask-image: radial-gradient(closest-side, #000 0%, #000 58%, transparent 100%);
  mask-image: radial-gradient(closest-side, #000 0%, #000 58%, transparent 100%);
  opacity: 0; transition: opacity .35s ease;
}
.geodot.has-heading s { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .geodot i { animation-duration: 4s; }
}
.stage.geo-here .citypin { display: none; }
/* The dot is centred ON the anchor while the featured name starts 2px
   below it, so the dot's bottom half printed straight through "Ashland"
   (Kyle, and zooming didn't help — both offsets are fixed px). Clear the
   name past the dot's white ring. Only while the two share a point:
   once the dot separates, geo-here is off and the name sits normally. */
.stage.geo-here .labels .home { margin-top: 13px; }
.citypin svg {
  position: absolute; left: 0; top: 0;
  transform: translate(-50%, -100%);
  filter: drop-shadow(0 2px 3px rgba(0,0,0,.45));
}
/* ---------- full-screen map ----------
   The inline map must never trap the page (Android users couldn't
   scroll at all), so it only takes horizontal drags. Real map work
   deserves the whole screen: in here one finger does everything and
   the timeline comes along. */
.fullbtn {
  position: absolute; left: 10px; top: 10px; z-index: 3;
  width: 38px; height: 38px; min-height: 0; padding: 0;
  display: grid; place-items: center; border-radius: 10px;
  background: rgba(20,22,27,.82); backdrop-filter: blur(6px);
  border-color: rgba(255,255,255,.14); color: var(--paper);
}
.fullbtn .ic-collapse { display: none; }
body.mapfull .fullbtn .ic-expand { display: none; }
body.mapfull .fullbtn .ic-collapse { display: block; }

body.mapfull { overflow: hidden; }
body.mapfull .stage {
  /* The map box STOPS at the panel rather than hiding behind it. The
     panel is opaque, so nothing is lost — and it means the map's own
     box is the visible area, so centring and zoom limits need no
     correction maths at all. */
  position: fixed; top: 0; left: 0; right: 0; bottom: var(--panelh, 0px);
  z-index: 40;
  background: var(--ink);      /* letterbox bands read as app, not as map */
  max-height: none; aspect-ratio: auto;
  border: 0; border-radius: 0;
  touch-action: none;          /* in here the map owns every gesture */
}
body.mapfull .spot { bottom: 10px; left: 10px; }

/* What the model can actually resolve, shown only once zoomed past it.
   A tester zoomed in looking for a swimming hole — the map should go
   there, but it shouldn't imply the forecast knows that address. */
/* Home-screen mode runs under the status bar, and the opaque strip that
   protects scrolled content paints above the map — it swallowed both of
   these buttons, leaving no way out of full screen (Kyle, on-device). */
body.mapfull .fullbtn,
body.mapfull .zoombtn { top: calc(10px + env(safe-area-inset-top, 0px)); }
/* one panel, not two fixed strips with map showing through between */
body.mapfull .col-controls {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 41;
  /* tight: every pixel here is map the user doesn't get (Kyle). The
     bottom inset is the home-indicator zone — iOS needs it kept clear. */
  padding: 5px 12px calc(2px + env(safe-area-inset-bottom, 0px));
  background: var(--ink);      /* opaque — map colours bled through the
                                  translucent version while panning */
  border-top: 1px solid var(--line);
}
/* the search field comes along so a dropped pin can be starred without
   leaving full screen; its label doesn't earn the space */
body.mapfull .col-controls > *:not(.readout):not(.scrub):not(.productbox):not(.stalebox):not(.homecity) { display: none; }
body.mapfull .readout { margin-top: 0; }
body.mapfull .readout .big { font-size: 20px; }
body.mapfull .readout .date { font-size: 12.5px; }
body.mapfull .scrub { margin: 2px 0 0; }
body.mapfull .homecity { margin-top: 6px; }
body.mapfull .homecity label { display: none; }
body.mapfull .homecity input {
  min-height: 42px; font-size: 15px; padding: 8px 44px 8px 38px;
}
body.mapfull .searchwrap::before { top: 21px; left: 12px; }
body.mapfull .starbtn { width: 36px; height: 36px; right: 4px; }
/* suggestions must open UPWARD out of a bottom-anchored panel */
body.mapfull .suggestions { top: auto; bottom: calc(100% + 6px); }
/* the tabs come along — switching surface/total is half of reading a map
   (Kyle) — but the description below them doesn't earn the space here */
body.mapfull .productbox { margin-top: 4px; background: none; border: 0; }
body.mapfull .productbox .blurb { display: none; }
body.mapfull .tabs { padding: 0; gap: 8px; }
body.mapfull .tab { min-height: 34px; font-size: 13.5px; }

.zoombtn {
  position: absolute; right: 10px; top: 10px; z-index: 3;
  font-size: 13px; font-weight: 600; min-height: 0; padding: 8px 12px;
  background: rgba(20,22,27,.82); backdrop-filter: blur(6px);
  border-color: rgba(255,255,255,.14);
}
.overlay {
  position: absolute; inset: 0; z-index: 2;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
  background: rgba(20, 22, 27, .88);   /* fallback: no color-mix() */
  background: color-mix(in srgb, var(--ink) 88%, transparent);
  color: var(--haze); text-align: center; padding: 24px; font-size: 14.5px;
}
.overlay.failed { color: var(--paper); }
.overlay.failed p { max-width: 34ch; }
.spin {
  width: 34px; height: 34px; border-radius: 50%;
  border: 3px solid var(--line); border-top-color: var(--ember);
  animation: spin .9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ---------- spot pill (overlaid on the map) ---------- */
.spot {
  position: absolute; left: 10px; bottom: 10px; z-index: 3;
  display: flex; align-items: center; gap: 8px;
  padding: 7px 12px; border-radius: 999px;
  background: var(--glass); backdrop-filter: blur(6px);
  border: 1px solid rgba(255,255,255,.14);
  font-size: 13px; font-weight: 600; white-space: nowrap;
}
/* The fire card had NO stylesheet rule at all. It was a transparent,
   statically positioned div at the top of the stage's flow, painted UNDER
   every map layer (they are z-index 2, it was z auto), so tapping a hot
   spot set the right text into something nobody could see. Kyle reported
   "I can't tap the fires and get any information" from the first day the
   feature shipped, and every check I ran read textContent, which is set
   whether or not anything is visible. Styled like the .spot pill it sits
   above, and above the map at z 3. */
.firecard {
  /* Bottom, not top: the top of the stage already carries the full-screen
     icon and the "Full map" button, and the card's title landed under
     both. Down here it takes the pill's place, which is why the pill
     stands down while it is open. */
  /* Above the saved-place pills. Both sat at z-index 3 and the pills come
     later in the DOM, so they painted straight through the card (Kyle). */
  position: absolute; left: 10px; right: 10px; bottom: 10px; z-index: 4;
  padding: 10px 34px 11px 12px; border-radius: 12px;
  /* SOLID, and the two boxes differ on purpose. Matching the pill's glass
     was tried and taken straight back out: Kyle, on a card sitting over the
     Grasshopper plume with a dozen diamonds behind it, "I definitely don't
     like the see-through effect... it is really distracting". The pill is
     one line in an empty corner and the card is a paragraph over the
     busiest part of the map, so the same surface does not suit both. The
     pill keeps the glass it earned.

     No backdrop-filter either, and that part is not cosmetic: a blur
     re-samples whatever is behind it every time the backdrop changes, this
     box is large, and it sits over a map that repaints on every finger
     move. It was the one thing present in Kyle's real freezing sessions and
     absent from every clean synthetic pass. */
  background: #14161b;
  border: 1px solid rgba(255,255,255,.14);
  font-size: 13px; line-height: 1.45; color: var(--paper);
  box-shadow: 0 6px 20px rgba(0,0,0,.35);
}
.firecard[hidden] { display: none; }
/* The pill lives along the bottom of the map and sat on top of the
   card's text, so it stands down while a card is open. */
.stage.card-open .spot { display: none; }
.firecard b { font-weight: 700; }
/* A button, not a link. An underlined phrase is a ~16px-tall target, and
   it sat on a card where any stray tap dismissed the whole thing -- so
   missing it did not just fail, it threw away what you were reading (Kyle
   needed three tries). A pill states that it is pressable, and the ::after
   gives it 52px of thumb without making the pill itself that tall. */
.perimbtn {
  position: relative; display: inline-flex; align-items: center;
  margin-top: 10px; min-height: 38px; padding: 0 15px;
  border-radius: 999px; cursor: pointer;
  background: rgba(229,144,67,.16); border: 1px solid rgba(229,144,67,.55);
  color: #f5b070; font-size: 13px; font-weight: 650; line-height: 1;
  -webkit-tap-highlight-color: transparent;
}
.perimbtn::after { content: ''; position: absolute; inset: -7px -6px; }
.perimbtn:active { background: rgba(229,144,67,.28); }
/* Hiding is a different KIND of action from showing, so it wears a
   different colour (Kyle). Checking a run of fires, you stop reading the
   label and start reading the colour: ember means "there is a shape to
   see", neutral means "you are already looking at it". */
.perimhint {
  margin: 8px 2px 0; font-size: 12px; line-height: 1.45; font-style: italic;
  color: rgba(153,161,174,.9); color: color-mix(in srgb, var(--haze) 90%, transparent);
}
.perimbtn.is-on {
  background: rgba(255,255,255,.07);
  border-color: rgba(255,255,255,.22);
  color: var(--paper);
}
.perimbtn.is-on:active { background: rgba(255,255,255,.14); }
.perimbtn:disabled {
  background: rgba(255,255,255,.05); border-color: var(--line);
  color: var(--haze); cursor: default;
}
.perimbtn:disabled::after { content: none; }
.firecard .sub {
  margin-top: 4px; font-size: 12px; line-height: 1.4;
  color: rgba(236,234,226,.72);
}

/* The empty-state twin of the reading pill: same glass, same corner, so
   answering it swaps one for the other in place rather than moving
   anything. */
.spotcta {
  min-height: 0; cursor: pointer;
  color: var(--paper); gap: 7px;
}
.spotcta[hidden] { display: none; }
.spotcta svg { opacity: .8; flex: none; }
.qhint {
  margin: 7px 0 0; padding-top: 7px;
  border-top: 1px solid var(--line);
  font-size: 11.5px; line-height: 1.45;
  color: rgba(153,161,174,.85); color: color-mix(in srgb, var(--haze) 85%, transparent);
}
.spot .dot {
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--haze); flex: none;
  transition: background .35s ease;
}
.spot small { color: var(--haze); font-weight: 500; }

/* The observed reading sits under the forecast, deliberately quieter than
   it: the forecast is what the app is for, this is the sanity check. It
   is a button because the mismatch between smoke-only and total PM2.5
   needs explaining the first time someone notices it. */
/* The guidance sentence: paper-coloured so it reads as advice, not as
   metadata like the haze-grey monitor line under it. Two-line max on a
   phone; the copy is written to fit. */
/* Guidance lives on the pill now, as an overlay above it. Same rule as
   .qabout: an overlay covers, it never grows the layout, so switching
   products cannot move the timeline under a finger. */
/* 44px hit target on a 35px pill: the button overhangs the pill with
   negative margins so the pill keeps its shape and the thumb gets its
   square. Kyle missed the 23px version and hit a fire under the pill. */
.spotinfo {
  flex: none; display: flex; align-items: center; justify-content: center;
  background: none; border: 0; padding: 0;
  width: 44px; height: 44px; min-height: 0; min-width: 0;
  margin: -12px -18px -12px -12px;
  color: var(--haze); opacity: .75; cursor: pointer;
  line-height: 0; border-radius: 50%; touch-action: manipulation;
}
.spot { cursor: pointer; }
.spotinfo:hover, .spotinfo[aria-expanded="true"] { opacity: 1; color: var(--paper); }
.spotguide {
  position: absolute; left: 10px; bottom: 50px; z-index: 4;
  max-width: min(320px, calc(100% - 20px));
  padding: 9px 12px; border-radius: 12px;
  background: var(--glass); backdrop-filter: blur(6px);
  border: 1px solid rgba(255,255,255,.14);
  font-size: 13px; line-height: 1.4; font-weight: 500;
  color: color-mix(in srgb, var(--paper) 92%, transparent);
  display: flex; align-items: flex-start; gap: 6px;
}
.spotguide-x {
  flex: none; width: 44px; height: 44px; min-height: 0; min-width: 0;
  margin: -12px -16px -12px -4px; padding: 0;
  background: none; border: 0; border-radius: 50%; color: var(--haze);
  font: 400 20px/1 -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
  cursor: pointer; touch-action: manipulation;
}
.spotguide-x:hover { color: var(--paper); }
.obsline {
  display: flex; align-items: center; gap: 1px;
  margin: 6px 2px 4px; padding: 0; border: 0; background: none;
  font: inherit; font-size: 12.5px; text-align: left;
  color: color-mix(in srgb, var(--haze) 78%, transparent);
  color: rgba(155, 163, 175, .95);
}
/* THE ONLY PRESSABLE PART OF THAT LINE. The whole line used to be a
   button, sitting directly above the scrubber, so a thumb reaching for
   the timeline opened an explanation over the control it was reaching
   for (Kyle). The target is wider than the glyph and deliberately short,
   so it cannot reach down into the track underneath. */
.obstext { flex: 1 1 auto; min-width: 0; }
.obsinfo {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  position: relative;
  width: 20px; height: 20px; margin-left: 4px;
  background: none; border: 0; padding: 0; min-height: 0; min-width: 0;
  color: var(--haze); opacity: .8; cursor: pointer; border-radius: 50%;
  line-height: 0; touch-action: manipulation;
}
.obsinfo::before {
  content: ''; position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%); width: 44px; height: 26px; border-radius: 13px;
}
.obsinfo:hover { opacity: 1; color: var(--paper); }
@supports (color: color-mix(in srgb, red 50%, transparent)) {
  .obsline { color: color-mix(in srgb, var(--haze) 78%, transparent); }
}
.obsline b { color: var(--ink-soft, #e9e7e2); font-weight: 600; }
.obsline .obsdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%;
  margin-right: 6px; vertical-align: baseline; }
.obsline .far { color: #ffb056; }

/* ---------- readout ---------- */
/* Always two lines — content-dependent wrapping made the slider below
   jump up and down while scrubbing. */
.readout { display: block; margin: 14px 2px 0; }
.readtop { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.readwhen { min-width: 0; }
.quicklayers { position: relative; flex: none; }
.qbtn {
  min-height: 0; padding: 7px 11px; border-radius: 999px;
  background: rgba(255,255,255,.06); border: 1px solid var(--line);
  color: var(--paper); font-size: 12.5px; font-weight: 600; white-space: nowrap;
}
.qbtn[aria-expanded="true"] { background: rgba(229,144,67,.18); border-color: rgba(229,144,67,.55); }
.qpop {
  position: absolute; right: 0; top: calc(100% + 6px); z-index: 6;
  /* Full screen leaves almost no panel below the button, so the panel ran
     off the bottom of the screen and lost its last key line (Kyle). It
     flips above the button when there is not room under it, and carries a
     scroll cap either way so it can never be taller than the screen. */
  max-height: calc(100vh - 96px); overflow-y: auto; overscroll-behavior: contain;
  /* Wider, because the key now spells each layer out in full and Kyle has
     the room to spend on saying it clearly. */
  width: max-content; max-width: min(92vw, 344px);
  background: #14161b; border: 1px solid var(--line); border-radius: 12px;
  padding: 8px 10px; box-shadow: 0 10px 28px rgba(0,0,0,.45);
}
.qpop.up { top: auto; bottom: calc(100% + 6px); }
/* A visible way out. Tapping outside works and so does the button that
   opened it, but neither announces itself, and the outside tap used to
   land on the map as well. */
/* OPTION A, currently in: a titled header strip. The X floating in a bare
   22px of padding read as an empty row (Kyle: "the X has added a lot of
   space to the top, and it doesn't look great"). Same height, but it now
   names the panel as well as closing it, so the space earns itself. */
.qhead {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px; padding: 2px 2px 8px;
  border-bottom: 1px solid var(--line); margin-bottom: 6px;
}
/* The title became a two-way switch (Kyle). The inactive side sits at .8
   rather than the .62 it started at -- at .62 he read it as blacked out,
   and a tab that looks disabled is a tab nobody presses. Colour still
   drops to haze, so "secondary" is carried by hue as well as by opacity. */
.qtabs { display: flex; align-items: center; gap: 22px; flex: 1; min-width: 0; }
.qtab {
  background: none; border: 0; margin: 0; cursor: pointer; min-height: 0;
  font-family: inherit; font-size: 12px; font-weight: 650; letter-spacing: .06em;
  text-transform: uppercase; color: var(--haze); opacity: .8;
  display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
  /* border-radius: 0 is NOT redundant. The global `button` rule sets 10px,
     and it applies to the underline below, which rendered it as a fat
     lozenge with curved ends instead of a rule (Kyle: "I really don't like
     the orange bar"). The mockup had no global button style, which is why
     it looked right there and wrong here. */
  border-radius: 0;
  /* Sits ON the header's own divider rather than floating above it: 8px of
     header padding, less this 2px rule, so the two lines meet. */
  border-bottom: 2px solid transparent;
  padding: 2px 0 6px; margin-bottom: -8px;
  transition: color .13s ease, opacity .13s ease;
}
.qtab:hover { opacity: 1; }
.qtab.on { color: var(--paper); opacity: 1; border-bottom-color: var(--ember); }
/* The swatch is what stops the dim word reading as disabled, and it IS the
   thing people are hunting for. 22px gap to the left of it, 7px to its own
   word, so it visibly belongs to "Legend" rather than floating between. */
.qsw {
  width: 22px; height: 9px; border-radius: 4.5px; border: 1px solid var(--line); flex: none;
  background: linear-gradient(to right, #d9e8f5 0 14%, #4a8ec4 0 28%, #3f9e4d 0 43%,
    #f4ef9e 0 64%, #ef9f3c 0 79%, #c02c20 0 93%, #8f4fb4 0 100%);
}
/* Both panes share one grid cell, so the box is always as tall as the
   TALLER of the two and switching never resizes the panel. No magic pixel
   number to go stale when either pane's copy changes. The off pane keeps
   its space but is visibility:hidden, which also takes it out of the
   accessibility tree and out of the tab order. */
.qbody { display: grid; }
.qbody > .qlist, .qbody > .qlegend { grid-area: 1 / 1; }
.qbody > .off { visibility: hidden; pointer-events: none; }
.qlegend { display: flex; flex-direction: column; gap: 10px; padding: 4px 2px 0; }
.qclose {
  flex: none; width: 30px; height: 30px; min-height: 0; padding: 0; margin: -4px -4px -4px 0;
  display: grid; place-items: center;
  background: none; border: 0; color: var(--haze);
  font-size: 20px; line-height: 1; border-radius: 50%;
}
.qclose:hover { color: var(--paper); }
/* The list keeps its own height so the overlay has a box to fill. */
.qpop[hidden] { display: none; }
.qpop label {
  display: flex; align-items: center; gap: 9px;
  padding: 6px 2px; font-size: 13px; cursor: pointer;
}
.qpop input { flex: none; width: 16px; height: 16px; accent-color: #e59043; }
/* The mark sits on its own switch now. One box for every glyph, and an
   empty box of the same width on the rows that have no mark, so every
   label starts on the same line. */
.qpop .kico, .qpop .qgap { width: 17px; height: 20px; flex: none; }
.qpop .kico { overflow: visible; }
.qpop label { gap: 8px; flex: 1; min-width: 0; }
.qrow { display: flex; align-items: center; gap: 4px; }
/* The info button, same glyph as the model-run line so it reads as the
   same offer. Sits OUTSIDE the label: nested in one, a tap on it would
   activate the label and toggle the layer it is trying to explain. */
.qinfo {
  flex: none; background: none; border: 0; padding: 6px; min-height: 0;
  color: var(--haze); opacity: .65; cursor: pointer; line-height: 0;
  border-radius: 50%;
}
.qinfo:hover, .qinfo[aria-expanded="true"] { opacity: 1; color: var(--paper); }
/* The description COVERS the panel rather than growing it. Expanding a row
   pushed every row below it down and made the whole menu jump, which Kyle
   called clunky and abrasive. As an overlay the panel keeps exactly the
   size it had, and the description is the only thing that changed. */
.qabout {
  position: absolute; inset: 0; z-index: 2;
  display: flex; flex-direction: column; gap: 10px;
  padding: 12px 14px; overflow-y: auto; overscroll-behavior: contain;
  background: #14161b; border-radius: 12px;
  font-size: 13px; line-height: 1.55; color: var(--haze);
}
.qabout[hidden] { display: none; }
.qabout p { margin: 0; }
.qabout em { font-style: italic; color: var(--paper); }
.qabout-h {
  display: flex; align-items: center; gap: 8px;
  color: var(--paper); font-weight: 650; font-size: 13.5px;
}
.qabout-h .kico { width: 17px; height: 20px; flex: none; overflow: visible; }
.qback {
  align-self: flex-start; margin-top: auto;
  background: var(--panel2); border: 1px solid var(--line); color: var(--paper);
  border-radius: 999px; padding: 7px 14px; min-height: 0; font-size: 12.5px;
}
.readout .big {
  margin: 0;   /* it is an h2 now; the h2 is for AT, the box must not move */
  font-size: clamp(21px, 6vw, 24px); font-weight: 720; line-height: 1.05;   /* caps at the wordmark size */
  letter-spacing: .2px; white-space: nowrap;
  font-variant-numeric: tabular-nums;   /* digits hold still while scrubbing */
}
/* Back to one line. The only string that overran this box carried a
   relative day AND a timezone, and renderReadout no longer emits both --
   the zone stands down on those frames -- so the longest form is now
   "September 30th · Tomorrow afternoon" at 248px inside 274px. */
.readout .date { color: var(--haze); font-size: 14px; white-space: nowrap; margin-top: 2px; }
/* Small and quiet beside the big time: it qualifies the clock, it is not
   part of it. Same size as the date line below so the two read as one
   register of supporting text. */
/* While the search is focused, the page gets temporary room underneath.
   The field lives near the END of the document, so without this there is
   nothing below it to scroll against and it cannot be lifted clear of the
   keyboard no matter what you call scrollTo with -- which is why it stayed
   pinned to the bottom under the autofill bar (Kyle). */
body.searching .app { padding-bottom: var(--kbpad, 62vh); }
/* The report form is the last thing on the page, so it needs the same
   scroll room the search field does or the keyboard simply covers it. */
body.reporting .app { padding-bottom: var(--kbpad, 62vh); }

.readout .big .tz {
  font-size: 14px; font-weight: 600; letter-spacing: 0; color: var(--haze);
}

/* ---------- stale-data notice ----------
   NOAA's RRFS is experimental and does go quiet (it stopped for ~30h on
   Aug 12-13). Showing an old run is fine; letting it look current is
   not. */
.stalebox {
  position: relative;
  margin: 10px 0 0; padding: 9px 38px 9px 12px;
  border: 1.5px solid rgba(193, 83, 50, .55);                        /* fallback */
  border: 1.5px solid color-mix(in srgb, var(--rust) 55%, transparent);
  border-radius: 10px;
  background: #2d262a;                                              /* fallback */
  background: color-mix(in srgb, var(--rust) 10%, var(--panel));
  color: rgba(236, 234, 226, 0.88);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--paper) 88%, transparent);
  font-size: 12.5px; line-height: 1.5;
}
.stalebox b { color: var(--paper); }
.stalebox p { margin: 0; }

/* ---------- scrubber ---------- */
.scrub { margin: 8px 0 4px; }
.scrubrow { display: flex; align-items: center; gap: 8px; }
.scrubrow .trackwrap { flex: 1; }
.playbtn {
  flex: none; width: 42px; height: 42px; min-height: 0; border-radius: 50%;
  display: grid; place-items: center; padding: 0; cursor: pointer;
  background: var(--panel2); border: 1px solid var(--line);
  color: var(--paper); -webkit-tap-highlight-color: transparent;
}
.playbtn:active { background: var(--panel); }
.playbtn .ic-play { margin-left: 1px; }   /* optical center for the triangle */
.playbtn .ic-pause { display: none; }
.playbtn.playing .ic-play { display: none; }
.playbtn.playing .ic-pause { display: block; }

/* ---------- long-press pin coach mark ----------
   Nobody discovered the long-press pin, so it needed saying out loud
   (Kyle). Same orange-card voice as the timeline mark, sitting right
   under the search field, where someone is already looking for a place
   that is not in the list. */
.pinnote {
  position: relative; margin: 10px 2px 0; padding: 11px 34px 11px 13px;
  border-radius: 12px; background: #23201d;                          /* fallback */
  border: 1.4px solid rgba(229, 144, 67, .6);
}
@supports (background: color-mix(in srgb, red 50%, transparent)) {
  .pinnote { background: color-mix(in srgb, var(--card) 96%, transparent); }
}
.pinnote p { margin: 0; font-size: 13px; line-height: 1.45; }

/* ---------- timeline coach mark ----------
   Same notice voice as the install card; the up-caret points at the
   track. Shows until the first timeline interaction proves the lesson
   landed (seek() dismisses it), or the X. */
.stripnote {
  position: relative; margin: 12px 0;
  padding: 10px 38px 10px 12px;
  border: 1.5px solid rgba(229, 144, 67, .6);                        /* fallback */
  border: 1.5px solid color-mix(in srgb, var(--ember) 60%, transparent);
  border-radius: 12px;
  background: #2b292b;                                              /* fallback */
  background: color-mix(in srgb, var(--ember) 7%, var(--panel));
}
/* hand-drawn arrow springing from the card up to the timeline */
.stripnote .stripnote-arrow {
  position: absolute; top: -54px; left: 44%;
  color: var(--ember-deep); pointer-events: none;
}
.stripnote p {
  margin: 0; font-size: 12.5px; line-height: 1.5;
  color: rgba(236, 234, 226, 0.88);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--paper) 88%, transparent);
}
.stripnote p b { color: var(--paper); }
.stripnote .gg { color: #7ced70; color: color-mix(in srgb, #00e400 65%, var(--paper)); font-weight: 650; }

.trackwrap { position: relative; }
input[type=range] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 44px; background: transparent; margin: 0; display: block;
  touch-action: pan-y;  /* vertical swipes scroll the page; horizontal
                           intent hands the touch to the custom drag */
}
input[type=range]::-webkit-slider-runnable-track {
  height: 16px; border-radius: 8px;
  background: var(--track, var(--panel2));
  border: 1px solid var(--line);
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 32px; height: 32px; border-radius: 50%;
  /* black & white on purpose: an ember thumb read as a smoke color
     sitting ON the color-coded strip (friend feedback, Aug 12) */
  /* bullseye like the map pin: white core, dark ring, thin white outer
     hairline — crisp on the dark page, no glow (Kyle) */
  background: #fff; border: 3px solid #2b323f;
  box-shadow: 0 0 0 1.5px rgba(255,255,255,.9), 0 1px 5px rgba(0,0,0,.45);
  margin-top: -8px;   /* (32px thumb - 16px border-box track) / 2 */
}
input[type=range]::-moz-range-track {
  height: 16px; border-radius: 8px;
  background: var(--track, var(--panel2));
  border: 1px solid var(--line);
}
input[type=range]::-moz-range-thumb {
  width: 24px; height: 24px; border-radius: 50%;
  background: #fff; border: 3px solid #2b323f;
  box-shadow: 0 0 0 1.5px rgba(255,255,255,.9), 0 1px 5px rgba(0,0,0,.45);
}
/* spans the full 32px thumb so at NOW the line reads as a crosshair
   passing straight through the button, ring to ring */
.nowmark {
  position: absolute; top: 5px; width: 2px; height: 34px; z-index: 1;
  background: var(--clear); border-radius: 1px; pointer-events: none;
}
/* THE LABEL IS THE BUTTON. Kyle: "scrubbing back to Now with my thumb and
   also pressing Now doesn't seem good enough ... we need to broaden the
   area that puts it on Now." A 2px dash is not a target. The word above
   the track is, and its hit area reaches UP and sideways only -- never
   down over the track, or it would swallow scrubs that begin on the
   line. */
.nowlab {
  position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
  padding: 0; margin: 0; border: 0; background: none; min-height: 0; min-width: 0;
  font: inherit; font-size: 9px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--clear); line-height: 1; cursor: pointer;
  pointer-events: auto; touch-action: manipulation; white-space: nowrap;
}
.nowlab::before {
  content: ''; position: absolute; left: 50%; bottom: -4px;
  transform: translateX(-50%); width: 64px; height: 34px; border-radius: 10px;
}
.nowlab:active { transform: translateX(-50%); }   /* the global press-shift must not move it */
/* THE THUMB IS SITTING ON IT. Confirms the magnet landed, and tells you
   at a glance that the number on screen is the current reading rather
   than a forecast, without having to read the date line. */
.nowmark.on { background: #fff; box-shadow: 0 0 7px rgba(255, 255, 255, .6); }
.nowmark.on .nowlab { color: #fff; font-weight: 700; }
/* left margin = play button (42px) + gap (8px) + the 16px half-thumb
   inset, so day labels stay aligned under the track */
.days { display: flex; margin: 2px 16px 0 66px; }
.days span {
  font-size: 10.5px; color: var(--haze); text-transform: uppercase; letter-spacing: .1em;
  border-left: 1px solid var(--line); padding: 1px 0 0 5px; overflow: hidden; white-space: nowrap;
}
.days span:first-child { border-left: 0; padding-left: 0; }

/* ---------- tabs sit below the scrubber ---------- */
.productbox { margin-top: 16px; }

/* ---------- legend ---------- */
.legend { margin-top: 16px; }
.bar {
  height: 12px; border-radius: 6px; border: 1px solid var(--line);
  background: linear-gradient(to right,
    #d9e8f5 0 7.1%, #b3d3ea 0 14.3%, #7fb2d9 0 21.4%, #4a8ec4 0 28.6%,
    #3b8a71 0 35.7%, #3f9e4d 0 42.9%, #2f7a38 0 50%, #b9d77e 0 57.1%,
    #f4ef9e 0 64.3%, #f2c94c 0 71.4%, #ef9f3c 0 78.6%, #e2622d 0 85.7%,
    #c02c20 0 92.9%, #8f4fb4 0 100%);
}
.ticks { display: flex; color: var(--haze); font-size: 10px; margin: 0 1px; }
.ticks span { flex: 1 1 0; text-align: left; }
.bands { color: var(--haze); font-size: 12px; margin: 0 2px; }

/* AQI translation chips (surface product) */
.aqirow { display: flex; flex-wrap: wrap; gap: 7px 14px; margin: 0 2px; font-size: 12.5px; }
.aqirow .c { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-weight: 600; }
.aqirow .c i {
  width: 11px; height: 11px; border-radius: 3.5px;
  border: 1px solid rgba(255,255,255,.28); flex: none;
}
.aqirow .c em { font-style: normal; color: var(--haze); font-weight: 500; }
.aqirow .approx { flex-basis: 100%; color: var(--haze); font-size: 11.5px; margin: 3px 0 0; }

/* ---------- home city ---------- */
.homecity { margin-top: 20px; }
.homecity label {
  display: block; margin: 0 2px 7px;
  color: var(--haze); font-size: 11px;
  text-transform: uppercase; letter-spacing: .12em;
}

footer { margin-top: 18px; }
footer p { color: rgba(153, 161, 174, .72); color: color-mix(in srgb, var(--haze) 72%, transparent); font-size: 11.5px; line-height: 1.55; }
footer .hits { margin-top: 6px; font-size: 12px; color: rgba(153, 161, 174, .55); color: color-mix(in srgb, var(--haze) 55%, transparent); }


/* ---------- desktop ---------- */
@media (min-width: 980px) {
  .app {
    max-width: 1100px;
    display: grid; grid-template-columns: minmax(0, 46%) minmax(0, 1fr);
    gap: 28px; align-items: start;
  }
  .col-map { position: sticky; top: 14px; }
  .readout .big { font-size: 40px; }
}


@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation-duration: .01s !important; }
}

/* ---------- place search (nationwide) ---------- */
.searchwrap { position: relative; }
.homecity input {
  width: 100%; min-height: 50px;
  -webkit-appearance: none; appearance: none;
  color: var(--paper); font-size: 16px; font-weight: 650;
  border: 1px solid var(--line); border-radius: 12px;
  padding: 12px 50px 12px 42px;   /* right gap clears the star button */
  background-color: var(--panel);
}
.searchwrap::before {
  content: ''; position: absolute; left: 14px; top: 25px;
  transform: translateY(-50%); width: 17px; height: 17px; z-index: 2;
  pointer-events: none;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23e59043' stroke-width='2'%3E%3Cpath d='M12 21s-7-5.6-7-11a7 7 0 0 1 14 0c0 5.4-7 11-7 11z'/%3E%3Ccircle cx='12' cy='10' r='2.6'/%3E%3C/svg%3E") no-repeat center / contain;
}
.homecity input::placeholder { color: var(--haze); font-weight: 500; }
.suggestions {
  position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 40;
  background: var(--panel2); border: 1px solid var(--line); border-radius: 12px;
  overflow: hidden; max-height: 300px; overflow-y: auto;
  box-shadow: 0 10px 30px rgba(0,0,0,.5);
}
.suggestions button {
  display: block; width: 100%; text-align: left;
  background: none; border: 0; border-radius: 0;
  padding: 12px 14px; min-height: 0; font-size: 15px;
}
.suggestions button:hover { background: var(--panel); }
.suggestions .note { padding: 12px 14px; color: var(--haze); font-size: 13px; }
.suggestions .notelink {
  background: none; border: 0; padding: 0; min-height: 0;
  font: inherit; color: var(--ember); cursor: pointer;
  text-decoration: underline; text-underline-offset: 2px;
}
/* the list you were sent to, briefly lit so it's obvious where you landed */
.favs.flash { animation: favflash 1.5s ease-out; border-radius: 12px; }
@keyframes favflash {
  from { background: rgba(229, 144, 67, .18); }
  to { background: transparent; }
}
.suggestions .locrow {
  display: flex; align-items: center; gap: 9px;
  color: var(--clear); font-weight: 600;
}

/* nationwide polish */
/* html ONLY, and this is load-bearing. overflow-x: hidden forces the other
   axis to compute to auto, because a box cannot be visible on one axis and
   clipped on the other. On body that made BODY a scroll container holding
   the whole page, and body also carried overscroll-behavior: none, so a
   touch scroll was captured by a box with nothing to scroll and then
   forbidden from chaining up to the viewport. Nothing moved.

   That is the bug Derek quit over and Jasper reported months later, and it
   never showed up on Kyle's iPhone because Safari propagates these
   differently. window.scrollTo kept working the whole time, which is why
   every scripted check passed. html's overflow-x propagates to the
   viewport, so horizontal scrolling is still clipped without any of this. */
html { overflow-x: hidden; }

.homecity input:-webkit-autofill,
.homecity input:-webkit-autofill:focus {
  -webkit-box-shadow: 0 0 0 60px var(--panel) inset;
  -webkit-text-fill-color: var(--paper);
  caret-color: var(--paper);
}

/* national map overlays */
.overlaywrap { position: absolute; left: 0; top: 0; pointer-events: none; z-index: 2; }
.overlaywrap svg { width: 100%; height: 100%; display: block; }
.overlaywrap #counties { opacity: 0; transition: opacity .25s; }
.overlaywrap.show-counties #counties { opacity: 1; }
/* zoomed out, the shoreline needs more weight to survive busy smoke */
/* Borders are ONE svg scaled with the map, so a stroke tuned for the
   national view grew with the zoom: at 60x the Oregon/California line was
   a ~20px band burying the terrain under it (Kyle). non-scaling-stroke
   pins every stroke to screen pixels instead, so borders stay the same
   weight at any zoom, the way a real map behaves. Widths below are now
   SCREEN px, not canvas units. */
.overlaywrap svg use, .overlaywrap svg path { vector-effect: non-scaling-stroke; }
.overlaywrap #counties { stroke-width: 0.8px; }
.overlaywrap #states { stroke-width: 1.2px; }
.overlaywrap #countries { stroke-width: 1.6px; }
.overlaywrap #coastInk { stroke-width: 1px; }
/* Under ?pan=transform the map moves by a CSS transform on the #world box,
   and WebKit's non-scaling-stroke undoes only the transforms it finds
   walking up SVG elements, stopping at the first HTML ancestor
   (SVGGraphicsElement::computeCTM). So in that mode the strokes are sized
   by hand instead: each is a screen px width divided by --k, the css px per
   frame px that applyView writes on the stage at bake time. In an SVG a px
   length is a user unit, so 1.2px / k user units renders as 1.2 screen px
   whatever the box size. Between bakes a stroke follows the pinch by up to
   2x and snaps back at the next bake. */
.stage { --k: 1; }
.stage.tpan .overlaywrap svg use, .stage.tpan .overlaywrap svg path { vector-effect: none; }
.stage.tpan .overlaywrap #counties { stroke-width: calc(0.8px / var(--k)); }
.stage.tpan .overlaywrap #states { stroke-width: calc(1.2px / var(--k)); }
.stage.tpan .overlaywrap #countries { stroke-width: calc(1.6px / var(--k)); }
.stage.tpan .overlaywrap #coastInk { stroke-width: calc(1px / var(--k)); }
.stage.tpan .overlaywrap #coastCase { stroke-width: calc(2.8px / var(--k)); }
.stage.tpan .overlaywrap.wide-coast #coastInk { stroke-width: calc(1.2px / var(--k)); }
.stage.tpan .overlaywrap.wide-coast #coastCase { stroke-width: calc(3.2px / var(--k)); }
.stage.tpan .overlaywrap #roadCase { stroke-width: calc(3.4px / var(--k)); }
.stage.tpan .overlaywrap #roadInk { stroke-width: calc(1.4px / var(--k)); }
/* NEUTRAL, and this took two tries. The shoreline started cream, which
   read as a fire perimeter (Kyle, on the lakes near Burns). I made it blue,
   which was worse: blue is what SMOKE is drawn in here, so the Great Salt
   Lake started reading as a plume. Both of the map's two meanings are
   spoken for -- warm is fire, blue is smoke -- so water gets neither. A
   desaturated grey says "geography" and competes with nothing.

   Still no fill. Smoke is forecast over water too, and filling lakes would
   hide real data, which is the rule that removed the old lake overlay. */
.overlaywrap #coastCase { stroke-width: 2.8px; stroke: #b9b6ae; }
/* zoomed right out, a touch bolder so the coastline still reads */
.overlaywrap.wide-coast #coastInk { stroke-width: 1.2px; }
.overlaywrap.wide-coast #coastCase { stroke-width: 3.2px; }
.labels { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 2; }
.labels span {
  position: absolute; transform: translate(-50%, -100%);
  white-space: nowrap; color: #26221c; font-weight: 600;
  font-family: 'InterTight', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  line-height: 1.15;
  text-shadow:
    0 0 1px rgba(255,255,255,.97), 0 0 1px rgba(255,255,255,.97),
    0 0 2.5px rgba(255,255,255,.95),
    0 0 5px rgba(252,250,243,.6);
  display: none;
  z-index: 1;   /* named labels sit above bare dots (see .dotonly) */
}
.labels span::after {
  content: ''; position: absolute; left: 50%; bottom: -4px;
  width: 4px; height: 4px; background: #26221c;
  border: 1px solid #fff; border-radius: 50%; transform: translateX(-50%);
}
.labels .t1 { font-size: 12.5px; font-weight: 700; letter-spacing: .02em; }
.labels .t2 { font-size: 10.5px; font-weight: 600; }
.labels .t3 { font-size: 9px; font-weight: 550; color: #4a4438; }
.labels .t1::after { width: 5px; height: 5px; bottom: -5px; }
.labels .t3::after { width: 3.5px; height: 3.5px; }
.labels span.nudged::after { display: none; }
/* alternate placements — the TEXT moves, the dot stays on the anchor */
.labels span.pr { transform: translate(7px, -50%); }
.labels span.pr::after { left: -7px; bottom: auto; top: 50%; transform: translate(-50%, -50%); }
.labels span.pl { transform: translate(calc(-100% - 7px), -50%); }
.labels span.pl::after { left: auto; right: -7px; bottom: auto; top: 50%; transform: translate(50%, -50%); }
.labels span.pb { transform: translate(-50%, 6px); }
.labels span.pb::after { left: 50%; bottom: auto; top: -6px; transform: translate(-50%, -50%); }
.labels .home {
  position: absolute; transform: translate(-50%, 0); margin-top: 2px;
  font-size: 12.5px; font-weight: 800; color: #8f3a1e; display: none;
  font-family: 'InterTight', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  line-height: 1.15;
  letter-spacing: .02em;
  white-space: nowrap; z-index: 2;   /* featured name tops every label */
  text-shadow:
    0 0 1px rgba(255,255,255,.98), 0 0 2px rgba(255,255,255,.96),
    0 0 3.5px rgba(255,255,255,.92),
    0 0 7px rgba(252,250,243,.65);
}
.labels span.dotonly { color: transparent; text-shadow: none; z-index: 0; }
/* Water, in the hand every paper map has used for a century: italic, and a
   cool slate that is nothing like the town ink beside it. This is the half
   of Kyle's lake problem that recolouring the outline could not solve --
   grey said "not a fire perimeter" but never said what the shape WAS. */
.labels span[data-kind="water"] {
  /* ONE size for every lake, and smaller than a town of any tier. Sizing
     water by lake area put Great Salt Lake above Salt Lake City, which
     inverts what somebody is reading the map for. */
  font-size: 9.5px; font-weight: 600; font-style: italic; letter-spacing: 0;
  color: #4d6472; color: color-mix(in srgb, #2f5d78 78%, var(--paper));
}
.labels span[data-kind="water"]::after { background: #4d6472; opacity: .8; }
.labels span[data-kind="water"].dotonly { color: transparent; }

/* ---------- options row (lives in a drawer) ---------- */
.optrow { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 2px; cursor: pointer; }
.optrow .opttext b { display: block; font-size: 14.5px; font-weight: 650; }
.optrow .opttext small { color: var(--haze); font-size: 12px; }
.switch {
  appearance: none; -webkit-appearance: none; width: 46px; height: 28px;
  border-radius: 999px; background: var(--panel2); border: 1px solid var(--line);
  position: relative; flex: none; margin: 0; cursor: pointer; transition: background .18s;
}
.switch::after {
  content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
  border-radius: 50%; background: var(--haze); transition: transform .18s, background .18s;
}
.switch:checked { background: var(--ember-deep); border-color: var(--ember-deep); }
.switch:checked::after { transform: translateX(18px); background: #fff; }
.labels .home::after { display: none; }

/* ---------- saved places (favorites) ---------- */
.starbtn {
  position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
  width: 40px; height: 40px; min-height: 0; padding: 0;
  display: grid; place-items: center;
  background: none; border: 0; cursor: pointer;
  color: var(--haze); -webkit-tap-highlight-color: transparent;
}
.starbtn:active { transform: translateY(-50%); }   /* cancel global button press-shift */
/* While the input holds text that is not the chosen place, the star is
   still describing the OLD place - it stayed lit for one city under a
   half-typed query for another (reviewer). Dimmed + disabled until the
   text matches a selection again. */
.starbtn:disabled { opacity: .35; }
.starbtn.saved { color: var(--ember); }
.starbtn.saved svg { fill: var(--ember); }
/* stacked favorites: each saved place is a row with its PRESENT-time
   reading in the active tab's language — a glanceable "where can I
   escape to" dashboard (Kyle, iPhone-Weather-style) */
.favs { margin-top: 22px; }
.favshead {
  margin: 0 0 8px; padding-left: 2px;
  /* an h2 now: pin weight and size or the UA's bold 1.5em takes over */
  color: var(--haze); font-size: 12px; font-weight: 400;
  text-transform: uppercase; letter-spacing: .12em;
}
#favList { display: flex; flex-direction: column; gap: 8px; }
.favrow {
  transition: transform .16s ease;   /* neighbors glide during a drag */
  display: grid; grid-template-columns: 1fr auto;
  grid-template-areas: "go x" "rib rib";
  column-gap: 10px; row-gap: 8px; align-items: center; width: 100%;
  min-height: 52px; padding: 11px 12px 12px; text-align: left;
  background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
  cursor: pointer; -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none; -webkit-user-select: none; user-select: none;
}
.favrow.dragging {
  transition: none;                  /* the lifted row tracks the finger raw */
  position: relative; z-index: 2;
  background: #313a49; border-color: #46516a;
  box-shadow: 0 8px 22px rgba(0,0,0,.55);
}
.favrow.active { background: #2b323f; box-shadow: inset 0 0 0 1px #3a4250; }
/* ---- a Favorites row carries its own three days ----
   The forecast for every saved place was already in memory and nowhere on
   screen: the row showed one hour of 84, whichever one it happens to be
   now. So the list could not answer the question it exists for -- where
   is the air bad, and when does it clear -- without visiting each place
   and scrubbing.
   The row is a two-line grid now. Line one is the reading; line two is
   the ribbon, spanning the full inner width so that every ribbon in the
   list starts and ends on the same pixel. That alignment is the whole
   idea: ten rows become one chart you read DOWN, at a fixed hour, rather
   than ten you read across. */
.favrow .favnum {
  grid-area: num;
  font-size: 19px; font-weight: 780; line-height: 1; letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
  min-width: 2.1em;                 /* 9 and 160 keep the names in one column */
}
/* SMOKE OVERHEAD HAS NO NUMBER. A dot in the band's colour stands where
   the AQI stands, cased in white so the palest haze survives the dark
   panel. It keeps the slot's full 2.1em width on purpose: the names must
   not shuffle sideways when you switch tabs. currentColor, so one style
   property drives both the digits and the dot and they cannot disagree. */
.favrow .favnum.dot {
  display: flex; align-items: center; justify-content: center;
}
.favrow .favnum.dot::before {
  content: ''; width: 15px; height: 15px; border-radius: 50%;
  background: currentColor;
  box-shadow: 0 0 0 1.6px rgba(255, 255, 255, .9), 0 1px 4px rgba(0, 0, 0, .55);
}
.favrow .favtext { grid-area: text; min-width: 0; }
.favrow .favname {
  display: block;
  font-size: 14px; font-weight: 650; color: var(--paper);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.favrow .favcat {
  display: block; margin-top: 2px;
  font-size: 9.5px; font-weight: 750; letter-spacing: .11em; text-transform: uppercase;
  color: var(--haze); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Hard stops, not a blend: an hour is an hour, exactly as the timeline
   under the map draws it. Same palette too, so the ribbon, the map, the
   strip and the mark all say the same thing in the same colours. */
.favrow .x { grid-area: x; }
.favrow .favrib {
  grid-area: rib; position: relative;
  height: 11px; border-radius: 3px; overflow: hidden;   /* Kyle, 21 Aug: "just a tad more" */
  background: var(--rib-gap);
}
.favrow .favrib[hidden] { display: none; }
.favrow .favnow {
  position: absolute; top: -1px; bottom: -1px; width: 1.5px;
  background: #fff; box-shadow: 0 0 4px rgba(255, 255, 255, .8);
  transform: translateX(-50%); pointer-events: none;
}
/* The axis the whole list shares. Its inset matches the ribbons exactly:
   1px of row border plus 12px of row padding on each side. A shared axis
   that does not sit on the thing it labels is worse than no axis. */
.favaxis {
  position: relative; height: 12px; margin: 0 13px 7px;
}
.favaxis[hidden] { display: none; }
.favaxis span {
  position: absolute; top: 0; transform: translateX(-50%);
  font-size: 9px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
  color: var(--haze); white-space: nowrap;
}
.favaxis span.n { color: var(--paper); }
/* The row used to be ONE <button> with a role="button" span inside it for
   remove - nested interactives, so keyboard and screen readers could never
   reach the x (reviewer). The box and the drag now live on a plain div,
   and Show / Remove are real sibling buttons inside it. This resets the
   global button chrome so the tag swap is invisible. */
.favrow .favgo {
  /* The ROW is the grid; this is just its first cell. Declared here and
     nowhere else: there was briefly a second `.favrow .favgo` rule above
     this one, and since the two have identical specificity the later
     `display: flex` won and the ribbon collapsed to nothing. One rule
     owns the layout. */
  grid-area: go;
  display: flex; align-items: center; gap: 10px;
  min-width: 0;
  background: none; border: 0; border-radius: 0; padding: 0; min-height: 0;
  color: inherit; font: inherit; text-align: left; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
/* Its sibling .favgo resets the global button chrome; this never did, and
   the global rule sets min-height 44px, padding 10px 14px and a background.
   min-height beats height, so a 22px circle rendered as a 28x44 OVAL with
   the glyph pushed off centre (Kyle). Reset the chrome here too, and make
   the button itself the full 44px thumb target with the visible circle
   drawn inside it, so the target is honest and the shape is round. */
.favrow .x {
  flex: none; display: grid; place-items: center;
  width: 44px; height: 44px; min-height: 0; min-width: 0;
  margin: 0 -8px 0 0;              /* only the right edge overhangs; row height unchanged */
  padding: 0; border: 0; background: none; border-radius: 50%;
  font-size: 17px; line-height: 1;
  color: rgba(153, 161, 174, 0.7);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--haze) 70%, transparent);
}
.favrow .x:hover { color: var(--paper); background: var(--panel2); }

/* ---------- collapsible legend ---------- */
.legend { margin-top: 22px; }
/* same type voice as the HOME CITY OR ZIP label; the disclosure triangle
   (right when closed, down when open) alone signals tappability */
.legendbox summary {
  list-style: none; cursor: pointer;
  display: inline-flex; align-items: center; gap: 8px;
  color: var(--haze); font-size: 11px;
  text-transform: uppercase; letter-spacing: .12em;
  padding: 4px 0 4px 2px; -webkit-tap-highlight-color: transparent;
}
.legendbox summary::-webkit-details-marker { display: none; }
.legendbox summary::after {
  content: ''; width: 0; height: 0;
  border: 4px solid transparent; border-left: 5px solid var(--haze);
  margin-left: -1px;
  transform-origin: 2px 4px;
  transition: transform .15s;
}
.legendbox[open] summary::after { transform: rotate(90deg); }
.legendbody { margin-top: 10px; }
.legend details + details { margin-top: 8px; }
.legendbody .credit {
  margin: 0; font-size: 11.5px; line-height: 1.55;
  color: rgba(153, 161, 174, 0.72);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--haze) 72%, transparent);
}

/* ---------- add to home screen (notification card in the footer) ---------- */
.a2hs {
  position: relative; margin-top: 16px;
  padding: 13px 40px 13px 13px;
  border: 1.5px solid rgba(229, 144, 67, .6);                        /* fallback */
  border: 1.5px solid color-mix(in srgb, var(--ember) 60%, transparent);
  border-radius: 12px;
  background: #2b292b;                                              /* fallback */
  background: color-mix(in srgb, var(--ember) 7%, var(--panel));
}
.a2hs-x {
  position: absolute; top: 6px; right: 6px; width: 32px; height: 32px;
  display: grid; place-items: center; padding: 0;
  background: none; border: 0; cursor: pointer;
  color: var(--haze); font-size: 22px; line-height: 1;
}
.a2hs-x:hover { color: var(--paper); }
.a2hs-row { display: flex; gap: 12px; align-items: flex-start; }
.a2hs-ico { font-size: 22px; line-height: 1.35; flex: none; }
.a2hs .a2hs-pitch {
  margin: 0; font-size: 13px; line-height: 1.55; max-width: 60ch;
  color: rgba(236, 234, 226, 0.88);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--paper) 88%, transparent);
}
.a2hs .a2hs-pitch b { color: var(--paper); }
.a2hs-how {
  margin: 8px 0 0; padding: 2px 0; background: none; border: 0; cursor: pointer;
  font: inherit; font-size: 13px; font-weight: 650; color: var(--ember);
  text-decoration: underline; text-underline-offset: 3px;
}
.a2hs ol {
  margin: 6px 0 4px; padding-left: 20px; max-width: 60ch;
  font-size: 12.5px; line-height: 1.5;
  color: rgba(153, 161, 174, 0.95);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--haze) 95%, transparent);
}
.a2hs ol li { margin: 5px 0; }
.a2hs ol li b { color: var(--paper); font-weight: 600; }
.a2hs ol li small {
  display: block; margin-top: 1px; font-size: 11px;
  color: rgba(153, 161, 174, 0.72);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--haze) 72%, transparent);
}
.a2hs .a2hs-note {
  margin: 6px 0 0; font-size: 11.5px; line-height: 1.5; max-width: 60ch;
  color: rgba(153, 161, 174, 0.72);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--haze) 72%, transparent);
}
.installbtn {
  display: inline-block; margin: 4px 0 2px; padding: 9px 16px;
  background: var(--ember-deep); color: #fff; border: 0; border-radius: 10px;
  font: inherit; font-size: 13.5px; font-weight: 650; cursor: pointer;
}

/* ---------- run line info ---------- */
.runbox summary {
  list-style: none; cursor: pointer;
  display: inline-flex; align-items: center; gap: 6px;
  -webkit-tap-highlight-color: transparent;
}
.runbox summary::-webkit-details-marker { display: none; }
.runbox summary svg { flex: none; opacity: .75; }
.runbox[open] summary svg { opacity: 1; color: var(--clear); }
.runinfo {
  margin: 6px 0 4px; max-width: 60ch;
  color: rgba(153, 161, 174, 0.88);   /* fallback: no color-mix() */
  color: color-mix(in srgb, var(--haze) 88%, transparent);
  font-size: 12px; line-height: 1.55;
}
.runinfo b { color: var(--paper); font-weight: 600; }

/* ---------- report a problem ----------
   Quiet by default. Kyle already has three first-run notes competing for a
   new reader's attention, so this one waits to be looked for: a plain line
   in the footer, opening into the form only when someone actually has
   something to say. */
.report { margin-top: 14px; }
.reportlink {
  background: none; border: 0; padding: 0; min-height: 0;
  font: inherit; font-size: 12px;
  color: rgba(153,161,174,.8); color: color-mix(in srgb, var(--haze) 80%, transparent);
  text-decoration: underline; text-underline-offset: 3px;
  text-decoration-color: rgba(153,161,174,.35);
  cursor: pointer;
}
.reportlink:hover { color: var(--haze); }
.reportform {
  margin-top: 10px; padding: 12px;
  background: var(--panel2); border: 1px solid var(--line); border-radius: var(--radius);
  display: grid; gap: 8px;
}
.reportform[hidden] { display: none; }
.reportlab { font-size: 12px; color: var(--haze); }
.reportlab span { opacity: .7; }
.reportform textarea, .reportform input[type=email] {
  width: 100%; box-sizing: border-box;
  background: var(--panel); color: var(--paper);
  border: 1px solid var(--line); border-radius: 10px;
  padding: 9px 10px; font: inherit; font-size: 15px;   /* 15px+ or iOS zooms the page on focus */
  resize: vertical;
}
.reportform textarea:focus, .reportform input[type=email]:focus {
  outline: none; border-color: var(--ember-deep);
}
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.reportdet summary {
  font-size: 12px; color: var(--haze); cursor: pointer; padding: 2px 0;
}
.reportpre {
  margin: 6px 0 0; padding: 8px; max-height: 190px; overflow: auto;
  background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
  font-family: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace;
  font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
  color: rgba(153,161,174,.9); color: color-mix(in srgb, var(--haze) 90%, transparent);
}
.reportbtns { display: flex; gap: 8px; align-items: center; }
.reportsend {
  background: var(--ember-deep); border-color: var(--ember-deep); color: #fff;
  font-weight: 650; padding: 9px 16px; border-radius: 999px;
}
.reportsend[disabled] { opacity: .55; }
.reportcancel {
  background: none; border: 0; color: var(--haze); font-size: 13px; padding: 9px 6px;
}
.reportnote { margin: 2px 0 0; font-size: 12.5px; }
.reportnote.ok { color: #7ced70; color: color-mix(in srgb, #00e400 65%, var(--paper)); }
.reportnote.bad { color: #ff9a8a; }
.reportver {
  margin-top: 8px; font-size: 10.5px;
  color: rgba(153,161,174,.45); color: color-mix(in srgb, var(--haze) 45%, transparent);
}

/* WCAG-size tap targets without changing a pixel: these controls render
   small on purpose (Kyle's density), so each gets an invisible halo that
   widens the hit box toward 44px instead of widening the control.
   (Reviewer: several targets under 44px, naming map expand + Map layers.) */
.qbtn, .qinfo, .qclose, .qtab, .favrow .x { position: relative; }
.fullbtn::after, .zoombtn::after, .a2hs-x::after, .starbtn::after,
.qbtn::after, .qinfo::after, .qclose::after, .qtab::after, .favrow .x::after {
  content: ''; position: absolute; inset: -8px;
}


/* ---------- saved places on the map ----------
   A dark disc carrying the AQI in its category colour, with the name
   underneath. Kyle chose this over a colour-filled badge because a yellow
   badge sitting on yellow air vanishes, while a dark disc holds against
   any map colour and lets the AQI colour do exactly one job. */
.favpins { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
/* ---------- your favourites on the map ----------
   Kyle, 20 Aug, after living with the chip: "the chip gets away from the
   actual main point of the app, which is to see the smoke forecast over
   time... a couple chips kind of obscure the map in a big way." So the
   mark stopped being a card and became what every other place on this map
   already is: a dot and a name.

   THE DOT is the button, and it is anchored dead on the town. It carries
   the reading's colour, cased white over black so it holds on pale green
   and on deep gold alike, and the colour follows the hour on screen
   rather than the hour it happens to be.

   THE NAME is set exactly like .labels .t1, the map's largest city label:
   fixed 12.5px dark ink with a white halo, no panel behind it, and NOT
   scaled by the zoom. Kyle: "I want the font size of all those favorite
   spots to be as tall as the tallest font size on the map, places like
   Albuquerque and Seattle." It is positioned absolutely off the dot, so
   it can move to the left or underneath without the dot budging, which is
   how the map's own labels have always dodged each other. */
.favpin {
  /* How much of its box the dot fills when it has nothing to say. The
     NAME's offset is derived from this below, so shrinking the dot can
     never again leave the name stranded where the box used to end. */
  --dotscale: .42;
  position: absolute; transform: translate(-50%, -50%);
  width: calc(26px * var(--fp, 1)); height: calc(26px * var(--fp, 1));
  min-height: 0; min-width: 0; margin: 0; padding: 0;
  background: none; border: 0; border-radius: 50%; box-shadow: none;
  cursor: pointer; pointer-events: auto; touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
/* Nothing is drawn behind the mark, so the thumb target is an invisible
   one, centred on the dot. The name is tappable in its own right. */
/* CANCEL THE GLOBAL PRESS-SHIFT. `button:active { transform:
   translateY(1px) }` at the top of this file replaces the transform
   outright, and this mark's transform is the thing that centres it on
   its town. So while a finger was down the mark jumped by half its own
   width and height, about 21 by 24 device pixels on Kyle's phone, which
   is what he had been reporting for four rounds. Nothing could see it:
   it is not a position change, not a rebuild, and CDP's synthetic
   touches do not raise :active, so every test passed. .starbtn already
   carries the same one-line antidote a few hundred lines below. */
.favpin:active { transform: translate(-50%, -50%); }
.favpin::before {
  content: ''; position: absolute;
  left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: 44px; height: 44px; border-radius: 50%;
}
/* A HOLE, NOT A FILL. Kyle's idea, and it is better than what it
   replaced: the circle has no background of its own, so the map shows
   through it and the mark can never disagree with the forecast under it.
   It also cannot flash grey while a frame loads, needs no reading to
   draw, and costs nothing to keep in step with the timeline. The filled
   version had to be told each hour's colour, and Kyle caught it going
   grey mid-scrub when the answer had not arrived yet.
   The double casing is what makes an empty ring readable on any ground:
   white inside, near-black outside, so one of the two always contrasts. */
/* ---- the circle, and the number it now carries ----
   Kyle, 20 Aug, once the monitor blend made the numbers trustworthy near
   an instrument: "the circle for Ashland should show 41... it might be
   nice to have the color of the inside of the circle itself match the
   AQI shade. The actual number might need to be in white with a black
   border."

   Four treatments live here, switched by data-aqi on #favPins, because
   the risk he named is real: a circle filled with the reading's own
   colour is exactly the thing that disappeared into air of that colour
   the last time we tried it, which is why the number used to sit on a
   dark disc. This time the fill is cased white-over-black and the digits
   are cased too, so both survive any ground — but it is a judgement call
   about how it LOOKS, so it gets photographed rather than argued. */
.favpin .disc {
  position: absolute; inset: 0; border-radius: 50%;
  display: grid; place-items: center;
  background: transparent;
  border: 1.5px solid #fff;
  /* the black ring is a shadow, not a border, so it costs no layout and
     the circle's own box stays exactly the size the placement assumes */
  box-shadow: 0 0 0 1.5px rgba(12, 14, 18, .92);
  font-family: 'InterTight', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: calc(12px * var(--fp, 1)); font-weight: 800;
  letter-spacing: -.02em; line-height: 1;
  font-variant-numeric: tabular-nums;
  color: transparent;                      /* each treatment supplies its own */
}
/* An approximate number is one character longer and must not spill. */
.favpin:not(.exact):not(.wide) .disc { font-size: calc(10.5px * var(--fp, 1)); letter-spacing: -.04em; }

/* THREE DIGITS DO NOT FIT A CIRCLE SIZED FOR TWO. Kyle, 20 Aug 2026,
   looking at Near Dufur reading 160: "it looks a little crowded, like it
   doesn't quite fit in the circle." He is right -- at 26px the digits run
   into the ring, and that mark is an estimate, so it was already down at
   10.5px and still tight.
   The obvious answer is to shrink the type again, and it is the wrong
   one: 100+ is Unhealthy and above, the readings a person most needs to
   read at a glance, so the number should not get smaller exactly there.
   The circle becomes a stadium instead. It grows sideways only, keeps
   the ring weight, the casing and the full 12px digits, and the shape
   change itself carries meaning -- a wide mark is a three-figure mark.
   placeFavPins is told the wider width so names still clear it. */
.favpin.wide { width: calc(36px * var(--fp, 1)); border-radius: 999px; }
.favpin.wide .disc { border-radius: 999px; }
.favpin.wide .disc::after { border-radius: 999px; }
/* NOTHING TO SAY YET, so don't reserve a hole the size of a number.
   Two cases share this: Smoke overhead, which has no AQI at all, and the
   first seconds after opening, before the hour's frame has landed. Kyle
   saw the second one as "an empty circle ... it says no reading here, but
   then eventually it does" — a big vacant ring reads as broken, where a
   small dot reads as a place with its answer still coming. */
/* ---- NOTHING TO SAY: a solid dot, not a ring ----
   Two cases share this. Smoke overhead, which has no AQI at all, and the
   first seconds after opening, before the hour's frame has landed.

   Kyle, 21 Aug 2026, looking at Near Dufur with a fire diamond and a hot
   spot underneath it: "could a circle near Dufur show just the smoke
   colors underneath it, rather than the busyness of the red dot and the
   orange diamond ... I think that can look a little busy."

   It was busy, and for a specific reason: on Air quality the circle earns
   its space by holding a number, but with no number it is a bare ring
   with whatever happens to be under it showing through. His first idea
   was to erase the fire markers inside the circle. That hides the one
   fire a person most wants to see, the one at their own saved place. A
   dot has no interior, so there is nothing to clutter, and every fire
   stays on the map.

   Same family as the numbered mark -- dark fill, white casing -- just
   with nothing to say. The casing is given in PRE-SCALE pixels, because
   transform scales a shadow along with everything else: 6px at .42 lands
   as the same 2.5px casing the numbered marks carry. */
.favpin:not(.num) .disc {
  font-size: 0;
  transform: scale(var(--dotscale));
  background: rgba(16, 19, 24, .94);
  border-color: transparent;
  box-shadow: 0 0 0 6px rgba(255, 255, 255, .92), 0 3px 14px rgba(0, 0, 0, .5);
  transition: transform .18s ease-out;
}
.favpin.num .disc { transition: transform .18s ease-out; }
/* PRESSED: the mark answers the finger the instant it lands, exactly as a
   fire does. Kyle, 21 Aug 2026: "tapping on fires or hot spots is a
   little bit quicker ... a little more responsive." Neither was faster.
   A fire hit-tests on pointerdown and lights up before the finger leaves;
   a mark sat silent until the lift, and silence reads as slow. Same 1.18
   the fires grow by (PRESS_GROW), so one map has one idea of "I felt
   that".
   Specificity is deliberate: `.favpin:not(.num) .disc` sets a transform
   of its own with the same weight, and the halo taught me yesterday what
   happens when two equal rules argue about one property. This one is
   heavier and later, so neither can undo it. */
.favpins .favpin.pressed .disc { transform: scale(1.18); }
.favpins .favpin.pressed:not(.num) .disc { transform: scale(.62); }

/* --- A: filled with the AQI colour, white digits cased in black --- */
.favpins[data-aqi="fill"] .favpin.num .disc {
  background: var(--aqi, var(--haze));
  color: #fff;
  text-shadow: 0 1px 0 rgba(8,10,14,.95), 0 -1px 0 rgba(8,10,14,.95),
               1px 0 0 rgba(8,10,14,.95), -1px 0 0 rgba(8,10,14,.95),
               0 0 3px rgba(8,10,14,.85);
}
/* --- B: filled, but the digits pick the contrast that wins --- */
.favpins[data-aqi="auto"] .favpin.num .disc {
  background: var(--aqi, var(--haze));
  color: color-mix(in srgb, var(--aqi, #888) 12%, #101318 88%);
  text-shadow: 0 1px 1px rgba(255,255,255,.45);
}
/* --- C: THE ONE THAT SHIPPED. Kyle, 20 Aug: "use the dark disk, but
   let's have the number just be in white and not the AQI color, just for
   maximum readability. Let's have the border of the disk be a thin ring
   of the AQI color of the location."

   It is the better division of labour. White digits on near-black are
   the same contrast at every reading, so the number never gets harder to
   read exactly when the air gets worse — which is what a coloured
   number does, since the palette runs pale at the good end. The colour
   still says the category, as a ring, where nothing has to be read
   through it. The ring uses the MAP's shade rather than a darkened
   badge, so it matches the air directly under it. --- */
.favpins[data-aqi="dark"] .favpin.num .disc {
  background: rgba(16, 19, 24, .94);
  border: 2.5px solid var(--aqi, var(--haze));
  color: #fff;
  text-shadow: none;
  /* THE LIGHT CASING MOVED OUTSIDE. The AQI ring now occupies the border,
     which is where the white casing used to be, and a near-black disc
     with a coloured edge disappears against the palette's dark reds and
     purples — the readings where being seen matters most. So the white
     ring sits outside the colour, and a soft shadow under it holds the
     whole mark off pale ground. Dark fill, colour, white, shadow: one of
     those four always contrasts with whatever is underneath. */
  box-shadow: 0 0 0 1.5px rgba(255, 255, 255, .92), 0 1px 5px rgba(0, 0, 0, .5);
}
/* --- E: the AQI CATEGORY colour, darkened until white digits sit on it
   cleanly. Kyle expected "dark green" for 41, and our map palette's Good
   band is a PALE green — it carries plume texture, not badge contrast, so
   filling a circle with it produced something that read as white. These
   are the same six categories a person recognises, taken down to a shade
   that can hold text. --- */
.favpins[data-aqi="badge"] .favpin.num .disc {
  background: var(--badge, #2f7d4f);
  border-color: rgba(255,255,255,.92);
  color: #fff;
  text-shadow: 0 1px 2px rgba(0,0,0,.55);
  box-shadow: 0 0 0 1.5px rgba(12,14,18,.92), 0 1px 4px rgba(0,0,0,.4);
}

/* --- D: filled, digits dark and heavy, no outline at all --- */
.favpins[data-aqi="ink"] .favpin.num .disc {
  background: var(--aqi, var(--haze));
  color: #12151b;
  text-shadow: 0 1px 0 rgba(255,255,255,.35);
}
.favpin .nm {
  position: absolute; left: calc(100% + 6px); top: 50%;
  transform: translateY(-50%);
  /* NOT tappable, like every other label on this map. The dot carries a
     44px target of its own; the name is ink. When names could take a
     touch, a bare dot that had been placed beside a neighbour's label
     handed its tap to that neighbour: the lab caught "tapped Klamath
     Falls and landed on Ashland". */
  white-space: nowrap; pointer-events: none;
  font-family: 'InterTight', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 12.5px; font-weight: 700; letter-spacing: .02em; line-height: 1.15;
  color: #26221c;
  text-shadow:
    0 0 1px rgba(255,255,255,.97), 0 0 1px rgba(255,255,255,.97),
    0 0 2.5px rgba(255,255,255,.95),
    0 0 5px rgba(252,250,243,.6);
}
/* The two fallbacks, chosen by placeFavPins when the name has no room on
   the right. The dot does not move for either. */
.favpin.nleft .nm { left: auto; right: calc(100% + 6px); }
.favpin.nbelow .nm {
  left: 50%; right: auto; top: calc(100% + 4px);
  transform: translateX(-50%);
}
/* No room for the name anywhere: the mark keeps its dot and drops the
   label, exactly as the map's own city labels do when they are crowded.
   A saved place is never lost to a neighbour's name this way. */
/* THE NAME SITS BESIDE THE DOT, NOT BESIDE THE BOX. Kyle, 21 Aug 2026,
   on Smoke overhead: "the city or the place is too far away from the
   black dot ... excessive space between the black dot and the city name."
   Exactly so, and it is arithmetic rather than taste. A mark with nothing
   to say shrinks its disc to --dotscale of a 26px box, but the name was
   still offset from the BOX edge, so it cleared about 7.5px of empty box
   before its own 6px gap even began. Pull the name back by whatever the
   dot gave up, and the visible gap is the 6px it was always meant to be.
   Specificity is deliberate: these carry one class more than the
   placement rules above and sit after them, so neither can undo the
   other. */
.favpin:not(.num) .nm {
  left: calc(100% + 6px - 13px * var(--fp, 1) * (1 - var(--dotscale)));
}
.favpin:not(.num).nleft .nm {
  left: auto;
  right: calc(100% + 6px - 13px * var(--fp, 1) * (1 - var(--dotscale)));
}
.favpin:not(.num).nbelow .nm {
  left: 50%; right: auto;
  top: calc(100% + 4px - 13px * var(--fp, 1) * (1 - var(--dotscale)));
}
.favpin.dotonly .nm { display: none; }
/* The place you are looking at, in the same ember the featured city name
   already uses, so "selected" reads the same everywhere on this map. */
.favpin.here .disc {
  box-shadow: 0 0 0 1.5px rgba(12, 14, 18, .92), 0 0 0 3.5px rgba(229, 144, 67, .9);
}
/* THE SELECTED RING OUTRANKS EVERY TREATMENT. Each data-aqi rule sets
   box-shadow for its own casing, and every one of them is more specific
   than the plain .here above, so the ember ring silently vanished the
   moment a treatment was switched on. Specificity here is deliberate,
   not accidental: .num.here inside a [data-aqi] beats them all. */
/* THE SELECTED PLACE PULSES INSTEAD OF TURNING ORANGE. Kyle: "the orange
   outline could be confusing because orange is a smoke color too ... maybe
   it could be kind of a little bit of a pulsing, the same pulsing effect
   and the glow." Orange is exactly the palette's Unhealthy band, so the
   old ring said "selected" in the vocabulary the map uses for danger. A
   white halo says neither, and it borrows the breathing rhythm the blue
   location dot already uses, so the app has one idiom for "this one". */
.favpins[data-aqi] .favpin.num.here .disc {
  box-shadow: 0 0 0 1.5px rgba(255, 255, 255, .92),
              0 0 14px 3px rgba(255, 255, 255, .5), 0 1px 5px rgba(0, 0, 0, .5);
}
/* The AQI ring is a BORDER now, and the selected state used to overwrite
   it with white — which threw away the reading's colour on the one place
   the user is actually looking at. The ember ring lives entirely in the
   shadow, outside it. */
.favpins[data-aqi="dark"] .favpin.num.here .disc {
  border-color: var(--aqi, var(--haze));
  box-shadow: 0 0 0 1.5px rgba(255, 255, 255, .92),
              0 0 14px 3px rgba(255, 255, 255, .5), 0 1px 5px rgba(0, 0, 0, .5);
}
/* The pulse itself: one ring breathing outward, on the same 2s cycle as
   the geolocation dot. ::after, because ::before is the 44px thumb pad. */
/* AND THE SELECTED ONE PULSES WHITE HERE TOO. The ember ring above is
   older than Kyle's "the orange outline could be confusing because orange
   is a smoke color too", and it still reached this case because the white
   override was scoped to `.num` -- so Smoke overhead, the one tab where
   orange is MOST confusable with the data, was the only place still
   wearing it. Specificity is deliberate: `:not(.num).here` is one class
   heavier than the ember rule and sits after it, so neither can undo the
   other. */
.favpin:not(.num).here .disc {
  box-shadow: 0 0 0 6px rgba(255, 255, 255, .92), 0 3px 14px rgba(0, 0, 0, .5);
}
/* The halo is scaled down with the dot, so its ring is given pre-scale
   too or it thins to nothing. */
.favpin:not(.num).here .disc::after { box-shadow: 0 0 0 5px rgba(255, 255, 255, .95); }
.favpin.here .disc::after {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 100%; height: 100%; border-radius: 50%;
  box-shadow: 0 0 0 2px rgba(255, 255, 255, .95);
  pointer-events: none;
  animation: favpulse 2s ease-out infinite;
}
/* A STADIUM RADIATES A STADIUM. Kyle: "from the stadium icons, it appears
   to be an oval that's radiating off of it. I think it'd be better if it
   was actually a stadium shape." It was an oval, and the cause was my own
   cascade: `.favpin.wide .disc::after` sets 999px 134 lines above, the
   rule right here sets 50%, and the two have IDENTICAL specificity
   (0,3,1) -- so source order decided it and the later 50% won, turning a
   36x26 halo into an ellipse. Same class of mistake as two matchers
   disagreeing, in CSS. This one is higher specificity AND later, so it
   cannot be silently undone by either rule. */
.favpin.wide.here .disc::after { border-radius: 999px; }
/* Starts just OUTSIDE the disc, never on it. At scale 1 the white ring
   landed exactly on the border and hid the AQI colour for most of the
   cycle, so the selected place was the one place whose reading you could
   not see. */
@keyframes favpulse {
  0%   { transform: translate(-50%, -50%) scale(1.18); opacity: .9; }
  75%  { transform: translate(-50%, -50%) scale(2.2);  opacity: 0; }
  100% { transform: translate(-50%, -50%) scale(2.2);  opacity: 0; }
}
/* Anyone who has asked the phone to stop moving things still needs to see
   WHICH place is selected, so the ring stays and only the breathing goes. */
@media (prefers-reduced-motion: reduce) {
  .favpin.here .disc::after {
    animation: none;
    transform: translate(-50%, -50%) scale(1.5);
    opacity: .85;
  }
  .favpin .disc { transition: none; }
}
.favpin.here .nm { color: #8f3a1e; font-weight: 800; }   /* same as .labels .home */
