/* ===========================================================================
 * styles.css — the map application (and the token layer the whole site shares).
 *
 * Visual language: cinematic editorial, the same one the home page speaks. A
 * warm paper canvas (not the cool slate this app used to run on), amber as the
 * single interactive accent, teal reserved for verified / "this actually
 * happened" signal, an editorial serif for display type, and tabular mono for
 * every number and micro-label.
 *
 * home.css layers its own .hp-* set on top for the marketing page, but the two
 * sets now resolve to the SAME values, so moving from the home page into the
 * map doesn't cross a visual seam.
 *
 * Values below are LIGHT; the [data-theme="dark"] block overrides them. Nearly
 * every rule in this file already reads these tokens, so the retheme is a
 * palette swap rather than a rewrite — including the newer AI-tinted blocks
 * (predictions, property recommendations), which inherit it for free.
 * ======================================================================== */
:root {
  /* Warm paper neutrals — mirrors home.css --hp-bg / --hp-ink / --hp-line. */
  --ink: #161d2b;                    /* body + heading ink */
  --ink-soft: #4a5466;               /* secondary text (home's ink at 74%) */
  --muted: #626d7e;                  /* labels, captions */
  --paper: #f4f1ec;                  /* app canvas (results panel) — warm paper */
  --bg2: #e9e4db;                    /* banded / recessed surfaces */
  --surface: #ffffff;                /* cards, bars */
  --surface-2: #faf8f4;              /* hover surface, warm off-white */
  --line: rgba(24, 32, 47, 0.12);    /* hairline */
  --line-strong: rgba(24, 32, 47, 0.2);

  /* Amber = the single primary interactive colour (home's --hp-primary).
     Darkened from the raw brand amber so it clears contrast on paper. */
  --accent: #b0651b;
  --accent-2: #94520f;               /* deeper hover */
  --accent-ink: #8a4f13;             /* amber text on a light wash */
  --accent-soft: rgba(215, 138, 53, 0.14); /* home's --hp-primary-wash */
  --on-accent: #ffffff;              /* label on an amber-filled control */

  /* The brighter brand amber — fills and glows that sit against ink. */
  --hot: #d78a35;
  --hot-soft: rgba(215, 138, 53, 0.12);

  /* Teal = verified / actually happened (home's --hp-signal). */
  --good: #0d7a70; --good-bg: rgba(13, 122, 112, 0.1);
  --warn: #b0651b; --warn-bg: rgba(215, 138, 53, 0.14);
  --bad: #c0392b;  --bad-bg: rgba(192, 57, 43, 0.1);

  --focus: rgba(215, 138, 53, 0.22);
  --accent-shadow: rgba(176, 101, 27, 0.3);
  /* Depth lifted from home.css so cards on both pages cast the same shadow. */
  --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 18px 44px -26px rgba(16, 24, 40, 0.4);
  --shadow-lg: 0 32px 80px -36px rgba(16, 24, 40, 0.5);
  --shadow-card: 0 1px 2px -1px rgba(16, 24, 40, 0.08), 0 10px 24px -14px rgba(16, 24, 40, 0.22);
  --shadow-pop: 0 16px 40px -16px rgba(16, 24, 40, 0.24), 0 2px 6px -3px rgba(16, 24, 40, 0.14);

  /* Motion */
  --ease: cubic-bezier(.4, 0, .2, 1);
  --ease-out: cubic-bezier(.2, .8, .3, 1);
  --ease-spring: cubic-bezier(.34, 1.4, .5, 1);

  --sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  /* Fraunces carries the editorial display voice the home page established.
     Archivo Narrow stays in the stack as the fallback so nothing reflows badly
     if the webfont is slow or blocked. */
  --display: "Fraunces", "Archivo Narrow", Georgia, serif;
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  --topbar-h: 56px;
  --filterbar-h: 56px;

  /* Basemap tint (overridden per theme) + the ambient wash. The wash is the
     same warm-amber-top-right / cool-depth recipe as home.css's .hp::before. */
  --tile-filter: sepia(0.1) saturate(0.9) brightness(1.03) contrast(0.97);
  --page-glow:
    radial-gradient(58rem 34rem at 82% -10%, rgba(215, 138, 53, 0.12), transparent 62%),
    radial-gradient(46rem 32rem at 6% 2%, rgba(99, 102, 241, 0.07), transparent 58%),
    radial-gradient(70rem 46rem at 50% 118%, rgba(45, 178, 170, 0.06), transparent 60%);
  --histo-bar: #d9d2c6;
  --pill-bg: #ece7de;
  --attrib-bg: rgba(255, 255, 255, 0.8);
  /* "No data" choropleth fill. Kept in sync with NO_DATA_LIGHT in map.js and
     home.js so the legend swatch matches what's actually drawn on the map. */
  --no-data: #c9d2df;

  /* AI accent = violet → coral, pulled from the analytics palette in the
     reference design. Still unmistakably "not the brand colour" — which is the
     job, since it marks model output rather than recorded fact — but it now
     shares a family with the amber instead of fighting it. */
  --ai-grad: linear-gradient(135deg, #7c5cd6 0%, #b8569a 52%, #e0714f 100%);
  --ai-grad-hover: linear-gradient(135deg, #6b4bc7 0%, #a5468a 52%, #d0603c 100%);
  --ai-border: #9b5bb8;
  --ai-glow: 0 8px 22px -6px rgba(140, 70, 170, 0.45), 0 2px 6px rgba(100, 40, 90, 0.2);
}

/* ---------- Dark theme ----------
   The reference look: a deep slate-navy base (the aerial map at dusk) with the
   amber highlight riding on top. Values match home.css's dark block one for
   one, so the two pages are the same room with the lights down. */
[data-theme="dark"] {
  --ink: #f3f5f8;
  --ink-soft: #b7c0cd;               /* home's ink at 74% on the dark base */
  --muted: #939db0;
  --paper: #12161f;                  /* app canvas */
  --bg2: #0d1017;                    /* recessed / banded surfaces */
  --surface: #1a202c;                /* cards, bars */
  --surface-2: #222a38;              /* hover surface */
  --line: rgba(255, 255, 255, 0.1);
  --line-strong: rgba(255, 255, 255, 0.19);

  /* On the dark base the amber needs no darkening — this is the raw brand hue. */
  --accent: #f0a860;
  --accent-2: #f7bf85;               /* lighter hover, reads as "lifting" */
  --accent-ink: #f0a860;
  --accent-soft: rgba(240, 168, 96, 0.14);
  /* The dark theme's amber is bright, so a white label on it is barely
     readable. Near-black, exactly as home.css does with --hp-primary-ink. */
  --on-accent: #2b1a06;

  --hot: #f0a860;
  --hot-soft: rgba(240, 168, 96, 0.12);

  --good: #4fc9bd; --good-bg: rgba(79, 201, 189, 0.12);
  --warn: #f0a860; --warn-bg: rgba(240, 168, 96, 0.14);
  --bad: #e8705f;  --bad-bg: rgba(232, 112, 95, 0.13);

  --focus: rgba(240, 168, 96, 0.34);
  --accent-shadow: rgba(240, 168, 96, 0.45);
  --shadow: 0 1px 0 rgba(0, 0, 0, 0.3), 0 20px 46px -26px rgba(0, 0, 0, 0.8);
  --shadow-lg: 0 36px 90px -40px rgba(0, 0, 0, 0.9);
  --shadow-card: 0 2px 5px -2px rgba(0, 0, 0, 0.44), 0 12px 28px -16px rgba(0, 0, 0, 0.62);
  --shadow-pop: 0 18px 44px -18px rgba(0, 0, 0, 0.7), 0 3px 8px -4px rgba(0, 0, 0, 0.55);

  /* Nudge the dark basemap slightly warm so it sits under the amber rather
     than reading as a separate blue-grey product. */
  --tile-filter: saturate(0.85) brightness(0.88) contrast(1.06) hue-rotate(-6deg);
  --page-glow:
    radial-gradient(58rem 34rem at 82% -10%, rgba(240, 168, 96, 0.1), transparent 62%),
    radial-gradient(46rem 32rem at 6% 2%, rgba(99, 102, 241, 0.08), transparent 58%),
    radial-gradient(70rem 46rem at 50% 118%, rgba(45, 178, 170, 0.06), transparent 60%);
  --histo-bar: #333c4d;
  --pill-bg: #222a38;
  --attrib-bg: rgba(18, 22, 31, 0.78);
  /* The zero-tract grey. Lightened away from the dark basemap it sits on —
     see the note on NO_DATA_DARK in map.js, which this must match. */
  --no-data: #4a5a75;                /* matches NO_DATA_DARK in map.js */

  /* Brighter violet → coral so the AI layer still separates from the amber. */
  --ai-grad: linear-gradient(135deg, #9d84ec 0%, #d573b4 52%, #f5926b 100%);
  --ai-grad-hover: linear-gradient(135deg, #ae98f2 0%, #e089c4 52%, #ffa580 100%);
  --ai-border: #c08fdc;
  --ai-glow: 0 8px 24px -6px rgba(170, 110, 200, 0.45), 0 2px 6px rgba(0, 0, 0, 0.4);
}

* { box-sizing: border-box; }
[hidden] { display: none !important; }

html, body {
  margin: 0;
  height: 100%;
  font-family: var(--sans);
  color: var(--ink);
  background: var(--paper);
  -webkit-font-smoothing: antialiased;
  background-image: var(--page-glow);
}
/* Smooth cross-fade when switching themes */
body,
.topbar, .filterbar, .results, .results-header, .map, .permit-detail {
  transition: background-color 0.35s var(--ease), color 0.35s var(--ease),
    border-color 0.35s var(--ease);
}
button { font-family: inherit; }

/* ---------- Top bar ---------- */
.topbar {
  height: var(--topbar-h);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 20px;
  background: var(--surface);
  color: var(--ink);
  border-bottom: 1px solid var(--line);
  position: relative;
  z-index: 1200;
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand-mark { display: grid; place-items: center; color: var(--accent); }
.brand-name {
  font-family: var(--display);
  font-size: 20px;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--ink);
}
.brand-name b { font-weight: 700; color: var(--accent); }
/* Two wordmark SVGs, not one: verg-logo.svg is dark ink + amber (for the
   light theme's pale surfaces), verg-logo-white.svg is white ink + amber
   (for the dark theme's near-black bars — --surface #1a202c, --paper
   #12161f). Neither uses currentColor, so a straight colour-inherit or a
   background chip behind one flat asset can't cover both themes cleanly;
   swapping the whole asset can. Every brand spot in the markup renders BOTH
   images back to back (.brand-logo-light + .brand-logo-dark) and this pair
   of rules shows exactly one, keyed off [data-theme] the same way every
   other theme-dependent style on this page is. */
.brand-logo-img { display: block; height: 36px;}
/* On a phone the topbar is the mark plus three controls in 375px, and the mark
   was being clipped mid-word rather than scaling. */
@media (max-width: 600px) {
  .topbar { padding: 0 12px; }
  .brand-logo-img { height: 30px; }
}
.brand-logo-light { display: block; }
.brand-logo-dark { display: none; }
[data-theme="dark"] .brand-logo-light { display: none; }
[data-theme="dark"] .brand-logo-dark { display: block; }

/* ---------- Brand lockup in the app topbar ----------
   The map page's topbar carries the same lockup markup as the home page nav
   (.hp-brand-lockup > .hp-brand + .hp-tagline), but the rules that shape it
   live in home.css, which /map does not load — it pulls only styles.css and
   auth.css. Without them the tagline fell out as a bare <p>: block-level,
   stacked under the wordmark, at the browser's default 1em margin and body
   size. That is the mismatch against the home page.

   These are deliberately scoped under .topbar rather than added as bare
   .hp-* selectors. home.css loads AFTER this file on the home page, so bare
   copies would be shadowed there anyway, and scoping guarantees the two
   headers can never fight: the home nav is styled only by home.css, the app
   topbar only by these. Values mirror home.css's lockup one for one, with
   the app's own tokens (--ink / --line / --muted) standing in for the --hp-*
   pair, so it reads identically in both themes. */
.topbar .hp-brand-lockup {
  display: flex;
  align-items: center;
  gap: 12px;
  /* The topbar is a fixed-height space-between row that will happily crush
     its first item; this stops the tagline wrapping to two lines. */
  min-width: 0;
}
.topbar .hp-brand {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
  color: var(--ink);
}
/* Sized to sit under the account controls rather than compete with them.
   The leading rule holds it on the wordmark's optical centre line, and the
   divider is this element's own left border so it disappears with the
   tagline instead of being left hanging beside the mark. */
.topbar .hp-tagline {
  margin: 0;
  padding-left: 12px;
  border-left: 1px solid var(--line);
  font-size: 13.5px;
  line-height: 1.2;
  color: var(--muted);
  white-space: nowrap;
}
/* Same breakpoint the home nav drops its tagline at. The tagline is the
   first thing to give when the row tightens: the controls beside it are
   functional, this is a restatement. */
@media (max-width: 1080px) {
  .topbar .hp-tagline { display: none; }
}

.account { display: flex; align-items: center; gap: 14px; }
.version-badge {
  font-family: var(--mono);
  text-transform: uppercase;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  color: var(--muted);
  border: 1px dashed var(--line-strong);
  padding: 5px 10px;
  border-radius: 6px;
}
/* Empty until auth.js fills it, so it must not collapse to a 1px sliver and
   then jump — see the note on the markup in index.html. */
.version-badge:empty { min-width: 74px; height: 25px; display: inline-block; }
/* "FULL VERSION" wrapping to two lines in a 56px bar is worse than not being
   there. On a phone the plan is one tap away in the account menu anyway. */
.version-badge { white-space: nowrap; }
@media (max-width: 600px) {
  .version-badge:not(.is-expired):not(.is-trial),
  .account-sep { display: none; }
}

/* THE TWO ACTIONABLE STATES CARRY A COLOUR AND A CURSOR. "Expired" used to sit
   in exactly the same quiet grey as "Full version", so the one state that needs
   the user to do something looked like the one that doesn't — and it was inert
   text, so there was nothing to do about it from here anyway. */
.version-badge.is-expired,
.version-badge.is-trial {
  cursor: pointer;
  border-style: solid;
  transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.version-badge.is-expired {
  color: var(--bad);
  border-color: color-mix(in srgb, var(--bad) 45%, transparent);
  background: var(--bad-bg);
}
.version-badge.is-expired:hover { background: color-mix(in srgb, var(--bad) 18%, transparent); }
.version-badge.is-trial {
  color: var(--accent-ink);
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  background: var(--accent-soft);
}
.version-badge.is-trial:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.version-badge.is-expired:focus-visible,
.version-badge.is-trial:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.account-sep { width: 1px; height: 22px; background: var(--line); }
.profile-btn {
  width: 34px; height: 34px;
  border-radius: 9px;
  border: 1px solid var(--line-strong);
  background: var(--surface-2);
  color: var(--ink-soft);
  font-family: var(--mono);
  font-weight: 600;
  font-size: 12px;
  cursor: pointer;
  transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.profile-btn:hover { background: var(--bg2); border-color: var(--muted); color: var(--ink); }
/* SIGNED OUT, THIS IS A SIGN-IN BUTTON. It used to be the same 34px square
   holding a bare "→" with a hover tooltip — not a recognisable control, and
   tooltips do not exist on touch at all. It is the only way into the product
   from this page for someone whose cookie expired, so it says what it is. */
.profile-btn.is-signin {
  width: auto;
  padding: 0 13px;
  font-family: var(--sans);
  font-size: 13px;
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.profile-btn.is-signin:hover { background: var(--accent-2); border-color: var(--accent-2); color: var(--on-accent); }
/* Empty until auth.js fills it. */
.profile-btn:empty { width: 34px; }

.theme-toggle {
  width: 34px; height: 34px;
  display: grid;
  place-items: center;
  border-radius: 9px;
  border: 1px solid var(--line-strong);
  background: var(--surface-2);
  color: var(--ink-soft);
  cursor: pointer;
  transition: background 0.14s var(--ease), border-color 0.14s var(--ease),
    color 0.14s var(--ease), transform 0.14s var(--ease);
}
.theme-toggle:hover { background: var(--bg2); border-color: var(--muted); color: var(--accent); }
.theme-toggle:active { transform: scale(0.93); }
.theme-toggle svg { display: block; }
.theme-toggle .icon-sun { display: none; }
[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
[data-theme="dark"] .theme-toggle .icon-sun { display: block; }

/* ---------- Filter bar ---------- */
.filterbar {
  height: var(--filterbar-h);
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 16px;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
  position: relative;
  z-index: 1100;
}
/* min-width:0 is what lets a flex item shrink below its content width. Without
   it every control in this bar is frozen at its natural size, the row overflows
   the moment the window narrows, and the controls at the RIGHT end — Save search
   and Saved — are pushed off the edge and clipped with no scrollbar and nothing
   on screen to say they exist. See the .search and .btn-text notes below for the
   other two halves of that fix. */
.filter { position: relative; min-width: 0; }

/* Labels inside the two right-hand buttons, hidden at narrow widths so both
   controls survive as compact icons instead of being pushed off the bar. The
   title / aria-label on each button carries the name when the text is gone.
   NOT overflow-x on .filterbar, which would have been the cheaper fix: every
   dropdown here is an absolutely positioned .panel hanging BELOW the bar, and a
   scroll container clips on both axes — the bar would scroll and every menu in
   it would be cut off at the bar's own bottom edge. */
.btn-text { display: inline; }
@media (max-width: 1180px) {
  .btn-text { display: none; }
  .save-btn { padding: 0 9px; }
  .saved-dropdown .filter-btn { padding: 0 9px; }
}

/* ---------- Trial banner ----------
   Floats over the map rather than sitting in the flow: the app's heights are
   computed as calc(100vh - topbar - filterbar), so an in-flow strip would push
   the map out of the viewport. Fixed positioning keeps that math untouched. */
.trial-banner {
  position: fixed;
  left: 50%;
  bottom: 18px;
  transform: translateX(-50%);
  z-index: 1150;              /* over the map + Leaflet controls, under dropdowns */
  display: flex;
  align-items: center;
  gap: 12px;
  max-width: min(760px, calc(100vw - 32px));
  padding: 10px 12px 10px 14px;
  border: 1px solid var(--line-strong);
  border-radius: 12px;
  background: var(--surface);
  box-shadow: var(--shadow-pop);
}
.trial-banner-icon { display: grid; place-items: center; color: var(--accent); flex: none; }
.trial-banner p {
  margin: 0;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink-soft);
}
.trial-banner p a { color: var(--accent); }
.trial-banner-cta {
  flex: none;
  padding: 7px 13px;
  border-radius: 8px;
  background: var(--accent);
  color: var(--on-accent);
  font-size: 13px;
  font-weight: 600;
  text-decoration: none;
}
.trial-banner-cta:hover { background: var(--accent-2); }
.trial-banner-close {
  flex: none;
  width: 26px;
  height: 26px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: var(--muted);
  font-size: 19px;
  line-height: 1;
  cursor: pointer;
}
.trial-banner-close:hover { background: var(--pill-bg); color: var(--ink); }
@media (max-width: 640px) {
  .trial-banner { flex-wrap: wrap; }
  .trial-banner p { flex: 1 1 100%; order: 3; }
}

/* search */
.search {
  display: flex;
  align-items: center;
  gap: 8px;
  /* GROW TO 280px, BUT GIVE PIXELS BACK WHEN THE BAR IS TIGHT. This was
     `flex: 0 0 280px` — flex-shrink 0 — so the widest control in the bar was
     also the only one that could not yield a single pixel, and everything to its
     right was pushed off the screen instead. */
  flex: 1 1 280px;
  max-width: 280px;
  min-width: 116px;
  height: 34px;
  padding: 0 10px;
  background: var(--surface-2);
  border: 1px solid var(--line-strong);
  border-radius: 7px;
  transition: border-color 0.12s, background 0.12s, box-shadow 0.12s;
}
.search:focus-within {
  background: var(--surface);
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--focus);
}
/* THE MAGNIFIER IS A BUTTON. It was an aria-hidden <svg> — decoration next to
   a box whose Enter key did something the decoration was the natural affordance
   for. On touch there may be no Enter key on screen at all, which made the
   fly-to-address behaviour unreachable as well as unmentioned. */
.search-icon-btn {
  display: grid;
  place-items: center;
  position: relative;
  width: 22px;
  height: 22px;
  flex: 0 0 auto;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  transition: color 0.12s, background 0.12s;
}
.search-icon-btn:hover { color: var(--accent); background: var(--pill-bg); }
.search-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.search-icon { color: inherit; flex: 0 0 auto; display: block; }

/* The in-box spinner. The page-wide progress bar is 2.5px at the very top of
   the window, which is easy to miss — so a lookup that takes a few seconds read
   as a dead search box, and the natural response to a dead search box is to
   retype, which is what generated the requests that deepened the rate limit.
   Feedback belongs where the user is looking. */
.search-spinner {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid var(--line-strong);
  border-top-color: var(--accent);
  opacity: 0;
  pointer-events: none;
  animation: searchSpin 0.7s linear infinite;
}
.search.is-busy .search-icon { opacity: 0; }
.search.is-busy .search-spinner { opacity: 1; }
@keyframes searchSpin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .search-spinner { animation: none; border-top-color: var(--accent); }
}

/* The hint line and the candidate shortlist both hang BELOW the box, which is
   why .search needs to stop clipping them — it is a 34px pill and these are
   outside it. */
.search-hint,
.search-suggest {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  z-index: 1200;
}
.search-hint {
  margin: 0;
  padding: 6px 10px;
  border-radius: 7px;
  background: var(--accent-soft);
  color: var(--accent-ink);
  font-size: 11.5px;
  line-height: 1.35;
}
.search-hint[hidden] { display: none; }

/* The shortlist. The map has ALREADY flown to the best guess by the time this
   renders, so it is a correction rather than a question — doing nothing leaves
   the user exactly where they wanted to be, and the whole list costs no extra
   request because the candidates were already in the reply. */
.search-suggest {
  padding: 6px;
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  background: var(--surface);
  box-shadow: var(--shadow-pop);
  max-height: 260px;
  overflow-y: auto;
}
.search-suggest[hidden] { display: none; }
.sg-head {
  margin: 2px 6px 6px;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--muted);
}
.sg-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  padding: 7px 8px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  font: inherit;
  font-size: 12.5px;
  color: var(--ink-soft);
  text-align: left;
  cursor: pointer;
}
.sg-item:hover { background: var(--surface-2); color: var(--ink); }
.sg-item.is-active { background: var(--accent-soft); color: var(--accent-ink); font-weight: 600; }
.sg-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sg-tag {
  flex: none;
  font-family: var(--mono);
  font-size: 9.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent-ink);
}
.search input {
  flex: 1;
  min-width: 0;
  border: none;
  background: none;
  font-family: var(--sans);
  font-size: 14px;
  outline: none;
  color: var(--ink);
  -webkit-appearance: none;
  appearance: none;
  border-radius: 0;
}
/* Strip Safari's native "search field" chrome (inner shadow + clear glyph). */
.search input::-webkit-search-decoration,
.search input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.search input::placeholder { color: var(--muted); }
.search-clear {
  border: none;
  background: var(--bg2);
  color: var(--muted);
  width: 19px; height: 19px;
  border-radius: 5px;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
}
.search-clear:hover { background: var(--line); }

/* city picker */
.city-filter {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;         /* shrinkable — see the note on .filter */
  height: 34px;
  padding: 0 6px 0 10px;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 7px;
  transition: border-color 0.12s, box-shadow 0.12s;
}
.city-filter:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--focus);
}
.city-pin { color: var(--accent); display: inline-flex; flex: 0 0 auto; }
.city-filter select {
  border: none;
  outline: none;
  min-width: 0;         /* the select is what actually holds the bar wide */
  font-family: var(--sans);
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
  /* Custom caret so Safari/Edge don't render their native select chrome. */
  background: transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888066' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 2px center;
  padding-right: 16px;
}

.filter-btn {
  height: 34px;
  padding: 0 11px;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 7px;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-soft);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  white-space: nowrap;
  transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.filter-btn:hover { border-color: var(--muted); background: var(--surface-2); }
.filter-btn:active { transform: translateY(0.5px); }
.dropdown.open .filter-btn {
  border-color: var(--accent);
  background: var(--accent-soft);
  color: var(--accent-ink);
}
.caret { color: var(--muted); margin-top: 1px; transition: transform 0.2s var(--ease-spring), color 0.12s; }
.dropdown.open .caret { color: var(--accent); transform: rotate(180deg); }

/* ---------- Phone-only filter-bar collapse ----------
   #filterToggle carries .filter-btn as well, so it inherits that button's whole
   look for free; this only decides whether it exists. Off by default and
   switched on in the max-width:600px block, which is also where the rules that
   fold the bar live — the entire feature is one media query, so at desktop
   widths it is a class no rule reads on a button that is not rendered.

   AFTER .filter-btn ON PURPOSE. Both selectors are one class, so between
   `display: inline-flex` there and `display: none` here it is source order that
   decides, and written any earlier in the file this rule simply loses. */
.filter-toggle { display: none; }
.filter-toggle .caret { margin-left: 2px; }
.count-badge {
  background: var(--accent);
  color: var(--on-accent);
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 600;
  border-radius: 4px;
  padding: 1px 6px;
}

.panel {
  position: absolute;
  top: calc(100% + 7px);
  left: 0;
  min-width: 230px;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  box-shadow: var(--shadow-pop);
  padding: 15px;
  display: none;
  flex-direction: column;
  gap: 12px;
  transform-origin: top center;
}
.panel.wide { min-width: 264px; }
/* Panels that open LEFTWARD. A panel is 264-320px wide and anchored to its
   button, so one belonging to a control near the right end of the filter bar
   runs off the window when it hangs to the right. Anchoring the panel's RIGHT
   edge to the button's instead makes it grow back across the bar, where there
   is always room. "savedSearches" is the last control in the bar and its panel
   is the widest of the three, so it was the one hanging off the screen. */
[data-dropdown="company"] .panel,
[data-dropdown="income"] .panel,
[data-dropdown="savedSearches"] .panel { left: auto; right: 0; }
.dropdown.open .panel {
  display: flex;
  animation: panelIn 0.16s var(--ease-out);
}
@keyframes panelIn {
  from { opacity: 0; transform: translateY(-6px) scale(0.985); }
  to   { opacity: 1; transform: translateY(0)    scale(1); }
}
.panel label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 12px;
  font-weight: 500;
  color: var(--muted);
}
.panel select {
  flex: 1;
  height: 34px;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  padding: 0 26px 0 8px;
  font-size: 13px;
  font-family: var(--mono);
  color: var(--ink);
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
  /* Custom caret for consistent rendering across Safari/Edge/Firefox. */
  background: var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888066' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 9px center;
}
.panel-search {
  height: 34px;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  padding: 0 10px;
  font-size: 13px;
  font-family: var(--sans);
  color: var(--ink);
  background: var(--surface);
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.panel-search:focus { border-color: var(--accent); }

/* ---------- Histogram range filter ---------- */
.histo-panel { min-width: 314px; }
.histo { width: 296px; }
.histo-bars {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 66px;
  margin-bottom: 5px;
  padding-bottom: 1px;
  border-bottom: 1px solid var(--line);
}
.histo-bar {
  flex: 1;
  min-height: 2px;
  background: var(--histo-bar);
  transition: background 0.1s;
}
.histo-bar.in-range { background: var(--accent); }

.rs { position: relative; height: 24px; margin: 0 7px 14px; }
.rs-track, .rs-sel {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  height: 3px;
  border-radius: 999px;
}
.rs-track { left: 0; right: 0; background: var(--line-strong); }
.rs-sel { background: var(--accent); }
.rs input[type="range"] {
  position: absolute;
  top: 0; left: 0;
  width: 100%;
  height: 24px;
  margin: 0;
  background: none;
  pointer-events: none;
  -webkit-appearance: none;
  appearance: none;
}
.rs input[type="range"].rs-min { z-index: 3; }
.rs input[type="range"].rs-max { z-index: 2; }
.rs input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  pointer-events: auto;
  width: 15px; height: 15px;
  border-radius: 50%;
  background: var(--surface);
  border: 1.5px solid var(--ink);
  box-shadow: 0 1px 2px rgba(0,0,0,0.2);
  cursor: pointer;
  /* Centre the 15px thumb on the 3px runnable track: (3 - 15) / 2 */
  margin-top: -6px;
  transition: transform 0.1s var(--ease-spring), border-color 0.1s;
}
.rs input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.13); border-color: var(--accent); }
.rs input[type="range"]::-moz-range-thumb {
  pointer-events: auto;
  width: 15px; height: 15px;
  border-radius: 50%;
  background: var(--surface);
  border: 1.5px solid var(--ink);
  cursor: pointer;
}
.rs input[type="range"]::-webkit-slider-runnable-track { height: 3px; background: none; border: none; }
.rs input[type="range"]::-moz-range-track { height: 3px; background: none; border: none; }

.histo-inputs { display: flex; align-items: flex-end; gap: 8px; }
.histo-inputs .hi-field { flex: 1; display: flex; flex-direction: column; gap: 5px; }
.histo-inputs .hi-field span {
  font-family: var(--mono);
  font-size: 10.5px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.1em;
}
.histo-inputs input {
  width: 100%;
  height: 34px;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  padding: 0 9px;
  font-size: 13px;
  font-family: var(--mono);
  outline: none;
}
.histo-inputs input:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--focus);
}
.histo-inputs .hi-dash { color: var(--line-strong); padding-bottom: 8px; }

/* ---------- Period picker ----------
   A dropdown of named windows with a month range underneath, in place of a flat
   <select> holding every month in the database. The presets are the subject of
   the panel and get the full width; the range below them is the escape hatch and
   is deliberately quieter — a rule, a small caps heading, and two compact
   selects. See the note on the control in index.html for why. */
.filter-cal { color: var(--accent); flex: 0 0 auto; }
/* The label can be a long custom range ("November 2025 – March 2026"), and this
   control sits second in a bar that has to fit nine others. */
.period-dropdown .filter-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 15ch;
}
/* Wide enough for the longest preset row — "Last 12 months" beside "September
   2025 – August 2026" — because the alternative is the name wrapping onto two
   lines, which turns a scannable list into a ragged block. */
.period-panel { min-width: 318px; gap: 0; }
.period-presets { display: flex; flex-direction: column; gap: 1px; }
.period-preset {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  padding: 7px 9px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  font: inherit;
  font-size: 13.5px;
  color: var(--ink-soft);
  text-align: left;
  cursor: pointer;
}
/* The preset's NAME never wraps; the months beside it are what gives way if the
   panel is ever narrower than its content. */
.period-preset > span:first-child { white-space: nowrap; }
.period-preset:hover { background: var(--surface-2); color: var(--ink); }
.period-preset.is-active {
  background: var(--accent-soft);
  color: var(--accent-ink);
  font-weight: 600;
}
/* The months a preset resolves to, so "Last 6 months" is checkable rather than
   something the user has to trust. Suppressed on the live option, which has no
   month keys behind it. */
.period-preset-sub {
  font-family: var(--mono);
  font-size: 10.5px;
  color: var(--muted);
  white-space: nowrap;
}
.period-preset.is-active .period-preset-sub { color: var(--accent-ink); }
/* Not offerable — the database holds no months for it. Kept visible rather than
   removed so the list doesn't change shape between metros. */
.period-preset[disabled] { opacity: 0.42; cursor: not-allowed; }
.period-preset[disabled]:hover { background: transparent; color: var(--ink-soft); }

.period-custom {
  margin-top: 12px;
  padding-top: 11px;
  border-top: 1px solid var(--line);
}
.period-custom-head {
  margin: 0 0 8px;
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}
.period-range { display: flex; align-items: flex-end; gap: 8px; }
/* These are <label>s, so `.panel label` above already gave them
   `align-items: center; justify-content: space-between` — which in a column
   flex box centres the caption over the select and spreads the two apart. Both
   are reset here rather than by making the rule above more specific, because
   every other panel in the bar still wants the row behaviour. */
.panel label.period-field {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  gap: 5px;
}
.period-field span {
  font-family: var(--mono);
  font-size: 10.5px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.1em;
}
.period-field select {
  width: 100%;
  height: 34px;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  padding: 0 8px;
  font-family: var(--sans);
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  outline: none;
  cursor: pointer;
}
.period-field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
.period-dash { color: var(--line-strong); padding-bottom: 8px; }

.checklist {
  display: flex;
  flex-direction: column;
  gap: 1px;
  max-height: 238px;
  overflow-y: auto;
}
.checklist label {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  font-size: 13.5px;
  color: var(--ink-soft);
  cursor: pointer;
  padding: 6px 6px;
  border-radius: 6px;
  text-align: left;
}
.checklist label:hover { background: var(--surface-2); }
.checklist input {
  flex: 0 0 auto;
  width: 15px;
  height: 15px;
  margin: 1px 0 0;
  accent-color: var(--accent);
}
.checklist .chk-text { flex: 1 1 auto; text-align: left; line-height: 1.35; }

.save-btn {
  margin-left: auto;
  height: 34px;
  padding: 0 15px;
  background: var(--accent);
  color: var(--on-accent);
  border: 1px solid var(--accent);
  border-radius: 8px;
  font-family: var(--mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  /* Tinted off --accent-shadow rather than a literal blue, so the glow tracks
     the accent through the theme switch instead of staying blue. */
  box-shadow: 0 1px 2px var(--accent-shadow), 0 6px 16px -8px var(--accent-shadow);
  transition: background 0.14s var(--ease), border-color 0.14s var(--ease),
    box-shadow 0.14s var(--ease), transform 0.14s var(--ease);
}
.save-btn:hover {
  background: var(--accent-2);
  border-color: var(--accent-2);
  transform: translateY(-1px);
  box-shadow: 0 2px 4px var(--accent-shadow), 0 12px 22px -10px var(--accent-shadow);
}
.save-btn:active { transform: translateY(0); box-shadow: 0 1px 2px var(--accent-shadow); }
.save-btn svg { opacity: 0.9; }

/* ---------- Saved searches ---------- */
.saved-dropdown { margin-left: 6px; }
.saved-panel { width: 320px; max-height: 360px; overflow-y: auto; }
.saved-search-list { display: flex; flex-direction: column; gap: 2px; }
.saved-search-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 8px;
  border-radius: 7px;
}
.saved-search-row:hover { background: var(--surface-2); }
.saved-search-main {
  flex: 1 1 auto;
  min-width: 0;
  background: none;
  border: none;
  text-align: left;
  cursor: pointer;
  padding: 0;
  color: inherit;
}
.saved-search-name {
  display: block;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.saved-search-meta {
  display: block;
  font-size: 11.5px;
  color: var(--muted);
  font-family: var(--mono);
  margin-top: 2px;
}
/* The two row actions. Identical geometry so they read as one pair of controls
   rather than two arbitrary icons; only the hover colour differs, and it
   differs on purpose — rename is ordinary, delete is the one you cannot undo. */
.saved-search-rename,
.saved-search-delete {
  flex: 0 0 auto;
  width: 26px;
  height: 26px;
  display: grid;
  place-items: center;
  border: none;
  border-radius: 6px;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
.saved-search-rename:hover { background: var(--surface-2); color: var(--ink); }
.saved-search-delete:hover { background: var(--bad-bg); color: var(--bad); }

/* The inline name editor. Sized and weighted exactly like the .saved-search-name
   it replaces, so committing an edit doesn't make the row jump — the text stays
   put and only the box around it goes away. */
.saved-search-input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  font-family: inherit;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink);
  background: var(--surface-2);
  border: 1px solid var(--accent);
  border-radius: 6px;
  padding: 2px 6px;
  /* Cancels the 3px the padding and border add, so the editing row is the same
     height as its neighbours and the list does not shift under the cursor. */
  margin: -3px 0;
}
.saved-search-input:focus { outline: none; box-shadow: 0 0 0 3px var(--accent-shadow); }
/* No hover tint while editing: the row is not a click target at that moment. */
.saved-search-row.is-editing:hover { background: transparent; }
.saved-empty {
  padding: 14px 8px;
  font-size: 13px;
  color: var(--muted);
  text-align: center;
}

/* ---------- Content ---------- */
.content {
  display: flex;
  /* 100vh FIRST, 100dvh SECOND. On iOS and Android 100vh is the EXPANDED
     viewport height — what the window would be if the browser chrome retracted
     — so the last rows of the results panel sat underneath the URL bar, and so
     did the fixed trial banner at the bottom of the map. dvh is the height the
     viewport actually has right now. The pair is the fallback: a browser that
     doesn't know dvh keeps the vh line, and everything current takes the
     second one. */
  height: calc(100vh - var(--topbar-h) - var(--filterbar-h));
  height: calc(100dvh - var(--topbar-h) - var(--filterbar-h));
  position: relative;
}

/* ==========================================================================
   THE APP AT SMALL WIDTHS
   ==========================================================================
   Before this block, styles.css contained exactly three media queries and none
   of them touched the app's layout: .content was a flex ROW with a map that
   grew and a results panel frozen at 396px, at every width, forever.

   What that meant in practice: on a 1024px laptop window the map was crushed to
   about 600px; on a half-screen window it was a sliver; on a phone the results
   panel alone was wider than the viewport, neither panel could shrink, and the
   page scrolled sideways with the results off-screen. A contractor opening a
   shared link on their phone in a truck — a large share of this audience — got
   an unusable app.

   Two breakpoints, because there are two different problems:

     ≤ 900px   Not enough width for two panels side by side. Stack them: map on
               top with a fixed share of the height, list below it. Both are
               fully visible and neither is squeezed.

     ≤ 600px   Not enough height to stack either — a map with 40% of a phone
               screen is a postage stamp. The list becomes a BOTTOM SHEET over
               a full-height map, which is the pattern every mapping app on a
               phone already uses, so it needs no explaining. It rests showing
               its header (the count, and the handle), and slides up when
               tapped.
   ========================================================================== */

/* THE SHEET'S GRAB HANDLE, declared before the media queries that reveal it.
   Base first, override second — the other way round and the base rule wins at
   equal specificity and the handle never appears. */
.sheet-handle {
  display: none;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  padding: 9px 16px 7px;
  border: 0;
  border-radius: 16px 16px 0 0;
  background: var(--surface);
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
  cursor: pointer;
  /* The handle is the sheet's own affordance, so it must not scroll away with
     the list underneath it. */
  flex: 0 0 auto;
}
.sheet-grip {
  display: block;
  width: 36px;
  height: 4px;
  border-radius: 999px;
  background: var(--line-strong);
}
.sheet-handle:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }

@media (max-width: 900px) {
  /* THE APP SHELL STOPS BEING A FIXED-HEIGHT CALCULATION.
     Above this width the filter bar is exactly one 56px row, so
     `calc(100vh - topbar - filterbar)` is exact. Below it the bar has to wrap —
     nine controls do not fit on one line at 600px, and the previous behaviour
     was to overflow the window and clip whatever fell off the right-hand edge.
     A wrapped bar has no fixed height, so the shell becomes a flex column and
     .content simply takes what is left. Nothing has to predict a number. */
  html, body { height: auto; }
  body {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-height: 100dvh;
  }
  .topbar, .filterbar { flex: 0 0 auto; }
  .filterbar {
    flex-wrap: wrap;
    height: auto;
    min-height: var(--filterbar-h);
    align-content: center;
    row-gap: 8px;
    padding: 8px 12px;
  }
  .content {
    flex: 1 1 auto;
    height: auto;
    min-height: 0;
    flex-direction: column;
  }
  /* The progress bar was pinned at calc(topbar + filterbar), which is only the
     bottom of the bar while the bar is one row tall. At the top of the window
     it is right at every height. */
  .load-bar { top: 0; }
  .map {
    /* A fixed share rather than flex:1 — the map is the subject, and a map that
       shrinks as the results list grows is a map that disappears on a busy
       query. */
    flex: 0 0 52%;
    height: auto;
    min-height: 0;
  }
  .results {
    flex: 1 1 auto;
    width: auto;
    height: auto;
    min-height: 0;
    border-left: 0;
    border-top: 1px solid var(--line-strong);
  }
  /* The legend and the on-map card both shrink with the map. */
  .area-card { width: 232px; }
}

@media (max-width: 600px) {
  /* How much of the sheet is visible when it is down: its handle and its
     header, which together say what is in it. */
  :root { --sheet-peek: 92px; }

  /* ---------- The filter bar folds ----------
     Wrapped, this bar was four rows and roughly a third of a phone screen,
     permanently, above a map that is the whole reason for the page. Collapsed
     it is one row: the toggle and the search box.

     `display: none` only ever ADDS a rule, under .is-collapsed. The open state
     asserts nothing, so every control goes back to whatever display its own
     rules give it — no `revert`, and no second copy of those rules here to
     drift out of step with the originals. */
  .filter-toggle { display: inline-flex; }
  .filterbar.is-collapsed > :not(.filter-toggle):not(.search) { display: none; }
  /* Nothing wraps when only two things are left, and the row should not keep
     the taller wrapped bar's padding. */
  .filterbar.is-collapsed {
    flex-wrap: nowrap;
    row-gap: 0;
  }
  /* Open, the caret points the other way — same language as every dropdown in
     this bar, where .dropdown.open .caret does exactly this. */
  .filterbar:not(.is-collapsed) .filter-toggle .caret { transform: rotate(180deg); }
  /* Collapsed, the search box has the row to itself and should use it. The
     280px ceiling exists to stop it crowding eight other controls; there are
     none beside it here. */
  .filterbar.is-collapsed .search { max-width: none; }

  .content { display: block; position: relative; }
  .map {
    position: absolute;
    inset: 0;
    height: 100%;
    flex: none;
  }
  .results {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: min(76%, 560px);
    z-index: 950;              /* over the map, under the scan overlay */
    border-top: 1px solid var(--line-strong);
    border-radius: 16px 16px 0 0;
    box-shadow: 0 -14px 34px -20px rgba(16, 24, 40, 0.55);
    transform: translateY(calc(100% - var(--sheet-peek)));
    transition: transform 0.28s var(--ease-out);
  }
  .content.sheet-open .results { transform: translateY(0); }
  .sheet-handle { display: flex; }

  /* The area card is switched off entirely at this width. That rule lives
     beside .area-card's own definition further down, not here, because it has
     to come AFTER it in source order to win — see the note there. */
  /* The trial banner is fixed to the bottom of the window, which on a phone is
     exactly where the sheet rests. Lift it clear of both the sheet and the
     home indicator. */
  .trial-banner {
    bottom: calc(var(--sheet-peek) + 12px + env(safe-area-inset-bottom, 0px));
    max-width: calc(100vw - 24px);
  }
  /* Leaflet's zoom control would sit under the sheet. */
  .leaflet-bottom.leaflet-left { bottom: var(--sheet-peek); }
}


/* ---------- Loading states ---------- */
/* Indeterminate top progress bar (any in-flight request) */
.load-bar {
  position: fixed;
  left: 0;
  right: 0;
  top: calc(var(--topbar-h) + var(--filterbar-h));
  height: 2.5px;
  z-index: 1300;
  overflow: hidden;
  background: transparent;
  pointer-events: none;
}
.load-bar::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 38%;
  border-radius: 999px;
  background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent);
  animation: loadSlide 1.05s var(--ease) infinite;
}
@keyframes loadSlide {
  0%   { transform: translateX(-110%); }
  100% { transform: translateX(360%); }
}

/* Full boot overlay: hides the blank map/list until the first data arrives */
.app-loading {
  position: absolute;
  inset: 0;
  z-index: 500;
  display: grid;
  place-items: center;
  background: radial-gradient(120% 120% at 30% 20%, var(--surface-2), var(--bg2) 72%);
  transition: opacity 0.45s var(--ease);
}
.app-loading.hide { opacity: 0; pointer-events: none; }

/* ---------- Access gate ----------
   The boot overlay, repurposed when the API answers 401/402. Reusing it means
   the app is already covered at the moment we discover the visitor can't see
   this data — there's no frame where the empty map is exposed. */
.app-loading.is-gate { opacity: 1; pointer-events: auto; }
.app-gate {
  max-width: 420px;
  padding: 8px 24px 24px;
  text-align: center;
  color: var(--ink);
}
.app-gate svg { color: var(--accent); }
.app-gate h2 {
  margin: 14px 0 0;
  font-family: var(--display);
  font-size: 22px;
  font-weight: 600;
}
.app-gate p { margin: 10px 0 0; font-size: 14px; line-height: 1.6; color: var(--ink-soft); }
.app-gate-actions { margin-top: 20px; display: grid; gap: 10px; justify-items: center; }
.app-gate-cta {
  display: inline-block;
  padding: 11px 22px;
  border-radius: 10px;
  background: var(--accent);
  color: var(--on-accent);
  font-size: 14.5px;
  font-weight: 600;
  text-decoration: none;
}
.app-gate-cta:hover { background: var(--accent-2); }
.app-gate-alt { font-size: 13.5px; color: var(--accent-ink); text-decoration: none; }
.app-gate-alt:hover { text-decoration: underline; }
.app-loading-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 15px;
  color: var(--accent);
}
/* The watchdog's message, appended to the boot overlay if it is still up after
   thirty seconds. See the note on bootWatchdog in map.js: a request that never
   settles cannot be caught, so this is the only thing standing between a wedged
   connection and a permanent spinner. `button` rather than `a` because it
   reloads rather than navigating. */
.app-loading-stall {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  margin-top: 6px;
}
.app-loading-stall p { margin: 0; font-size: 13.5px; color: var(--muted); }
.app-loading-stall button { border: 0; cursor: pointer; font-family: inherit; }

.loader-radar { display: grid; place-items: center; }
.loader-radar .radar-sweep {
  transform-origin: 24px 24px;
  animation: radarSpin 1.15s linear infinite;
}
@keyframes radarSpin { to { transform: rotate(360deg); } }
.app-loading-text {
  font-family: var(--mono);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-size: 11.5px;
  font-weight: 600;
  color: var(--muted);
}
.map {
  flex: 1;
  height: 100%;
  background:
    radial-gradient(120% 120% at 30% 20%, var(--surface-2), var(--bg2) 70%);
}

/* ---------- Map legend ----------
   Sequential-ramp key for the choropleth. Sits top-right of the map (clear of
   the zoom control, attribution and AI wand). Title + graduated bar + low/high
   axis follows cartographic best practice (ColorBrewer / Felt legend guidance). */
.map-legend {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 1000;
  width: 168px;
  padding: 11px 13px 10px;
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  border: 1px solid var(--line-strong);
  border-radius: 12px;
  box-shadow: var(--shadow-pop);
  font-family: var(--sans);
  pointer-events: none;
  user-select: none;
}
.ml-title {
  font-family: var(--mono);
  text-transform: uppercase;
  letter-spacing: 0.07em;
  font-size: 11px;
  font-weight: 600;
  color: var(--ink);
  margin-bottom: 8px;
  line-height: 1.2;
}
.ml-bar {
  display: flex;
  height: 10px;
  border-radius: 5px;
  overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.10);
}
.ml-bar > span { flex: 1; }
.ml-axis {
  display: flex;
  justify-content: space-between;
  margin-top: 5px;
  font-size: 10.5px;
  font-weight: 600;
  color: var(--muted);
}
.ml-nodata {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 9px;
  padding-top: 8px;
  border-top: 1px solid var(--line);
  font-size: 10.5px;
  font-weight: 500;
  color: var(--muted);
}
.ml-nd-swatch {
  width: 12px;
  height: 12px;
  border-radius: 3px;
  /* Reads the same token map.js draws with, so the key can't drift from the map. */
  background: var(--no-data);
  box-shadow: inset 0 0 0 1px var(--line-strong);
  flex: 0 0 auto;
}
@media (max-width: 640px) {
  .map-legend { top: 10px; right: 10px; width: 138px; padding: 9px 10px 8px; }
}

.results {
  flex: 0 0 396px;
  height: 100%;
  background: var(--paper);
  border-left: 1px solid var(--line-strong);
  display: flex;
  flex-direction: column;
}
.results-header {
  padding: 13px 16px;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
}
/* Hero metric: the result total is the highest-value number in the panel, so it
   gets the strongest type weight/size to anchor the visual hierarchy. */
.results-count {
  font-family: var(--mono);
  font-weight: 700;
  font-size: 15.5px;
  letter-spacing: -0.2px;
  color: var(--ink);
}
/* Caveat line under the results header. Deliberately quiet — it's an
   explanation for figures that are already on screen, not a warning — but kept
   on the surface background so it reads as part of the header block. */
.scope-note {
  margin: 0;
  padding: 8px 16px 10px;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
  font-size: 12px;
  line-height: 1.45;
  color: var(--muted);
}
.link-btn {
  background: none;
  border: none;
  color: var(--accent);
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
}

/* Shading control: sits above the ranked area list and chooses what the
   choropleth shades by. The History/Predictions switch picks the question; the
   rows beneath it offer only the fields that answer it.

   .pred-control is kept as an alias of .mode-control so the existing
   .ai-active rules below (which style the prediction dropdown) keep applying. */
.mode-control,
.pred-control {
  display: flex;
  flex-direction: column;
  gap: 7px;
  padding: 9px 16px;
  background: var(--surface);
  border-bottom: 1px solid var(--line);
}

/* The switch itself: a two-segment control, sized so both halves are equal
   whatever their labels, with the active half lifted off the track. */
.mode-switch {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 3px;
  padding: 3px;
  margin-bottom: 2px;
  background: var(--bg, rgba(0, 0, 0, 0.04));
  border: 1px solid var(--line);
  border-radius: 9px;
}
.mode-tab {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 6px 8px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: var(--muted);
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.2px;
  cursor: pointer;
  transition: background 0.14s, color 0.14s, box-shadow 0.14s;
}
.mode-tab svg { flex: 0 0 auto; }
.mode-tab:hover:not(:disabled) { color: var(--ink); }
.mode-tab.is-active {
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14);
}
/* The Predictions half carries the AI hue once it is the active one, which is
   the same signal .ai-active gives the dropdown underneath it. */
.mode-control.ai-active #modePredictionsBtn.is-active {
  background: var(--ai-grad, var(--ai-border));
  color: #fff;
}
.mode-control.ai-active #modePredictionsBtn.is-active svg { color: #fff; }
.mode-tab:disabled { opacity: 0.45; cursor: not-allowed; }
.mode-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Permit-type readout in the history panel. Looks like the select next to it
   but opens the filter bar's checklist rather than a menu of its own. */
.types-summary {
  flex: 1 1 auto;
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 7px 11px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: 13px;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.14s;
}
.types-summary:hover { border-color: var(--muted); }
.types-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.types-summary .caret { flex: 0 0 auto; opacity: 0.6; }
.pred-row {
  display: flex;
  align-items: center;
  gap: 10px;
}
.pred-row > label {
  flex: 0 0 auto;
  min-width: 92px;
  white-space: nowrap;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--muted);
}
.pred-select-wrap {
  position: relative;
  flex: 1 1 auto;
}
.pred-select-wrap::after {
  content: '';
  position: absolute;
  right: 11px;
  top: 50%;
  width: 7px;
  height: 7px;
  border-right: 1.6px solid var(--muted);
  border-bottom: 1.6px solid var(--muted);
  transform: translateY(-65%) rotate(45deg);
  pointer-events: none;
}
.pred-select-wrap select {
  width: 100%;
  height: 34px;
  padding: 0 30px 0 11px;
  background: var(--surface-2, var(--paper));
  border: 1px solid var(--line-strong);
  border-radius: 7px;
  font-family: var(--sans);
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
  outline: none;
  appearance: none;
  -webkit-appearance: none;
  text-overflow: ellipsis;
  transition: border-color 0.12s;
}
.pred-select-wrap select:hover { border-color: var(--muted); }
.pred-select-wrap select:focus { border-color: var(--accent); }

/* One-line gloss under the "Shade areas by" picker, saying what the selected
   metric means.

   FULL WIDTH, NOT INDENTED to the dropdown. The panel is a fixed 396px column,
   so indenting past the 92px label gutter would leave a sentence squeezed into
   ~260px and wrapping to four lines — taller and harder to read than the control
   it is explaining. It sits immediately under its own row instead, and the tight
   negative top margin is what binds it there rather than to the row below.

   Deliberately quiet: a footnote to the control above it, not a second control.
   The leading rule marks it as annotation at a glance. */
.shade-hint {
  margin: -3px 0 2px;
  padding: 0 0 0 9px;
  border-left: 2px solid var(--line-strong);
  font-size: 11.5px;
  line-height: 1.42;
  color: var(--muted);
}
/* The prediction side's gloss picks up the AI hue on its rule — the same signal
   the sparkle and the gradient on the dropdown give — so the whole block reads
   as one "this is modelled, not measured" unit. The text itself stays muted;
   colouring body copy would cost more legibility than the signal is worth. */
.mode-control.ai-active .shade-hint-ai { border-left-color: var(--ai-border, var(--line-strong)); }

/* Sparkle marker tucked inside the selector; only shown for an AI layer. */
.pred-spark {
  position: absolute;
  left: 10px;
  top: 50%;
  transform: translateY(-50%);
  width: 15px;
  height: 15px;
  fill: #fff;
  pointer-events: none;
  display: none;
  z-index: 1;
  filter: drop-shadow(0 1px 1px rgba(70, 15, 80, 0.35));
}
/* AI layer active: the metric selector itself takes the purple→pink gradient
   with the sparkle, so the "AI predicted" state is baked into the control —
   not a separate label. The category selector below keeps the paper styling. */
.mode-control.ai-active .pred-spark { display: block; }
.mode-control.ai-active #predSelect {
  background: var(--ai-grad);
  border-color: var(--ai-border);
  color: #fff;
  font-weight: 700;
  padding-left: 32px;
  text-shadow: 0 1px 2px rgba(60, 12, 70, 0.4);
}
.mode-control.ai-active #predSelect:hover { background: var(--ai-grad-hover); border-color: var(--ai-border); }
/* Keep the native dropdown list legible (options shouldn't inherit white-on-gradient). */
.mode-control.ai-active #predSelect option { color: var(--ink); background: var(--surface); text-shadow: none; }
.mode-control.ai-active .pred-row:not(.pred-cat-row) .pred-select-wrap::after {
  border-right-color: #fff;
  border-bottom-color: #fff;
}

/* "See how accurate the model is" — opens the track-record modal. Only shown
   while an AI layer is active (toggled with the .ai-active parent). */
.track-record-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  align-self: flex-start;
  margin-top: 1px;
  padding: 5px 11px 5px 9px;
  border: 1px solid var(--ai-border);
  border-radius: 999px;
  background: transparent;
  color: var(--ink);
  font-family: var(--sans);
  font-size: 11.5px;
  font-weight: 650;
  letter-spacing: 0.2px;
  cursor: pointer;
  transition: background 0.14s var(--ease), color 0.14s, transform 0.1s;
}
.track-record-btn svg { color: var(--ai-border); flex: 0 0 auto; transition: color 0.14s; }
.track-record-btn:hover { background: var(--ai-grad); border-color: transparent; color: #fff; }
.track-record-btn:hover svg { color: #fff; }
.track-record-btn:active { transform: scale(0.97); }
.track-record-btn:focus-visible { outline: 2px solid var(--ai-border); outline-offset: 2px; }

/* ---------- Model track-record modal ---------- */
.track-modal {
  position: fixed;
  inset: 0;
  z-index: 2600;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.track-modal[hidden] { display: none; }
.track-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(9, 14, 28, 0.55);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  animation: track-fade 0.16s var(--ease) both;
}
.track-dialog {
  position: relative;
  width: min(560px, 100%);
  max-height: min(82vh, 720px);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 16px;
  box-shadow: var(--shadow-lg);
  overflow: hidden;
  animation: track-pop 0.2s var(--ease-spring) both;
}
@keyframes track-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes track-pop { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } }

.track-head {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 18px 20px 14px;
  /* Violet → coral wash, matching the retuned --ai-grad. */
  background:
    linear-gradient(135deg, rgba(124, 92, 214, 0.13), rgba(224, 113, 79, 0.1)),
    var(--surface);
  border-bottom: 1px solid var(--line);
}
.track-head-text { flex: 1 1 auto; min-width: 0; }
.track-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.7px;
  text-transform: uppercase;
  color: var(--ai-border);
}
.track-eyebrow svg { fill: var(--ai-border); }
.track-title {
  margin: 5px 0 0;
  font-family: var(--display);
  font-size: 22px;
  font-weight: 700;
  line-height: 1.12;
  color: var(--ink);
  letter-spacing: 0.2px;
}
.track-subtitle {
  margin: 5px 0 0;
  font-size: 12.5px;
  line-height: 1.4;
  color: var(--ink-soft);
}
.track-close {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border: none;
  border-radius: 8px;
  background: var(--surface-2);
  color: var(--muted);
  font-size: 21px;
  line-height: 1;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
.track-close:hover { background: var(--bad-bg); color: var(--bad); }

/* ---------- Feedback / bug report modal ---------- */
.feedback-dialog { max-width: 480px; }
.feedback-form { display: flex; flex-direction: column; gap: 14px; padding: 4px 22px 22px; }
.feedback-type-row { display: flex; gap: 10px; }
.feedback-type-opt {
  flex: 1 1 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  height: 36px;
  border: 1px solid var(--line);
  border-radius: 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink-soft);
  cursor: pointer;
  transition: border-color 0.12s var(--ease), color 0.12s var(--ease), background 0.12s var(--ease);
}
.feedback-type-opt input { position: absolute; opacity: 0; pointer-events: none; }
.feedback-type-opt:has(input:checked) {
  border-color: var(--accent);
  color: var(--accent-ink);
  background: var(--accent-soft);
}
.feedback-field { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--ink-soft); }
.feedback-field i { font-style: normal; color: var(--muted); font-weight: 400; }
.feedback-field textarea,
.feedback-field input {
  font-family: inherit;
  font-size: 14px;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 9px 11px;
  resize: vertical;
}
.feedback-field textarea:focus,
.feedback-field input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
.feedback-msg {
  font-size: 13px;
  border-radius: 8px;
  padding: 9px 11px;
}
.feedback-msg.ok { background: var(--good-bg); color: var(--good); }
.feedback-msg.error { background: var(--bad-bg); color: var(--bad); }
.feedback-actions { display: flex; justify-content: flex-end; }
.feedback-submit { margin-left: 0; }
.feedback-submit:disabled { opacity: 0.6; cursor: default; transform: none; }

.track-filter {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 20px;
  border-bottom: 1px solid var(--line);
}
.track-filter label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--muted);
}
.track-filter select {
  flex: 1 1 auto;
  height: 32px;
  padding: 0 10px;
  background: var(--surface-2, var(--paper));
  border: 1px solid var(--line-strong);
  border-radius: 7px;
  font-family: var(--sans);
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
}

.track-stats {
  display: flex;
  gap: 8px;
  padding: 14px 20px 4px;
  flex-wrap: wrap;
}
.track-stat {
  flex: 1 1 0;
  min-width: 96px;
  padding: 10px 12px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 10px;
}
.track-stat-num {
  font-family: var(--mono);
  font-size: 21px;
  font-weight: 700;
  line-height: 1;
  color: var(--ink);
}
.track-stat-num.grad {
  background: var(--ai-grad);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}
.track-stat-label {
  margin-top: 4px;
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.3px;
  text-transform: uppercase;
  color: var(--muted);
}

.track-list {
  padding: 12px 20px 20px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 9px;
}
.track-card {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: 12px;
  transition: border-color 0.12s, transform 0.1s;
}
.track-card:hover { border-color: var(--ai-border); transform: translateY(-1px); }
.track-rank {
  flex: 0 0 auto;
  width: 42px;
  height: 42px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  border-radius: 10px;
  background: var(--ai-grad);
  color: #fff;
  box-shadow: var(--ai-glow);
}
.track-rank-num { font-family: var(--mono); font-size: 18px; font-weight: 700; line-height: 1; }
.track-rank-label { font-size: 8px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; opacity: 0.9; }
.track-card-body { flex: 1 1 auto; min-width: 0; }
.track-card-top {
  display: flex;
  align-items: center;
  gap: 7px;
  flex-wrap: wrap;
}
.track-card-cat {
  font-size: 13.5px;
  font-weight: 700;
  color: var(--ink);
}
.track-hit {
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: 999px;
}
.track-hit.both { background: var(--ai-grad); color: #fff; }
.track-hit.rank { background: var(--accent-soft); color: var(--accent-ink); }
.track-hit.value { background: var(--good-bg); color: var(--good); }
.track-card-detail {
  margin-top: 4px;
  font-size: 12px;
  line-height: 1.45;
  color: var(--ink-soft);
}
.track-card-detail b { color: var(--ink); font-weight: 700; }
.track-accuracy {
  flex: 0 0 auto;
  text-align: right;
}
.track-accuracy-num {
  font-family: var(--mono);
  font-size: 19px;
  font-weight: 700;
  line-height: 1;
  color: var(--good);
}
.track-accuracy-label {
  margin-top: 3px;
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.3px;
  text-transform: uppercase;
  color: var(--muted);
}
.track-empty {
  padding: 40px 20px;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}

.permit-list {
  list-style: none;
  margin: 0;
  padding: 8px;
  overflow-y: auto;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.permit-card {
  position: relative;
  padding: 11px 13px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 8px;
  cursor: pointer;
  transition: border-color 0.16s var(--ease), background 0.16s var(--ease),
    box-shadow 0.16s var(--ease), transform 0.16s var(--ease);
}
.permit-card:hover {
  border-color: var(--line-strong);
  background: var(--surface-2);
  transform: translateY(-1px);
  box-shadow: var(--shadow-card);
}
.permit-card:active { transform: translateY(0); }
.permit-card.active {
  border-color: var(--accent);
  box-shadow: inset 3px 0 0 var(--accent), var(--shadow-card);
}
.permit-card .pc-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.permit-card .pc-value {
  font-family: var(--mono);
  font-weight: 600;
  font-size: 15px;
  letter-spacing: -0.2px;
}
.type-tag {
  font-family: var(--mono);
  font-size: 9.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-soft);
  background: var(--surface-2);
  border: 1px solid var(--line-strong);
  border-radius: 5px;
  padding: 3px 7px;
}
.permit-card .pc-address { margin: 6px 0 4px; font-size: 13.5px; font-weight: 600; color: var(--ink); }
.permit-card .pc-meta {
  font-size: 12px;
  color: var(--muted);
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
}

.status-pill {
  display: inline-block;
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  padding: 2px 7px;
  border-radius: 4px;
  background: var(--pill-bg);
  color: var(--muted);
}
.status-pill.s-issued, .status-pill.s-approved { background: var(--good-bg); color: var(--good); }
.status-pill.s-under-review { background: var(--warn-bg); color: var(--warn); }
.status-pill.s-finaled { background: var(--accent-soft); color: var(--accent-ink); }
.status-pill.s-expired { background: var(--bad-bg); color: var(--bad); }

.permit-card .pc-meta .permit-num {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 11px;
  color: var(--muted);
}

.empty-note {
  padding: 10px 4px;
  font-size: 12.5px;
  color: var(--muted);
}

/* ---------- Tract (area) ranking cards ---------- */
.tract-card {
  position: relative;
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 10px 13px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 8px;
  cursor: pointer;
  transition: border-color 0.16s var(--ease), background 0.16s var(--ease),
    box-shadow 0.16s var(--ease), transform 0.16s var(--ease);
}
.tract-card:hover {
  border-color: var(--line-strong);
  background: var(--surface-2);
  transform: translateY(-1px);
  box-shadow: var(--shadow-card);
}
.tract-card:active { transform: translateY(0); }
.tract-card .tc-rank {
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 600;
  color: var(--accent-ink);
  background: var(--accent-soft);
  border-radius: 50%;
  transition: transform 0.16s var(--ease-spring);
}
.tract-card:hover .tc-rank { transform: scale(1.08); }
/* Medal accents for the top three ranked areas.
   .is-ranked IS THE POINT OF THESE SELECTORS. Two different lists render into
   .permit-list as .tract-card rows: the tract list, which is sorted by the
   selected metric, and the zoomed-out list of every covered city, which is
   alphabetical. Keyed on nth-child alone these rules crowned the first three
   cities in the ALPHABET gold, silver and bronze — a podium awarded for a
   name. map.js adds .is-ranked only to the list where a position means
   something. */
.permit-list.is-ranked .tract-card:nth-child(1) .tc-rank {
  color: #7a5a12; background: linear-gradient(145deg, #fbe7a6, #e9c04e);
  box-shadow: inset 0 0 0 1px rgba(122,90,18,.25);
}
.permit-list.is-ranked .tract-card:nth-child(2) .tc-rank {
  color: #5c5c63; background: linear-gradient(145deg, #eef0f2, #c7ccd2);
  box-shadow: inset 0 0 0 1px rgba(92,92,99,.22);
}
.permit-list.is-ranked .tract-card:nth-child(3) .tc-rank {
  color: #7a4626; background: linear-gradient(145deg, #f0cba8, #d69a6b);
  box-shadow: inset 0 0 0 1px rgba(122,70,38,.24);
}
.tract-card .tc-body { flex: 1; min-width: 0; }
.tract-card .tc-name {
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tract-card .tc-geoid {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--muted);
  margin-top: 2px;
}
.tract-card .tc-metric { flex: 0 0 auto; text-align: right; }
.tract-card .tc-metric b {
  font-family: var(--mono);
  font-size: 15px;
  font-weight: 600;
  color: var(--ink);
  letter-spacing: -0.2px;
}
.tract-card .tc-metric span {
  display: block;
  font-family: var(--mono);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--muted);
}

/* ---------- Choropleth hover tooltip ---------- */
.tract-tooltip {
  font-family: var(--sans);
  font-size: 12px;
  line-height: 1.4;
  padding: 6px 9px;
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  box-shadow: var(--shadow);
}
.tract-tooltip .tt-geoid {
  font-family: var(--mono);
  font-size: 10px;
  color: var(--muted);
}

/* Zoomed-out overview prompt (top-right Leaflet control). */
.overview-hint {
  font-family: var(--sans);
  font-size: 12px;
  font-weight: 600;
  padding: 7px 12px;
  margin: 14px !important;
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--ai-border, #a333d6);
  border-radius: 999px;
  box-shadow: var(--shadow);
  pointer-events: none;
}

/* ---------- Detail ---------- */
.permit-detail { flex: 1; padding: 12px; overflow-y: auto; }
.detail-card {
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  padding: 18px;
}
.permit-detail h2 { margin: 0 0 6px; font-family: var(--display); font-size: 21px; font-weight: 700; letter-spacing: 0.005em; line-height: 1.15; }
.permit-detail .detail-sub { font-size: 12px; color: var(--muted); margin-bottom: 16px; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.permit-detail .detail-value {
  font-family: var(--mono);
  font-size: 24px;
  font-weight: 600;
  color: var(--ink);
  letter-spacing: -0.5px;
  padding-bottom: 14px;
  margin-bottom: 14px;
  border-bottom: 1px solid var(--line);
}
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px 16px; }
.detail-grid .dg-label { font-family: var(--mono); font-size: 10.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 3px; }
.detail-grid .dg-value { font-size: 14px; font-weight: 600; color: var(--ink-soft); }
.detail-grid .dg-value.mono { font-family: var(--mono); font-weight: 600; }
.detail-desc {
  margin-top: 18px;
  padding-top: 15px;
  border-top: 1px solid var(--line);
  font-size: 13.5px;
  color: var(--ink-soft);
  line-height: 1.6;
}

.empty {
  padding: 40px 16px;
  text-align: center;
  color: var(--muted);
  font-size: 13.5px;
}

/* ---------- Price pin markers ---------- */
.price-pin { background: none; border: none; }
.price-pin .pin-inner {
  position: absolute;
  left: 0; top: 0;
  transform: translate(-50%, calc(-100% - 6px));
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  font-family: var(--mono);
  font-size: 11.5px;
  font-weight: 600;
  padding: 3px 7px;
  border-radius: 5px;
  white-space: nowrap;
  box-shadow: 0 2px 5px rgba(20, 23, 26, 0.22);
  cursor: pointer;
  transition: transform 0.1s, background 0.1s, color 0.1s, border-color 0.1s;
}
.price-pin .pin-inner::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -5px;
  transform: translateX(-50%);
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-top: 5px solid var(--line-strong);
}
.price-pin.hover .pin-inner,
.price-pin .pin-inner:hover {
  border-color: var(--accent);
  color: var(--accent-ink);
}
.price-pin.active { z-index: 1000 !important; }
.price-pin.active .pin-inner {
  background: var(--ink);
  /* Not #fff: --ink is near-white in the dark theme, which made the active
     pin's price disappear. --paper is the canvas, so it inverts correctly. */
  color: var(--paper);
  border-color: var(--ink);
  transform: translate(-50%, calc(-100% - 8px)) scale(1.06);
}
.price-pin.active .pin-inner::after { border-top-color: var(--ink); }

/* Basemap tuned toward the paper palette. The filter is scoped to the tile
   pane only, so choropleth fills and price pins keep their true colors. */
.leaflet-tile-pane {
  filter: var(--tile-filter);
}

/* Leaflet controls to match */
.leaflet-bar {
  border: 1px solid var(--line) !important;
  border-radius: 8px !important;
  box-shadow: var(--shadow) !important;
  overflow: hidden;
}
.leaflet-bar a {
  color: var(--ink-soft);
  background: var(--surface);
  border-color: var(--line);
}
.leaflet-bar a:hover { background: var(--surface-2); color: var(--ink); }
.leaflet-control-attribution {
  background: var(--attrib-bg) !important;
  color: var(--muted) !important;
  font-family: var(--sans);
  font-size: 9.5px;
  padding: 1px 7px;
  border-radius: 6px 0 0 0;
  border: 1px solid var(--line);
  border-right: none;
  border-bottom: none;
  box-shadow: none;
}
.leaflet-control-attribution a { color: var(--accent) !important; }

/* Zoom control: give it a little breathing room + spring on press */
.leaflet-control-zoom { margin: 14px !important; }
.leaflet-bar a {
  transition: background 0.12s var(--ease), color 0.12s var(--ease);
}
.leaflet-bar a:active { background: var(--accent-soft); color: var(--accent-ink); }

/* ---------- Refined scrollbars (paper palette) ---------- */
.permit-list,
.checklist,
.permit-detail {
  scrollbar-width: thin;
  scrollbar-color: var(--line-strong) transparent;
}
.permit-list::-webkit-scrollbar,
.checklist::-webkit-scrollbar,
.permit-detail::-webkit-scrollbar { width: 10px; }
.permit-list::-webkit-scrollbar-thumb,
.checklist::-webkit-scrollbar-thumb,
.permit-detail::-webkit-scrollbar-thumb {
  background: var(--line-strong);
  border-radius: 999px;
  border: 3px solid transparent;
  background-clip: padding-box;
}
.permit-list::-webkit-scrollbar-thumb:hover,
.checklist::-webkit-scrollbar-thumb:hover,
.permit-detail::-webkit-scrollbar-thumb:hover {
  background: var(--muted);
  background-clip: padding-box;
}
.permit-list::-webkit-scrollbar-track,
.checklist::-webkit-scrollbar-track,
.permit-detail::-webkit-scrollbar-track { background: transparent; }

/* Newly rendered result cards fade + rise in, gently staggered */
@keyframes cardIn {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
.permit-list > .permit-card,
.permit-list > .tract-card {
  animation: cardIn 0.28s var(--ease-out) both;
}
.permit-list > *:nth-child(1) { animation-delay: 0ms; }
.permit-list > *:nth-child(2) { animation-delay: 22ms; }
.permit-list > *:nth-child(3) { animation-delay: 44ms; }
.permit-list > *:nth-child(4) { animation-delay: 66ms; }
.permit-list > *:nth-child(5) { animation-delay: 88ms; }
.permit-list > *:nth-child(6) { animation-delay: 110ms; }
.permit-list > *:nth-child(7) { animation-delay: 132ms; }
.permit-list > *:nth-child(8) { animation-delay: 154ms; }
.permit-list > *:nth-child(n+9) { animation-delay: 170ms; }

/* ---------- AI assistant (floating launcher + chat) ---------- */
.ai-assistant {
  position: fixed;
  left: 18px;
  bottom: 18px;
  z-index: 1500;
}

/* Wand-in-a-circle launcher, purple→pink to match the AI prediction selector. */
.ai-launcher {
  width: 54px;
  height: 54px;
  border: none;
  border-radius: 50%;
  background: var(--ai-grad);
  color: #fff;
  display: grid;
  place-items: center;
  cursor: pointer;
  box-shadow: var(--ai-glow);
  transition: transform 0.16s var(--ease-spring), box-shadow 0.16s, background 0.16s;
}
.ai-launcher:hover { transform: translateY(-2px) scale(1.05); background: var(--ai-grad-hover); }
.ai-launcher:active { transform: scale(0.95); }
.ai-launcher:focus-visible { outline: 3px solid var(--ai-border); outline-offset: 2px; }
.ai-wand { filter: drop-shadow(0 1px 1px rgba(60, 12, 70, 0.4)); }

/* Chat window that the launcher opens into (anchored to the same corner). */
.ai-chat {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 340px;
  max-width: calc(100vw - 36px);
  height: 460px;
  max-height: calc(100vh - var(--topbar-h) - var(--filterbar-h) - 40px);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line-strong);
  border-radius: 16px;
  box-shadow: var(--shadow-lg);
  overflow: hidden;
  transform-origin: bottom left;
  animation: aiChatIn 0.18s var(--ease-out);
}
@keyframes aiChatIn {
  from { opacity: 0; transform: translateY(12px) scale(0.97); }
  to   { opacity: 1; transform: translateY(0)    scale(1); }
}
.ai-chat-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 12px 14px;
  background: var(--ai-grad);
  color: #fff;
}
.ai-chat-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-family: var(--display);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: 0.2px;
}
.ai-chat-title svg { width: 16px; height: 16px; fill: #fff; flex: 0 0 auto; }
.ai-chat-close {
  flex: 0 0 auto;
  width: 26px;
  height: 26px;
  border: none;
  border-radius: 7px;
  background: rgba(255, 255, 255, 0.22);
  color: #fff;
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  transition: background 0.12s;
}
.ai-chat-close:hover { background: rgba(255, 255, 255, 0.36); }
.ai-chat-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  background: var(--surface-2);
}
.ai-msg {
  font-size: 13px;
  line-height: 1.5;
  padding: 9px 12px;
  border-radius: 13px;
  max-width: 85%;
}
.ai-msg-bot {
  align-self: flex-start;
  background: var(--surface);
  border: 1px solid var(--line);
  color: var(--ink);
  border-bottom-left-radius: 4px;
}
.ai-soon {
  align-self: center;
  margin-top: 2px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--muted);
}
.ai-chat-input {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px;
  border-top: 1px solid var(--line);
  background: var(--surface);
}
.ai-chat-input input {
  flex: 1;
  min-width: 0;
  height: 38px;
  padding: 0 12px;
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  background: var(--surface-2);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 13px;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.ai-chat-input input:disabled { opacity: 0.7; cursor: not-allowed; }
.ai-send {
  flex: 0 0 auto;
  width: 38px;
  height: 38px;
  border: none;
  border-radius: 10px;
  background: var(--ai-grad);
  color: #fff;
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: background 0.14s;
}
.ai-send:hover:not(:disabled) { background: var(--ai-grad-hover); }
.ai-send:disabled { opacity: 0.5; cursor: not-allowed; }

/* Respect users who prefer less motion */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-delay: 0ms !important;
    transition-duration: 0.001ms !important;
  }
}

/* ---------- Property recommendations ----------
   The tract drill-down for an AI prediction metric. It reuses the results
   panel's card language so it reads as the same product, but everything that
   identifies a lead — the pin, the rank badge, the reason line — is tinted with
   the AI gradient rather than the blue accent. That is the point: a permit is a
   fact and a suggestion is a model output, and a contractor deciding whose door
   to knock on should never have to work out which one they are looking at. */
.rec-panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-height: 0;
}

/* Determinate progress, unlike the indeterminate #loadBar at the top of the
   page. Computing a tract means paging every parcel out of a county assessor
   server, which takes tens of seconds — long enough that a bar with no end in
   sight reads as a hang rather than as work. The server sends the row count up
   front so this one can actually move. */
.rec-progress {
  padding: 12px 13px 13px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 8px;
}
.rec-progress-track {
  height: 5px;
  border-radius: 999px;
  background: var(--line);
  overflow: hidden;
}
.rec-progress-fill {
  height: 100%;
  width: 0%;
  border-radius: 999px;
  background: var(--ai-grad);
  transition: width 0.3s var(--ease);
}
/* The early phases — reading permits, asking the county for a count — have no
   denominator to show. A short bar that slides beats one pinned at 0%, which
   looks stuck at exactly the moment the user is deciding whether to wait. */
.rec-progress-fill.is-indeterminate {
  animation: recSlide 1.1s var(--ease) infinite;
}
@keyframes recSlide {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(900%); }
}
.rec-progress-label {
  margin-top: 8px;
  font-size: 12px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* One line of provenance above the list: computed now or read back, how many
   properties were compared, and — when it applies — that most permits matched
   their parcel by proximity rather than exactly. */
.rec-note {
  margin: 0;
  font-size: 11.5px;
  color: var(--muted);
}

.rec-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  overflow-y: auto;
  min-height: 0;
}
.rec-card {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 11px;
  padding: 10px 13px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 8px;
  cursor: pointer;
  transition: border-color 0.16s var(--ease), background 0.16s var(--ease),
    box-shadow 0.16s var(--ease), transform 0.16s var(--ease);
}
.rec-card:hover {
  border-color: var(--ai-border);
  background: var(--surface-2);
  transform: translateY(-1px);
  box-shadow: var(--shadow-card);
}
.rec-card:active { transform: translateY(0); }
.rec-card.active { border-color: var(--ai-border); box-shadow: var(--ai-glow); }
.rec-card .rc-rank {
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 999px;
  background: var(--ai-grad);
  color: #fff;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 700;
}
.rec-card .rc-body { min-width: 0; flex: 1 1 auto; }
.rec-card .rc-address {
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  overflow-wrap: anywhere;
}
/* The reason line is the product. It is why this address rather than the one
   next door, and it is what a contractor repeats to themselves on the drive. */
.rec-card .rc-reasons {
  margin-top: 3px;
  font-size: 11.5px;
  line-height: 1.45;
  color: var(--ink-soft);
}
.rec-card .rc-meta { margin-top: 5px; }
.rec-card .rc-cat {
  display: inline-block;
  padding: 1px 7px;
  border-radius: 999px;
  border: 1px solid var(--line-strong);
  font-size: 10.5px;
  letter-spacing: 0.02em;
  color: var(--muted);
}

/* Empty states are specific rather than a shared shrug: "no parcel source for
   this state", "no recent permits here" and "the county server didn't answer"
   call for three different next moves from the user. */
.rec-empty {
  padding: 16px 14px;
  background: var(--surface);
  border: 1px dashed var(--line-strong);
  border-radius: 8px;
}
.rec-empty-title { font-size: 13px; font-weight: 600; color: var(--ink); }
.rec-empty-body {
  margin-top: 5px;
  font-size: 12px;
  line-height: 1.5;
  color: var(--muted);
}
.rec-retry {
  margin-top: 10px;
  padding: 6px 12px;
  border-radius: 7px;
  border: 1px solid var(--line-strong);
  background: var(--surface-2);
  color: var(--ink-soft);
  font-size: 12px;
  cursor: pointer;
}
.rec-retry:hover { border-color: var(--ai-border); color: var(--ink); }

.rec-footer {
  padding-top: 4px;
  border-top: 1px solid var(--line);
}
.rec-more-btn {
  width: 100%;
  padding: 9px 12px;
  border-radius: 8px;
  border: 1px solid var(--ai-border);
  background: transparent;
  color: var(--ink);
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.16s var(--ease), color 0.16s var(--ease);
}
.rec-more-btn:hover { background: var(--ai-grad); color: #fff; border-color: transparent; }
.rec-more-form {
  margin-top: 9px;
  display: flex;
  flex-direction: column;
  gap: 7px;
}
.rec-more-form label { font-size: 11.5px; color: var(--muted); }
.rec-more-form input {
  padding: 7px 9px;
  border-radius: 7px;
  border: 1px solid var(--line-strong);
  background: var(--surface);
  color: var(--ink);
  font-family: var(--mono);
  font-size: 12.5px;
}
.rec-more-actions { display: flex; gap: 7px; }
.rec-more-go, .rec-more-cancel {
  flex: 1 1 auto;
  padding: 7px 10px;
  border-radius: 7px;
  font-size: 12px;
  cursor: pointer;
  border: 1px solid var(--line-strong);
}
.rec-more-go { background: var(--ai-grad); color: #fff; border-color: transparent; font-weight: 600; }
.rec-more-cancel { background: var(--surface-2); color: var(--ink-soft); }

/* Suggestion pins carry a RANK, where permit pins carry a dollar value. The
   shapes are deliberately different so the two can never be confused on a map
   the user may have switched between moments earlier. */
.rec-pin { background: none; border: none; }
.rec-pin .rec-pin-inner {
  position: absolute;
  left: 0; top: 0;
  transform: translate(-50%, -50%);
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border-radius: 999px;
  background: var(--ai-grad);
  color: #fff;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 700;
  border: 2px solid var(--surface);
  box-shadow: 0 2px 6px rgba(20, 23, 26, 0.3);
  cursor: pointer;
  transition: transform 0.12s var(--ease), box-shadow 0.12s var(--ease);
}
.rec-pin.active .rec-pin-inner,
.rec-pin .rec-pin-inner:hover {
  transform: translate(-50%, -50%) scale(1.18);
  box-shadow: var(--ai-glow);
}

.rec-list > .rec-card { animation: cardIn 0.28s var(--ease-out) both; }
.rec-list > *:nth-child(1) { animation-delay: 0ms; }
.rec-list > *:nth-child(2) { animation-delay: 22ms; }
.rec-list > *:nth-child(3) { animation-delay: 44ms; }
.rec-list > *:nth-child(4) { animation-delay: 66ms; }
.rec-list > *:nth-child(5) { animation-delay: 88ms; }

/* ==========================================================================
   Coverage view: radar pulses, city pins, and the blurred coverage blob
   ========================================================================== */

/* The blurred pane behind the city outlines. The blur RADIUS is set inline by
   map.js (it scales with zoom); this rule only establishes that the pane is a
   filtered, non-interactive surface. `will-change` keeps the filter on the
   compositor so panning at continent zoom doesn't re-rasterise it every frame. */
.leaflet-pane.leaflet-coverageGlow-pane {
  pointer-events: none;
  will-change: filter;
}

/* One radar pulse per covered area. Anchored at [0,0] on the marker's own
   point, so everything inside is positioned from that centre. */
.radar-pulse {
  position: relative;
  pointer-events: auto;
  cursor: pointer;
}
.radar-pulse .radar-core,
.radar-pulse .radar-ring {
  position: absolute;
  left: 0;
  top: 0;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}
/* The solid dot at the centre — the thing the rings emanate from.
   ENLARGED FROM 8px. At continent zoom the rings are the only part of a pulse
   with any real presence and the core read as a speck of dust between them, so
   a covered area was easy to miss entirely and the dot was a small click target
   for something that is the primary way into a city. The rings grew with it so
   the proportions of the sweep are unchanged — see the ring rule below. */
.radar-pulse .radar-core {
  width: 12px;
  height: 12px;
  background: var(--ai-border, #9b5bb8);
  /* The white collar is what keeps the dot readable over a dark basemap or a
     busy satellite tile; it scales with the dot. */
  box-shadow: 0 0 0 2.5px rgba(255, 255, 255, 0.8);
}
/* Two rings on the same keyframes, the second delayed half a cycle, which is
   what makes one pulse read as a repeating sweep rather than a single ping. */
.radar-pulse .radar-ring {
  width: 17px;
  height: 17px;
  border: 2px solid var(--ai-border, #9b5bb8);
  opacity: 0;
  animation: radar-sweep 2.8s cubic-bezier(0.18, 0.7, 0.4, 1) infinite;
}
.radar-pulse .radar-ring-2 { animation-delay: 1.4s; }

@keyframes radar-sweep {
  0%   { transform: translate(-50%, -50%) scale(0.6); opacity: 0.75; }
  70%  { opacity: 0.14; }
  100% { transform: translate(-50%, -50%) scale(6.5); opacity: 0; }
}

/* Respect a reduced-motion preference: the pulse becomes a static halo, which
   still marks the area without animating. */
@media (prefers-reduced-motion: reduce) {
  .radar-pulse .radar-ring {
    animation: none;
    opacity: 0.35;
    transform: translate(-50%, -50%) scale(2.4);
  }
}

/* Pin + label, added once the map is zoomed in enough to read them
   (PIN_LABEL_ZOOM in map.js). The pin's tip sits on the centre point, so it is
   offset up by its own height. */
.radar-pulse .radar-pin {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, -100%);
  color: var(--ai-border, #9b5bb8);
  line-height: 0;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
}
.radar-pulse .radar-label {
  position: absolute;
  left: 0;
  top: 4px;
  transform: translateX(-50%);
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--surface, #fff);
  border: 1px solid var(--line, rgba(0, 0, 0, 0.12));
  color: var(--ink, #161d2b);
  font-size: 11px;
  font-weight: 600;
  white-space: nowrap;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
}
/* With a pin on top, the core dot would just sit behind it. */
.radar-pulse.is-labelled .radar-core { display: none; }

/* ==========================================================================
   Parcel scan overlay — the on-map "we are working" state
   ==========================================================================
   Sits over the map rather than in the side panel because the wait is long
   enough (a paged county fetch, tens of seconds) that a thin bar off to the
   side reads as a hang. Top-centre, above Leaflet's own controls. */
.scan-overlay {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;                 /* above Leaflet panes and controls */
  display: flex;
  align-items: center;
  gap: 13px;
  max-width: min(440px, calc(100% - 28px));
  padding: 12px 15px;
  border-radius: 12px;
  border: 1px solid var(--line);
  background: var(--surface);
  box-shadow: 0 8px 26px rgba(0, 0, 0, 0.18);
}
.scan-overlay[hidden] { display: none; }

/* The radar dial: a static grid with a sweeping wedge over it. Purely
   decorative — the numbers underneath carry the actual information. */
.scan-radar {
  position: relative;
  flex: 0 0 auto;
  width: 42px;
  height: 42px;
  border-radius: 50%;
  overflow: hidden;
  background: radial-gradient(circle at 50% 50%,
    color-mix(in srgb, var(--ai-border, #9b5bb8) 22%, transparent),
    transparent 70%);
  border: 1px solid color-mix(in srgb, var(--ai-border, #9b5bb8) 45%, transparent);
}
.scan-radar .scan-grid {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to right, color-mix(in srgb, var(--ai-border, #9b5bb8) 30%, transparent) 1px, transparent 1px) 50% 0 / 100% 100%,
    linear-gradient(to bottom, color-mix(in srgb, var(--ai-border, #9b5bb8) 30%, transparent) 1px, transparent 1px) 0 50% / 100% 100%;
  background-repeat: no-repeat;
}
.scan-radar .scan-sweep {
  position: absolute;
  inset: 0;
  background: conic-gradient(
    from 0deg,
    color-mix(in srgb, var(--ai-border, #9b5bb8) 70%, transparent) 0deg,
    transparent 65deg,
    transparent 360deg);
  animation: scan-rotate 1.9s linear infinite;
}
@keyframes scan-rotate { to { transform: rotate(360deg); } }

.scan-body { flex: 1 1 auto; min-width: 0; }
.scan-title {
  font-size: 12px;
  font-weight: 700;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.scan-message {
  margin-top: 1px;
  font-size: 12px;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.scan-track {
  margin-top: 7px;
  height: 5px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--muted) 22%, transparent);
  overflow: hidden;
}
.scan-fill {
  height: 100%;
  width: 6%;
  border-radius: 999px;
  background: var(--ai-grad, var(--ai-border, #9b5bb8));
  transition: width 0.3s ease;
}
/* Before the county reports a total there is no denominator, so the bar slides
   instead of sitting at zero — which would read as stuck. */
.scan-fill.is-indeterminate { animation: scan-slide 1.5s ease-in-out infinite; }
@keyframes scan-slide {
  0%   { margin-left: 0%;  width: 12%; }
  50%  { margin-left: 60%; width: 30%; }
  100% { margin-left: 0%;  width: 12%; }
}
.scan-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 5px;
  font-size: 11px;
  color: var(--muted);
}
.scan-cancel {
  flex: 0 0 auto;
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 11px;
  text-decoration: underline;
  cursor: pointer;
}
.scan-cancel:hover { color: var(--ink); }

/* While a scan is running the selected tract's boundary breathes, so it is
   obvious WHICH area the overlay is talking about. */
.leaflet-pane.leaflet-selectedTract-pane.is-scanning {
  animation: scan-breathe 1.9s ease-in-out infinite;
}
@keyframes scan-breathe {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.45; }
}

@media (prefers-reduced-motion: reduce) {
  .scan-radar .scan-sweep,
  .scan-fill.is-indeterminate,
  .leaflet-pane.leaflet-selectedTract-pane.is-scanning { animation: none; }
}


/* ===========================================================================
 * Display face: Fraunces, pinned to ONE static optical size
 * ===========================================================================
 *
 * Fraunces is the house display face and stays. What changed is how it is
 * REQUESTED, because the old request is what produced the warped letterforms.
 *
 * The URL used to ask for an optical-size AXIS RANGE (`opsz@9..144`). Asking
 * for a range makes Google serve a variable file, and the file it serves has
 * `opsz: 9 .. 9 .. 144` — its DEFAULT optical size is 9, the smallest cut in
 * the family. Chrome applies `font-optical-sizing: auto` and silently moves
 * that axis up to match the rendered size; other engines do not. So a 68px
 * headline rendered in the NINE-POINT master blown up seven times: heavy
 * joins, flared stems, exaggerated apertures — shapes drawn to survive at tiny
 * sizes, and visibly distorted at display size.
 *
 * The request now names a SINGLE optical size (`opsz@72`), which makes Google
 * serve static instances — files with no `fvar` table at all. Same typeface,
 * one fixed cut, no axis for two engines to resolve differently. 72 rather
 * than the 144 extreme: at 144 the contrast is so high the apertures nearly
 * close and legibility suffers at the sizes this page actually uses.
 *
 * `font-synthesis: none` stays. Where a real cut is missing a browser will
 * manufacture one by slanting or smearing the upright, and engines synthesise
 * differently — a second, independent source of "looks wrong in Firefox" for
 * the italic <em>. Refusing synthesis means a missing cut falls back to the
 * real upright: honest, and identical everywhere. Every weight used here,
 * upright and italic, is in the request, so nothing legitimate needs it.
 *
 * DO NOT reintroduce an axis range in the font URL without re-reading the
 * above. `opsz,wght@9..144,400..700` looks tidier and quietly brings the whole
 * problem back. */

.hp-headline,
.hp-h2,
.hp-brand-name,
.brand-name,
.hp-note-title,
.hp-trade-copy h3 {
  font-synthesis: none;
}

/* ==========================================================================
   The area card — the tract drill-down's own panel, on the map
   ==========================================================================
   Top-left, over the map, because it describes the polygon outlined underneath
   it. Two halves in one card: what happened here in the selected window, then
   what the area IS. The rule and the small-caps heading between them are load
   bearing — the first half moves with the period picker and the second does not,
   and reading them as one list scoped the same way would be wrong.

   MAX-HEIGHT AND ITS OWN SCROLL. The card sits inside a map panel whose height
   is whatever the window gives it; without a ceiling a tract with a full ACS row
   runs off the bottom of the map on a short viewport. */
.area-card {
  /* THE SWITCH map.js READS. The card is turned off entirely at phone widths
     (see the max-width: 600px block), and loadAreaCard has to know that so it
     does not fire a /profile request for a card nobody will see. It asks for
     this custom property rather than running a matchMedia check of its own:
     the breakpoint is a layout decision, it is made here, and a layout decision
     expressed twice eventually disagrees with itself. Custom properties resolve
     on a display:none element, so this survives being switched off. */
  --area-card-enabled: 1;
  position: absolute;
  top: 14px;
  /* CLEAR OF LEAFLET'S ZOOM CONTROL, which lives at the map's top-left corner
     (10px margin, 30px wide). At left:14 the card sat straight on top of the +
     and − buttons. 54 puts its edge just past them and still reads as anchored
     to that corner. */
  left: 54px;
  z-index: 905;                       /* over the choropleth, under the scan overlay */
  width: 258px;
  max-height: calc(100% - 28px);
  overflow-y: auto;
  padding: 12px 13px 13px;
  border: 1px solid var(--line-strong);
  border-radius: 12px;
  background: var(--surface);
  box-shadow: var(--shadow-pop);
}
.area-card[hidden] { display: none; }

/* NO AREA CARD ON A PHONE.
   Full-width-minus-gutters at up to 48% of the height was still most of what a
   phone shows: at that width the map IS the whole screen (position:absolute;
   inset:0 — see the max-width:600px block), the results sheet already takes the
   bottom --sheet-peek of it, and dropping a card over the rest left the tract
   the user had just tapped completely hidden behind the description of it.

   Nothing is stranded by this. The card's two jobs both survive: the way OUT of
   a tract is also the results header's "Back to areas" button, which sits
   inside the sheet's peek and so is on screen without opening anything; and the
   tract's figures are in the results panel a swipe up.

   HERE RATHER THAN WITH THE OTHER PHONE RULES, which appear ~1800 lines
   earlier. A media query adds no specificity, so `--area-card-enabled: 0` there
   would be overwritten by the `: 1` in the rule above and map.js would go on
   fetching profiles for a card it cannot show — silently, since `display: none`
   has no competing declaration and would have kept working. */
@media (max-width: 600px) {
  .area-card {
    display: none;
    --area-card-enabled: 0;
  }
}

/* THE WAY OUT, first thing in the card and styled as a real button rather than
   as the text link at the far end of the results panel that used to be the only
   exit from a tract. Full width: it is the one action this card offers, and a
   button that fills its container cannot be mistaken for a label. */
.area-back {
  display: flex;
  align-items: center;
  gap: 7px;
  width: 100%;
  margin-bottom: 11px;
  padding: 7px 10px;
  border: 1px solid var(--line-strong);
  border-radius: 8px;
  background: var(--surface-2);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
  transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.area-back:hover {
  border-color: var(--accent);
  background: var(--accent-soft);
  color: var(--accent-ink);
}
.area-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.area-back svg { flex: 0 0 auto; }

.area-card-head { margin-bottom: 10px; }
.area-card-name {
  font-family: var(--display);
  font-size: 17px;
  font-weight: 600;
  line-height: 1.2;
  color: var(--ink);
}
/* The window the figures below describe, named on the card itself. A number
   without its window is not an answer, and this card is often the only thing on
   screen when the map is zoomed into one polygon. */
.area-card-period {
  margin-top: 2px;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

/* The permit figures. Two columns: four short figures read faster side by side
   than stacked, and the card is narrow enough that one column would be tall. */
.area-metrics {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 9px 10px;
}
.area-stat { min-width: 0; }
.area-stat-k {
  font-size: 10.5px;
  line-height: 1.25;
  color: var(--muted);
}
.area-stat-v {
  font-family: var(--mono);
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
}
/* An unknown is an em-dash in the muted colour, never a zero: "no ACS figure for
   this tract" and "this tract's median income is nothing" are opposite claims. */
.area-stat-v.is-empty { color: var(--muted); font-weight: 500; }

.area-demo-head {
  margin: 12px 0 8px;
  padding-top: 11px;
  border-top: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}
/* The ACS half is a label/value list rather than a grid: these labels are long
   ("Owner-occupied share") and a two-column grid would wrap every one of them. */
.area-demo { display: flex; flex-direction: column; gap: 6px; }
.area-demo-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  font-size: 12.5px;
}
.area-demo-row .k { color: var(--muted); min-width: 0; }
.area-demo-row .v {
  font-family: var(--mono);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
  white-space: nowrap;
}
.area-demo-row .v.is-empty { color: var(--muted); font-weight: 500; }

/* The forecast block on the area card.
 *
 * Same heading treatment as .area-demo-head — a rule and a mono kicker —
 * because it is the same KIND of division: a block scoped differently from the
 * one above it. The permit figures move with the period picker; neither the
 * forecast nor the ACS half does, and both say so by sitting under their own
 * rule rather than running on from the block above. */
.area-pred-head {
  margin: 12px 0 8px;
  padding-top: 11px;
  border-top: 1px solid var(--line);
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
}
.area-pred-head[hidden] { display: none; }
/* Breathing room between "what we expect next" and "how the last call landed",
   which sit in the same block under one heading but are two different claims. */
#areaPredLast { margin-top: 9px; }
#areaPredLast:empty { display: none; }
/* WHAT ACTUALLY HAPPENED, as opposed to what we said would happen. --good is
   the app's existing "verified / actually happened" teal — the same token the
   track-record hits and the issued-permit pill already use — so it arrives
   carrying this meaning rather than teaching a new one here. */
.area-demo-row .v.is-actual { color: var(--good); }

.area-card-note {
  margin: 10px 0 0;
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--muted);
}
.area-card-note[hidden] { display: none; }

/* The card and the zoom prompt never coexist (updateZoomHint goes silent inside
   a drill-down), but the search chip can, and it is centred — so nothing here
   needs to move for it. */

/* ==========================================================================
   First-run orientation card
   ==========================================================================
   Bottom-centre over the map: clear of the area card and the legend, in the
   lane the eye lands in after the map paints, and out of the way of the
   controls it is describing. See the note on the markup in index.html for what
   it fixes — the short version is that the app already opens on the user's own
   zip filtered to their own trade, and never said so. */
.first-run {
  position: absolute;
  left: 50%;
  bottom: 22px;
  transform: translateX(-50%);
  z-index: 940;
  width: min(420px, calc(100% - 28px));
  padding: 15px 17px 15px;
  border: 1px solid var(--line-strong);
  border-radius: 14px;
  background: var(--surface);
  box-shadow: var(--shadow-lg);
  animation: firstRunIn 0.34s var(--ease-out);
}
.first-run[hidden] { display: none; }
@keyframes firstRunIn {
  from { opacity: 0; transform: translateX(-50%) translateY(10px); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@media (prefers-reduced-motion: reduce) { .first-run { animation: none; } }

.first-run-close {
  position: absolute;
  top: 8px;
  right: 9px;
  width: 24px;
  height: 24px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--muted);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
}
.first-run-close:hover { background: var(--pill-bg); color: var(--ink); }
.first-run-eyebrow {
  margin: 0 0 6px;
  font-family: var(--mono);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
}
.first-run-body {
  margin: 0;
  padding-right: 18px;          /* clear of the close button */
  font-size: 14px;
  line-height: 1.55;
  color: var(--ink-soft);
}
.first-run-body b { color: var(--ink); font-weight: 600; }
.first-run-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 13px;
}
/* The AI gradient, because the button switches to the prediction layer and that
   layer is what this accent means everywhere else in the app. */
.first-run-cta {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 14px;
  border: 0;
  border-radius: 9px;
  background: var(--ai-grad);
  color: #fff;
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.first-run-cta:hover { background: var(--ai-grad-hover); }
.first-run-cta svg { fill: currentColor; }
.first-run-alt {
  border: 0;
  background: none;
  font: inherit;
  font-size: 13px;
  color: var(--muted);
  cursor: pointer;
}
.first-run-alt:hover { color: var(--ink); }
/* On a phone the sheet rests at the bottom of the map, so the card sits above
   it rather than behind it. */
@media (max-width: 600px) {
  .first-run { bottom: calc(var(--sheet-peek) + 14px); }
}

/* ==========================================================================
   Zoom prompt over the map
   ==========================================================================
   Shown whenever the tract layer is not what's on screen. Sits centred at the
   top, above Leaflet's panes but below the scan overlay, which is the more
   urgent message when both could apply. */
.zoom-hint {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 900;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 14px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--muted);
  font-size: 12.5px;
  font-weight: 500;
  white-space: nowrap;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14);
  pointer-events: none;
}
.zoom-hint[hidden] { display: none; }
/* On a phone the legend is pinned top-right and this chip is top-centre, so at
   full width the two collide. Left-aligned and wrapped, it clears the legend
   and the zoom control both. */
@media (max-width: 600px) {
  .zoom-hint {
    /* Clear of Leaflet's zoom control on the left and the legend on the right. */
    left: 52px;
    right: 128px;
    transform: none;
    max-width: none;
    white-space: normal;
    line-height: 1.3;
    text-align: left;
  }
}
.zoom-hint svg { flex: 0 0 auto; color: var(--accent); }

/* ---------------------------------------------------------------------------
   Search result: the pin, the ZIP highlight, and the chip that names them
   --------------------------------------------------------------------------- */

/* TOP-CENTRE, in the same lane as .zoom-hint — and stacked under it when both
   are showing (map.js adds .below-hint).

   Every other edge of the map is spoken for: Leaflet's zoom control is
   top-left, the choropleth legend is top-right, the attribution is
   bottom-right, the AI launcher is bottom-left, and the trial banner is
   bottom-centre. Top-centre is the one place a chip can sit without landing on
   something. Unlike .zoom-hint it takes pointer events — it has a button in it.

   max-width leaves room for the legend on a narrow map rather than growing
   until the two meet. */
.search-result {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 900;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  max-width: min(340px, calc(100% - 200px));
  padding: 7px 7px 7px 12px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  font-size: 12.5px;
  font-weight: 500;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
}
.search-result[hidden] { display: none; }
/* Both chips visible at once (searched, then zoomed back out past the tract
   layer): this one drops below the zoom hint instead of on top of it. */
.search-result.below-hint { top: 56px; }
.search-result svg { flex: 0 0 auto; color: var(--accent); }
/* A long resolved address is truncated rather than allowed to wrap: the chip is
   a one-line label, and the full text is in the search box the user typed. */
#searchResultText {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A search that found nothing is a different kind of message from one that
   found something, so it reads as muted rather than as a result. */
.search-result.is-miss { color: var(--muted); }
.search-result.is-miss svg { color: var(--muted); }
.search-result-clear {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--muted);
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  transition: background 0.12s, color 0.12s;
}
.search-result-clear:hover { background: var(--line); color: var(--ink); }
.search-result-clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* The dropped pin for a found address.
   Built out of a divIcon with a zero-size box, like the price pins, so the
   anchor is the geocoded point EXACTLY and both children can be centred on it
   with transforms rather than with icon offsets that have to be kept in step
   with the CSS. */
.search-pin { pointer-events: none; }
.search-pin-dot,
.search-pin-pulse {
  position: absolute;
  left: 0;
  top: 0;
  border-radius: 50%;
  transform: translate(-50%, -50%);
}
.search-pin-dot {
  width: 15px;
  height: 15px;
  background: var(--accent);
  /* A ring in the panel colour, so the dot stays legible over a dark basemap,
     a light one, and the choropleth's mid-tones alike. */
  border: 3px solid var(--surface);
  box-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
/* One expanding ring. It draws the eye to the answer the moment the map
   arrives — which is the whole job — and then stops, because a marker that
   pulses forever becomes something to ignore. */
.search-pin-pulse {
  width: 15px;
  height: 15px;
  border: 2px solid var(--accent);
  animation: search-pin-ping 1.5s ease-out 3;
  animation-fill-mode: forwards;
}
@keyframes search-pin-ping {
  0% { opacity: 0.75; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(4.2); }
}
@media (prefers-reduced-motion: reduce) {
  .search-pin-pulse { animation: none; opacity: 0; }
}

/* A clustered pulse carries a count instead of the single-area dot. */
.radar-pulse .radar-count {
  position: absolute;
  left: 0;
  top: 0;
  transform: translate(-50%, -50%);
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 999px;
  background: var(--ai-border, #9b5bb8);
  color: #fff;
  border: 2px solid var(--surface, #fff);
  font-size: 10.5px;
  font-weight: 700;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
/* A cluster is several places, so its rings start wider — it is covering more
   ground than a single area's pulse. */
.radar-pulse.is-cluster .radar-ring { width: 24px; height: 24px; }

/* ==========================================================================
   Tract history chart
   ==========================================================================
   Six monthly columns, one series, emphasis on the month the map is showing.

   The two colours are measured, not chosen by eye: --tc-active vs --tc-idle
   separate by ΔE 15.3 (deuteranopia, light) and 18.2 (protanopia, dark), and
   all four clear 3:1 against their own surface. The idle step reads gray
   deliberately — that is what makes the active column the subject. */
.tract-chart {
  margin: 0;
  padding: 12px 16px 10px;
  border-bottom: 1px solid var(--line);
  background: var(--surface);

  --tc-active: #b0651b;   /* the brand accent — 4.45:1 on white */
  --tc-idle: #8e94a1;     /* de-emphasis gray — 3.04:1 on white */
}
[data-theme="dark"] .tract-chart {
  /* Selected for the dark surface, not an automatic flip of the light steps:
     8.14:1 and 4.43:1 against #1a202c respectively. */
  --tc-active: #f0a860;
  --tc-idle: #7b8698;
}
.tract-chart[hidden] { display: none; }

.tc-chart-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 4px;
  padding: 0;
}
.tc-chart-title {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.4px;
  text-transform: uppercase;
  color: var(--ink);
}
.tc-chart-sub { font-size: 11px; color: var(--muted); }

.tc-svg { display: block; overflow: visible; }
/* One hairline baseline, solid, recessive. No gridlines: six columns and a
   direct label do not need a coordinate system drawn behind them. */
.tc-axis { stroke: var(--line-strong); stroke-width: 1; }

.tc-bar rect { fill: var(--tc-idle); transition: fill 0.14s; }
.tc-bar.is-active rect { fill: var(--tc-active); }
.tc-bar-zero { fill: var(--line-strong) !important; }
/* Hover is the per-mark tooltip's affordance — the <title> supplies the text,
   this makes it clear the column is the thing being pointed at. */
.tc-bar:hover rect { fill: var(--tc-active); }

/* EVERY COLUMN IS A BUTTON. The chart named six months the map could be showing
   and offered no way to go to any of them: the only route to March was to open
   the period picker at the far end of the screen and find it by name, having
   just been shown it here. Clicking a column now sets the window to that month,
   which is the shortest possible path between seeing a spike and looking at it.

   The hit area is the whole SLOT, not the drawn bar — a zero month is a 1px
   hairline on the baseline, and a control you cannot hit is not a control. That
   is what .tc-hit is; it is transparent and sits over the column. */
.tc-bar { cursor: pointer; }
.tc-hit { fill: transparent; cursor: pointer; }
/* Pointing anywhere in the slot lights the column, so the whole target reads as
   one thing rather than as a bar with dead space around it. */
.tc-slot:hover .tc-bar rect { fill: var(--tc-active); }
.tc-slot:hover .tc-tick { fill: var(--ink); }
/* Keyboard focus. The slot is focusable, so it needs a visible ring of its own —
   the browser's default outline on an SVG <g> is unreliable across engines. */
.tc-slot:focus { outline: none; }
.tc-slot:focus-visible .tc-hit {
  fill: var(--accent-soft);
  stroke: var(--accent);
  stroke-width: 1.5;
}

/* Text wears text tokens, never the series colour. */
.tc-bar-label {
  /* Drawn over the slots, so without this it would swallow the pointer across
     its own glyphs and put a dead patch in the middle of a column's hit area. */
  pointer-events: none;
  fill: var(--ink);
  font-size: 11px;
  font-weight: 700;
  font-family: var(--mono, inherit);
}
.tc-tick {
  fill: var(--muted);
  font-size: 9.5px;
  letter-spacing: 0.02em;
}
.tc-tick.is-active { fill: var(--ink); font-weight: 700; }

/* The table view. Collapsed, but reachable — the accessible equivalent of the
   hover tooltips and the fallback for anyone who cannot use them. */
.tc-chart-table { margin-top: 8px; }
.tc-chart-table summary {
  font-size: 11px;
  color: var(--muted);
  cursor: pointer;
  list-style: none;
}
.tc-chart-table summary::-webkit-details-marker { display: none; }
.tc-chart-table summary::before { content: '▸ '; }
.tc-chart-table[open] summary::before { content: '▾ '; }
.tc-chart-table summary:hover { color: var(--ink); }
.tc-table {
  width: 100%;
  margin-top: 6px;
  border-collapse: collapse;
  font-size: 11.5px;
}
.tc-table th, .tc-table td {
  padding: 3px 0;
  text-align: left;
  border-bottom: 1px solid var(--line);
  color: var(--ink);
}
.tc-table th {
  color: var(--muted);
  font-weight: 600;
  text-transform: uppercase;
  font-size: 9.5px;
  letter-spacing: 0.4px;
}
.tc-table td:last-child, .tc-table th:last-child { text-align: right; }

/* Permit-type checklist rows carry a one-line definition under the label.
   OTHER is the reason this exists — "Other" alone doesn't tell anyone that new
   construction is in there — but every row gets one so the list reads evenly. */
.checklist .chk-row {
  align-items: flex-start;
  gap: 9px;
  padding: 7px 4px;
}
.checklist .chk-row input { margin-top: 2px; flex: 0 0 auto; }
.chk-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.chk-desc {
  font-size: 11px;
  line-height: 1.35;
  color: var(--muted);
}

/* ===========================================================================
 * "Since you last signed in" — the map's notification card
 * ========================================================================
 * Anchored to the map, TOP RIGHT. Not the side panel, which is scrolled,
 * filtered and replaced constantly and would lose the notice on the first pan;
 * and not top-centre, which is where .zoom-hint and .search-result already sit.
 *
 * z-index sits above those chips and above Leaflet's own panes (max 800) but
 * below the modals (.track-modal). The card is dismissable and the modals are
 * not — a dialog that opened underneath a card the user has to close first is
 * the one stacking order that would actually trap someone. */
.notify-card {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 950;
  width: min(320px, calc(100% - 28px));
  padding: 13px 14px 12px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface);
  color: var(--ink);
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
  /* The card can hold two groups of four permits; past that it scrolls rather
     than growing down over the map it is meant to annotate. */
  max-height: min(60vh, 440px);
  overflow-y: auto;
}
.notify-card[hidden] { display: none; }

/* Above the map legend on narrow screens, where the two would otherwise
   overlap in the same corner. The card is transient; the legend is not, so the
   card is what moves. */
@media (max-width: 900px) {
  .notify-card { width: calc(100% - 28px); max-height: 45vh; }
}

.notify-close {
  position: absolute;
  top: 6px;
  right: 8px;
  width: 24px;
  height: 24px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--muted);
  font-size: 19px;
  line-height: 1;
  cursor: pointer;
}
.notify-close:hover { background: var(--bg2); color: var(--ink); }

.notify-head {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  /* Clears the close button so a long subtitle cannot run under it. */
  padding-right: 22px;
}
.notify-icon { display: grid; place-items: center; color: var(--accent); flex: 0 0 auto; }
.notify-head-text { min-width: 0; }
.notify-title {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: -0.01em;
}
/* The window this card describes: which area, and what "new" is measured from.
   Without it "3 new permits" is a number with no period attached to it — and
   the period here is "between two sign-ins", which nobody can infer. */
.notify-sub {
  margin-top: 2px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--muted);
}

.notify-groups { margin-top: 10px; }
.notify-group + .notify-group {
  margin-top: 9px;
  padding-top: 9px;
  border-top: 1px solid var(--line);
}
.notify-group-title {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--ink);
}
.notify-list {
  margin: 5px 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.notify-list li {
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--muted);
  /* One line each. The card is a count with examples under it, not the permit
     list — that is what the panel behind it is for. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Shown to a signed-in caller without entitlement. The COUNT is still theirs —
   it is the honest reason to come back — but the addresses are the paid layer,
   and /api/notifications does not send them. This says so rather than leaving
   a group title over nothing, which reads as a failed load. */
.notify-locked {
  margin: 5px 0 0;
  font-size: 11px;
  font-style: italic;
  color: var(--muted);
}

/* The quiet state: a card with a destination but no news. Distinguishable from
   an empty body, which reads as a load that failed. */
.notify-quiet {
  font-size: 12px;
  line-height: 1.45;
  color: var(--muted);
}

.notify-cta {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  width: 100%;
  margin-top: 11px;
  padding: 8px 11px;
  border: 0;
  border-radius: 9px;
  background: var(--accent);
  /* NOT #fff. In dark mode --accent is a light amber and white on it fails
     contrast badly; --on-accent is the paired label colour that flips with the
     theme, and it is what every other amber-filled control in this file uses. */
  color: var(--on-accent);
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}
.notify-cta[hidden] { display: none; }
.notify-cta:hover { background: var(--accent-2); }
.notify-cta svg { flex: 0 0 auto; fill: currentColor; }
/* The tract name is appended to the label and can be long; it truncates rather
   than wrapping the button to two lines. */
.notify-cta span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
