  :root {
    --bg: #f6f5f1;
    --surface: #ffffff;
    --surface-alt: #edebe4;
    --border: #ddd8cd;
    --border-strong: #c4bdb0;
    --text: #201e1a;
    --muted: #71695d;
    --accent: #1f3d5c;
    /* Data-mark blue. NOT --accent: that is #1f3d5c, which measures chroma 0.065 and lightness
       0.353 — below the chroma floor and outside the lightness band, so as a thin 2px line it
       reads gray rather than as data. This is the same hue family stepped to pass (validated:
       lightness band, chroma floor, and >=3:1 against the card surface). */
    --chart-series: #2b6cb0;
    /* Second data hue, for a chart carrying two series. Validated AS A PAIR with --chart-series:
       CVD ΔE 21.0 (protan) / 22.3 (tritan) and 24.0 for normal vision. The intuitive pick — a
       purple against the blue — FAILED at ΔE 2.3 under deuteranopia and 11.8 even with full colour
       vision, i.e. two series a third of readers could not separate. Deliberately not --warn:
       status colours stay reserved for status. */
    --chart-series-2: #a05a1f;
    --accent-hover: #163049;
    --accent-soft: #e6ebf0;
    /* A lighter navy than --accent, used only for the map's price badges/dots — the full-strength
     * --accent read as too heavy across a whole map dense with pins, per direct user feedback. */
    --pin-accent: #3d6690;
    --pin-highlight: #3b82f6;
    --good: #1c6b45;
    --good-soft: #e2efe6;
    --bad: #9c3327;
    /* Map pin colours per dataset (see the map-mode switch). Sale keeps --pin-accent's blue; rent
       and sold get their own hue so a glance at the map tells you WHICH dataset you're looking at
       without reading the filter panel. Yellow can't carry white text at this size — .price-pin
       switches to dark ink for sold, which is why these are two tokens and not one. */
    --pin-rent: #6b3fa0;
    --pin-sold: #e0a300;
    --bad-soft: #f4e5e1;
    --warn: #8a5f10;
    --warn-soft: #f2e7cd;
    --radius: 10px;
    /* Liquid-glass tokens (see the liquid-glass skill) — mapped onto this app's own warm/navy
     * palette rather than importing a second, competing brand palette. Glass is four properties
     * together (translucent fill + blur+saturate + light border + soft shadow); dropping any one
     * of them is what makes an attempt at this read as "just a translucent box" instead. */
    --glass-bg: rgba(255, 255, 255, 0.64);
    --glass-border: rgba(255, 255, 255, 0.8);
    --glass-shadow: 0 12px 34px -18px rgba(32, 30, 26, 0.28), 0 26px 70px -40px rgba(32, 30, 26, 0.22),
      inset 0 1px 0 rgba(255, 255, 255, 0.5);
    --glass-nav-border: rgba(32, 30, 26, 0.14);
    --spring: linear(0, 0.04, 0.16, 0.36, 0.6, 0.82, 0.97, 1.04, 1.05, 1.04, 1.02, 1.007, 1);
    /* The results-page split view's fixed-width side column (see the `min-width: 900px` block
       further down) — map.js reads this SAME 900px breakpoint via matchMedia to decide whether to
       run the mobile bottom-sheet drag machinery at all; keep the two in sync if this changes. */
    --results-panel-width: 420px;
    /* Where the filter panel's top edge lands: .overlay-top's 14px top padding + the 40px search
       row + the 8px the panel already offsets itself by. Kept as a token because the panel's
       height cap has to subtract exactly this, and the two drifting apart is what leaves the
       panel either overlapping the bar or stopping short of the window. */
    --filter-panel-top: 62px;
    font-size: 16px;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0;
    /* A page short enough to need no scrolling (e.g. an empty Favorites/My Homes state) leaves
       mobile Safari's toolbar expanded, which shrinks the viewport the floating bottom-nav pill
       (position: fixed) anchors to — same page, same CSS, but the pill visibly sits higher than
       it does on the always-full-height search/map page. Forcing every page to fill at least one
       full viewport keeps the toolbar (and therefore the pill's position) consistent everywhere. */
    min-height: 100vh;
    min-height: 100dvh;
    background: var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    line-height: 1.45;
    font-size: 0.9375rem;
  }
  .wrap { max-width: 1140px; margin: 0 auto; padding: calc(20px + env(safe-area-inset-top)) 16px 40px; overflow-x: hidden; }
  /* Baseline reset — .detail-header h1/header.app-header h1 already set their own margin below,
     so this only actually changes anything for a bare top-level <h1> (Favorites/My Homes' title).
     Without it, that h1 fell back to the browser's unstyled default top margin (relative to its
     OWN font-size, on top of .wrap's own safe-area-aware padding above), which stacked into a
     noticeably bigger gap below the status bar than every other page in the app has — reported
     live as "the pages aren't the same size" comparing the search page (content sitting right at
     the safe-area edge) against Favorites (title sitting well below it). */
  h1 { margin: 0 0 4px; }
  a { color: var(--accent); }

  header.app-header { margin-bottom: 24px; }
  header.app-header h1 { font-size: 1.625rem; font-weight: 700; margin: 0 0 4px; letter-spacing: -0.01em; }
  header.app-header p { color: var(--muted); margin: 0; font-size: 0.875rem; }

  form.search-form { margin: 0; }
  .search-row { display: flex; align-items: center; gap: 10px; }
  /* Glass, matching the filter button and nav-menu pill either side of it (same chrome-tier
     16px blur) — this row floats over the map (see .overlay-top), so it's exactly where the
     material is meant to read rather than sitting as the one solid pill in an otherwise-glass
     row. */
  .search-pill {
    position: relative;
    flex: 1 1 auto;
    min-width: 0;
    height: 40px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    gap: 8px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    border-radius: 999px;
    padding: 0 8px 0 14px;
    box-shadow: var(--glass-shadow);
  }
  .search-pill:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
  .autocomplete-panel {
    position: absolute;
    top: calc(100% + 8px);
    left: 0;
    right: 0;
    z-index: 50;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    box-shadow: 0 12px 28px rgba(20,18,15,0.16);
    overflow: hidden;
  }
  .autocomplete-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 10px 14px;
    font-size: 0.875rem;
    color: var(--text);
    text-decoration: none;
    border-bottom: 1px solid var(--border);
  }
  .autocomplete-row:last-child { border-bottom: none; }
  .autocomplete-row:hover { background: var(--surface-alt); }
  .autocomplete-row span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .autocomplete-price { flex: 0 0 auto; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--muted); }
  .autocomplete-tag { font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.02em; }
  .search-icon-btn {
    flex: 0 0 auto;
    border: none;
    background: none;
    padding: 0;
    display: flex;
    align-items: center;
    color: var(--muted);
    cursor: pointer;
  }
  .search-icon-btn:hover { color: var(--accent); }
  .search-pill input {
    flex: 1 1 auto;
    min-width: 0;
    height: 100%;
    border: none;
    outline: none;
    background: none;
    font-size: 0.9375rem;
    color: var(--text);
    padding: 0;
  }
  .search-pill input::placeholder { color: var(--muted); }
  .search-clear {
    flex: 0 0 auto;
    border: none;
    background: var(--surface-alt);
    color: var(--muted);
    width: 22px;
    height: 22px;
    border-radius: 999px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    padding: 0;
    text-decoration: none;
  }
  .search-clear:hover { background: var(--border); color: var(--text); }
  details.search-filters { position: relative; flex: 0 0 auto; }
  details.search-filters summary {
    list-style: none;
    cursor: pointer;
    width: 40px;
    height: 40px;
    border-radius: 999px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
    color: var(--text);
    display: flex;
    align-items: center;
    justify-content: center;
  }
  details.search-filters summary::-webkit-details-marker { display: none; }
  details.search-filters[open] summary { color: var(--accent); }
  .map-credit {
    margin: 8px 0 2px;
    font-size: 0.6875rem;
    color: var(--muted);
    text-align: center;
  }
  .map-credit a { color: var(--muted); text-decoration: underline; }
  details.search-filters .filter-panel {
    position: absolute;
    top: calc(100% + 8px);
    right: 0;
    z-index: 30;
    width: 260px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(24px) saturate(180%);
    backdrop-filter: blur(24px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    border-radius: 16px;
    box-shadow: var(--glass-shadow);
    padding: 4px 14px;
    display: flex;
    flex-direction: column;
    /* Scroll rather than run off the bottom of a short window. The panel is absolutely positioned
       under the filter button, so with no cap its own height wins and the lower rows — including
       Apply filters — end up unreachable on a laptop in landscape or a small phone.
       dvh, not vh: on mobile browsers vh counts the space behind the collapsing address bar, so a
       vh-based cap still overflows by exactly that toolbar's height. The vh line above it is the
       fallback for engines without dvh. The subtracted amount covers this panel's own offset from
       the top of the screen (the search bar plus its safe-area padding) with a little breathing
       room beneath. */
    max-height: calc(100vh - var(--filter-panel-top) - 16px);
    max-height: calc(100dvh - var(--filter-panel-top) - 16px);
    /* Vertical only. overflow-x cannot stay "visible" while overflow-y is "auto" - the spec
       computes it to "auto" as well - so leaving it unset hands the panel a horizontal scrollbar
       it has no use for. */
    overflow-x: hidden;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    /* Scrolls, but with no visible track. Wheel and touch are the only ways anyone scrolls this
       panel, and a permanent grey bar cuts into a 260px-wide glass surface and pushes the value
       fields off-centre. Keyboard scrolling (arrows/PgDn on a focused row) is untouched. */
    scrollbar-width: none;
    /* Keeps a flick at the end of the list from chaining into the map behind it. */
    overscroll-behavior: contain;
  }

  /* Firefox and Chrome 121+ honour scrollbar-width above; older Blink and every WebKit need this. */
  details.search-filters .filter-panel::-webkit-scrollbar {
    display: none;
  }
  /* Liquid-glass nav menu — the desktop equivalent of the mobile bottom tab bar below, same
     three destinations. Left of the search pill, mirroring the filter button's shape/position
     on the right, but glass instead of solid — floating chrome over the map is exactly where
     the material is meant to read (see the liquid-glass skill's rule 2). */
  details.nav-menu { position: relative; flex: 0 0 auto; }
  @media (max-width: 680px) {
    details.nav-menu { display: none; }
  }
  details.nav-menu summary {
    list-style: none;
    cursor: pointer;
    width: 40px;
    height: 40px;
    border-radius: 999px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
    color: var(--text);
    display: flex;
    align-items: center;
    justify-content: center;
  }
  details.nav-menu summary::-webkit-details-marker { display: none; }
  details.nav-menu[open] summary { color: var(--accent); }
  details.nav-menu .nav-menu-panel {
    position: absolute;
    top: calc(100% + 8px);
    left: 0;
    z-index: 30;
    width: 200px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(24px) saturate(180%);
    backdrop-filter: blur(24px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    border-radius: 16px;
    box-shadow: var(--glass-shadow);
    padding: 6px;
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .nav-menu-item {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 9px 10px;
    border-radius: 10px;
    color: var(--text);
    font-size: 0.875rem;
    font-weight: 600;
    text-decoration: none;
  }
  .nav-menu-item:hover { background: rgba(255, 255, 255, 0.5); }
  .nav-menu-item svg { flex: 0 0 auto; color: var(--accent); }

  /* Mobile bottom tab bar — same three destinations plus Home, glass floating pill, hidden on
     desktop where the nav-menu dropdown above covers the same ground. */
  .bottom-nav-wrap {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    pointer-events: none;
    /* Fixed 20px on every side, matching the liquid-glass reference exactly — deliberately NOT
       env(safe-area-inset-bottom)-driven, which leaves the bar sitting too high on notched
       devices; the fixed inset is the same regardless of device. */
    padding: 20px;
    z-index: 200;
    display: none;
  }
  @media (max-width: 680px) {
    .bottom-nav-wrap { display: block; }
  }
  .bottom-nav {
    display: flex;
    position: relative;
    pointer-events: auto;
    padding: 4px;
    border-radius: 999px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-nav-border);
    box-shadow: var(--glass-shadow);
    max-width: 360px;
    margin: 0 auto;
  }
  .bnav-btn {
    flex: 1;
    position: relative;
    z-index: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    min-height: 52px;
    padding: 6px 2px;
    border: none;
    border-radius: 999px;
    background: transparent;
    color: var(--muted);
    text-decoration: none;
    -webkit-tap-highlight-color: transparent;
    transition: color 0.15s;
  }
  .bnav-btn.active { color: var(--accent); }
  .bnav-btn svg { flex: 0 0 auto; }
  .bnav-lbl { font-size: 0.625rem; font-weight: 600; }

  /* The sliding active-tab highlight (see the liquid-glass skill's segmented-pill.js) — ONE
   * element that travels between tabs rather than each button crossfading its own background;
   * the latter reads as a state change instead of a thing moving. Neutral grey fill, not a
   * brand-tinted glow — a tinted bubble with a colored shadow reads as a notification badge, not
   * a selection (Apple's own quaternary-fill value, not one of this app's navy tones). */
  /* No left/top/width/height transition or squish keyframe here on purpose (2026-08-25 — this
   * app removed both): bottom-nav.js's slidePill() only ever runs with animate=false (every tap
   * is a full page navigation, not a same-page toggle — see bottom-nav.js's header comment), so
   * that code path never executed. The real cross-tab motion is entirely the browser's native
   * View Transition API below (view-transition-name), which already animates via transform/
   * opacity internally — segmented-pill.js's reference implementation supports animating in place
   * too (for a same-page segmented control), but this app has no such use case to support. */
  .bnav-pill {
    position: absolute;
    left: 0;
    top: 0;
    z-index: 0;
    border-radius: 999px;
    background: rgba(120, 120, 128, 0.14);
    border: 0.5px solid rgba(120, 120, 128, 0.18);
    opacity: 0;
    pointer-events: none;
  }
  .bnav-pill.show { opacity: 1; }
  /* Applied for ONE synchronous measurement so a tab's own layout can't hide where it's about to
   * settle — never left on. See segmented-pill.js's _pillMeasure. */
  .pill-measure .bnav-btn, .pill-measure .bnav-btn * { transition: none !important; animation: none !important; }

  /* Cross-document View Transition (see bottom-nav.js) — every bottom-nav tap on this app is a
   * real full-page navigation (server-rendered routes, not a client-side router), so the pill
   * would otherwise just snap into its new position with no visible motion at all. Naming the
   * pill lets the browser morph it between the outgoing and incoming page automatically; the root
   * override below keeps that to JUST the pill; the rest of the page still swaps instantly rather
   * than cross-fading, which is a bigger, more jarring effect nobody asked for here. Browsers
   * without this API ignore both rules — a full no-op, not a visual regression. */
  .bnav-pill { view-transition-name: bnav-pill; }
  ::view-transition-group(root), ::view-transition-old(root), ::view-transition-new(root) {
    animation: none !important;
  }
  ::view-transition-group(bnav-pill) {
    animation-duration: 0.32s;
    animation-timing-function: var(--spring);
  }

  /* Thin top-of-viewport progress indicator for spa-nav.js's client-side navigations — the same
     "something is happening" cue a real browser navigation gives for free (its own loading spinner/
     progress), which a fetch-and-swap otherwise has none of. Scales 0 -> ~80% while .loading is set
     (a real request can take an unknown amount of time, so it never claims to reach 100% on its
     own); adding .done on completion snaps it the rest of the way, then it fades out. Fixed to
     <html>, not the swapped <body>, so it survives every swap untouched. Already full-width and
     scaled via transform (not animating width itself) so this stays a compositor-only animation. */
  .spa-progress-bar {
    position: fixed;
    top: 0;
    left: 0;
    height: 2.5px;
    width: 100%;
    transform: scaleX(0);
    transform-origin: left;
    background: var(--accent);
    z-index: 9999;
    opacity: 0;
    pointer-events: none;
  }
  .spa-progress-bar.loading {
    transform: scaleX(0.8);
    opacity: 1;
    transition: transform 4s cubic-bezier(0.15, 0.75, 0.3, 1), opacity 0.15s ease;
  }
  .spa-progress-bar.done {
    transform: scaleX(1);
    opacity: 1;
    transition: transform 0.18s ease, opacity 0.25s ease 0.15s;
  }

  /* A settings-list row — label left, compact control right — reused for every "more options"
     control (down payment, price range, schools, and whatever gets added next). */
  .filter-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 0;
  }
  .filter-row + .filter-row { border-top: 1px solid var(--border); }
  .filter-row label {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: 0.03em;
    white-space: nowrap;
  }
  .filter-row input[type="number"], .filter-row input.js-thousands-input, .filter-row select {
    width: 76px;
    flex: 0 0 auto;
    padding: 6px 8px;
    font-size: 0.875rem;
    text-align: right;
    border-radius: 8px;
    border: 1px solid var(--border-strong);
    background: var(--surface);
    color: var(--text);
    font-variant-numeric: tabular-nums;
  }
  .filter-row select { width: 118px; text-align: left; }
  .filter-row input[type="number"]:focus, .filter-row input.js-thousands-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
  /* Property type is multi-select (checkbox chips, not a single-value <select>) — needs its own
   * row layout since a wrapping chip group doesn't fit the label-left/control-right pattern every
   * other filter row uses. */
  .filter-row-stacked { flex-direction: column; align-items: stretch; gap: 8px; }
  .chip-group { display: flex; flex-wrap: wrap; gap: 6px; }
  .chip-checkbox {
    position: relative;
    display: inline-flex;
    align-items: center;
    padding: 6px 12px;
    border-radius: 999px;
    border: 1px solid var(--border-strong);
    background: var(--surface);
    color: var(--muted);
    font-size: 0.8125rem;
    font-weight: 600;
    cursor: pointer;
    user-select: none;
    transition: background 0.15s, border-color 0.15s, color 0.15s;
  }
  .chip-checkbox input { position: absolute; opacity: 0; width: 0; height: 0; }
  .chip-checkbox:has(input:checked) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
  /* Same pill shape as the property-type chips, but radios — the map shows one dataset at a time,
     and matching the existing chips keeps the panel visually consistent rather than introducing a
     second control idiom for the same kind of choice. The selected state is filled rather than
     tinted, since this one picks WHAT you're looking at rather than narrowing it. */
  .chip-radio {
    position: relative;
    display: inline-flex;
    align-items: center;
    padding: 6px 12px;
    border-radius: 999px;
    border: 1px solid var(--border-strong);
    background: var(--surface);
    color: var(--muted);
    font-size: 0.8125rem;
    font-weight: 600;
    cursor: pointer;
    user-select: none;
    transition: background 0.15s, border-color 0.15s, color 0.15s;
  }
  .chip-radio input { position: absolute; opacity: 0; width: 0; height: 0; }
  /* One line, always. .chip-group wraps by default, which is right for the five property-type
     chips but wrong here: these three are one choice and read as a segmented control, so "Sold"
     dropping to its own row makes it look like a separate setting.
     Each chip hugs its own label rather than taking an equal share of the width — stretching them
     to match left "Sold" floating in dead space, and the padding is what should be consistent
     between them, not the outer width. Padding is deliberately identical to .chip-radio's own
     (6px 12px, same as the property-type chips), so the gap from text to pill edge reads the same
     on every chip in the panel. */
  .map-mode-group { flex-wrap: nowrap; }
  .map-mode-group .chip-radio { white-space: nowrap; }
  .chip-radio:has(input:checked) { background: var(--accent); border-color: var(--accent); color: #fff; }
  .filter-panel-actions {
    display: flex;
    gap: 8px;
    padding-top: 14px;
    margin-top: 4px;
    border-top: 1px solid var(--border);
  }
  .filter-reset-btn, .filter-save-btn {
    flex: 1 1 0;
    padding: 9px 12px;
    border-radius: 999px;
    font-size: 0.8125rem;
    font-weight: 600;
    cursor: pointer;
  }
  .filter-reset-btn { border: 1px solid var(--border-strong); background: var(--surface); color: var(--muted); }
  .filter-reset-btn:hover { background: var(--surface-alt); }
  .filter-save-btn { border: 1px solid var(--accent); background: var(--accent); color: var(--surface); }
  .filter-save-btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
  .filter-row-checkbox input[type="checkbox"] { width: 18px; height: 18px; flex: 0 0 auto; margin: 0; accent-color: var(--accent); }

  /* Inline underwriting-edit toggle: a hidden checkbox (sibling of the table, not inside the
     form it submits to — see UnderwritingSection) flips every .uw-display/.uw-edit-field pair
     via the CSS sibling combinator, same trick as the Monthly/Yearly segmented toggle above. */
  .uw-edit-toggle { position: absolute; opacity: 0; width: 0; height: 0; }
  .uw-edit-actions { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; flex-wrap: wrap; }
  .uw-edit-btn {
    display: inline-flex;
    align-items: center;
    padding: 4px 9px;
    border: 1px solid var(--border-strong);
    border-radius: 7px;
    background: var(--surface);
    color: var(--muted);
    font-size: 0.6875rem;
    font-weight: 600;
    cursor: pointer;
    white-space: nowrap;
  }
  .uw-edit-toggle:checked ~ table .uw-edit-btn { background: var(--accent); border-color: var(--accent); color: #ffffff; }
  .uw-save-btn, .uw-reset-btn {
    display: none;
    padding: 4px 9px;
    border-radius: 7px;
    font-size: 0.6875rem;
    font-weight: 600;
    cursor: pointer;
    white-space: nowrap;
  }
  .uw-save-btn { border: 1px solid var(--accent); background: var(--accent); color: #ffffff; }
  .uw-reset-btn { border: 1px solid var(--border); background: none; color: var(--bad); }
  .uw-reset-btn:hover { background: var(--surface-alt); }
  .uw-edit-toggle:checked ~ table .uw-save-btn,
  .uw-edit-toggle:checked ~ table .uw-reset-btn { display: inline-flex; }
  @media (max-width: 680px) {
    .section-label td:last-child { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
    .uw-edit-actions { margin-left: 0; }
  }
  /* Looks like the plain text it replaces, not a form control — no border/background/box
     until the user actually interacts with it. Sized to its own typed content (field-sizing,
     supported Chrome/Edge 123+) with a fixed-width fallback for browsers without it. */
  .uw-edit-field {
    display: none;
    width: 90px;
    field-sizing: content;
    min-width: 3ch;
    max-width: 140px;
    padding: 1px 3px;
    font: inherit;
    font-size: 0.8125rem;
    font-weight: 600;
    text-align: right;
    border-radius: 5px;
    border: 1px solid transparent;
    background: transparent;
    color: var(--text);
    font-variant-numeric: tabular-nums;
    transition: background-color 0.1s, border-color 0.1s;
  }
  .uw-edit-field:hover { background: var(--surface-alt); border-bottom-color: var(--border-strong); }
  .uw-edit-field:focus {
    outline: none;
    background: var(--surface);
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-soft);
  }
  .uw-edit-field::placeholder { color: var(--muted); font-weight: 400; }
  .uw-edit-toggle:checked ~ table .uw-display { display: none; }
  .uw-edit-toggle:checked ~ table .uw-edit-field { display: inline-block; }
  /* Sits inline within a row's own label text ("Down payment (20.0%, manually entered)") rather
     than the value column .uw-edit-field normally lives in — narrower fixed-width fallback (a
     rate is at most a few digits, never needs 90px) and centered rather than right-aligned, which
     only made sense lined up against a column of dollar amounts. */
  .uw-edit-field-inline { width: 48px; max-width: 70px; text-align: center; }
  /* Very subtle highlight on any value currently coming from a saved manual override. */
  .uw-edited { background: var(--accent-soft); border-radius: 5px; padding: 1px 6px; margin: -1px -6px; }

  /* Fallback manual-entry form for a researched property with no underwriting yet at all (no
     rent comps, no recorded sale price) — same look as the inline edit fields above, just
     standalone since there's no underwriting table to attach to. */
  .override-fields { margin-top: 10px; }
  .override-fields .filter-row { padding: 8px 0; }
  .override-fields input[type="number"] { width: 130px; }
  .override-actions { display: flex; gap: 10px; margin-top: 10px; }

  /* "Fix data" mode (see listingCorrections.ts): a hidden checkbox toggles every
     .fixdata-display/.fixdata-edit-field pair across the whole detail page via the CSS sibling
     combinator, same trick as the underwriting edit toggle above but scoped to raw listing
     facts (price, beds/baths/sqft, type, year built, HOA) instead of underwriting inputs. */
  .fixdata-edit-toggle { position: absolute; opacity: 0; width: 0; height: 0; }
  .fixdata-btn {
    border: none;
    background: none;
    padding: 2px;
    display: flex;
    color: var(--border-strong);
    cursor: pointer;
  }
  .fixdata-btn:hover { color: var(--warn); }
  .fixdata-edit-toggle:checked ~ * .fixdata-btn { color: var(--warn); }
  .fixdata-save-btn, .fixdata-reset-btn {
    display: none;
    align-items: center;
    padding: 0 12px;
    height: 30px;
    border-radius: 999px;
    font-size: 0.8125rem;
    font-weight: 600;
    cursor: pointer;
  }
  .fixdata-save-btn { border: 1px solid var(--warn); background: var(--warn); color: #ffffff; }
  .fixdata-reset-btn { border: 1px solid var(--border); background: none; color: var(--bad); }
  .fixdata-reset-btn:hover { background: var(--surface-alt); }
  .fixdata-edit-toggle:checked ~ * .fixdata-save-btn,
  .fixdata-edit-toggle:checked ~ * .fixdata-reset-btn { display: inline-flex; }
  /* Own class, not fixdata-reset-btn — that one is display:none by default and only shown inside
     the unrelated "fix data" pencil-icon edit mode (see the toggle rule above), which silently
     hid this button from every normal page view. Same visual treatment, always visible. */
  .reclassify-rental-btn {
    display: inline-flex;
    align-items: center;
    padding: 0 12px;
    height: 30px;
    border-radius: 999px;
    font-size: 0.8125rem;
    font-weight: 600;
    cursor: pointer;
    border: 1px solid var(--border);
    background: none;
    color: var(--bad);
  }
  .reclassify-rental-btn:hover { background: var(--surface-alt); }
  .fixdata-edit-field {
    display: none;
    padding: 4px 8px;
    font-size: 0.8125rem;
    text-align: right;
    border-radius: 6px;
    border: 1px solid var(--warn);
    background: var(--surface);
    color: var(--text);
    font-variant-numeric: tabular-nums;
  }
  .fixdata-edit-field:focus { outline: none; box-shadow: 0 0 0 3px var(--warn-soft); }
  .fixdata-field-sm { width: 70px; }
  .fixdata-edit-toggle:checked ~ * .fixdata-display { display: none; }
  .fixdata-edit-toggle:checked ~ * .fixdata-edit-field { display: inline-block; }
  /* Amber rather than the underwriting override's blue — a distinct visual cue that this number
     was flagged as wrong at the source, not a deliberate what-if assumption. */
  .fixdata-edited { background: var(--warn-soft); border-radius: 5px; padding: 1px 6px; margin: -1px -6px; }

  .summary { color: var(--muted); font-size: 0.8125rem; margin-bottom: 16px; }
  .summary strong { color: var(--text); font-weight: 600; }

  .days-on-market-note { color: var(--muted); font-size: 0.75rem; margin: 8px 0 16px; }

  .segmented-toggle {
    display: inline-flex;
    border: 1px solid var(--border-strong);
    border-radius: 7px;
    overflow: hidden;
    background: var(--surface);
  }
  .segmented-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
  .segmented-toggle label {
    display: inline-flex;
    align-items: center;
    padding: 4px 9px;
    font-size: 0.6875rem;
    font-weight: 600;
    cursor: pointer;
    color: var(--muted);
    line-height: 1.4;
  }
  .segmented-toggle label:not(:first-of-type) { border-left: 1px solid var(--border-strong); }
  #period-monthly:checked ~ label[for="period-monthly"],
  #period-yearly:checked ~ label[for="period-yearly"] {
    background: var(--accent);
    color: #ffffff;
  }

  .schools-filter { display: flex; align-items: center; gap: 6px; font-size: 0.8125rem; font-weight: 600; color: var(--text); cursor: pointer; }
  .schools-filter input { accent-color: var(--accent); width: 15px; height: 15px; }

  /* Full-screen map results page: the map is the page's whole canvas (position:fixed, behind
     everything); the search bar floats over its top edge and the results list is a draggable
     bottom sheet over its bottom edge (JS translates the sheet between a "peek" and "expanded"
     position — see MAP_SCRIPT). body:has(.wrap-fullmap) turns off normal page scroll since all
     scrolling happens inside the sheet instead. */
  body:has(.wrap-fullmap) { overflow: hidden; height: 100vh; }
  .wrap-fullmap { max-width: none; padding: 0; }
  .map-full { position: fixed; inset: 0; z-index: 0; background: var(--surface-alt); }
  #map-canvas { height: 100%; width: 100%; background: var(--surface-alt); }
  .overlay-top {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 40;
    padding: calc(14px + env(safe-area-inset-top)) 16px 0;
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }
  /* The rounded top corner + glass frame live HERE, on the outer sheet, not on the handle or the
     scroll area individually. Earlier attempts gave .sheet-drag-handle AND .sheet-scroll each
     their own independent background/border/radius — two directly-adjacent, separately-rounded
     boxes with no gap between them, which stacks as two competing curves ("look at this mess",
     reported live) instead of one continuous pane. A single frame on the shared ancestor, clipped
     with overflow:hidden, means the rounded top corner is a fixed part of the sheet's own shape —
     always there regardless of scroll position or which card currently sits at the top — with no
     seam for two curves to collide at. */
  .results-sheet {
    position: fixed;
    inset: 0;
    z-index: 30;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    border-radius: 16px 16px 0 0;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
  }
  /* impeccable's layout-transition hook flags animating max-height/padding here (suggesting
     grid-template-rows instead) — tried that first, and it doesn't actually work: tested directly
     (grid-template-rows: 0fr forced via inline !important, transitions disabled entirely) and the
     row settles at ~22px, not 0, even with min-height:0 and overflow:hidden on the sole grid item
     (the standard documented fix for exactly this). That's a real engine limitation, not a
     misconfiguration, so this stays a plain max-height/padding/opacity transition — confirmed to
     genuinely reach 0 height. It's also a single short (200ms) transition triggered only on
     scroll-direction CHANGES (not every scroll tick, see map.js's threshold-gated listener), so
     the actual reflow cost this hook warns about is one discrete event, not a per-frame cost. */
  .sheet-drag-handle {
    flex: 0 0 auto;
    padding: 10px 16px 12px;
    cursor: grab;
    touch-action: none;
    -webkit-user-select: none;
    user-select: none;
    overflow: hidden;
    max-height: 60px;
    /* Opacity only. This used to animate max-height and padding, which meant the scroll container
       below (flex: 1 1 auto) was RESIZED on every frame for 200ms - while a momentum fling was
       still running. Measured: the handle shrinks 36px and the scrollport grows by exactly 36px.
       Chromium re-resolves the snap position through that and stays aligned, but iOS Safari does
       not reliably, which is how a card ends up resting midway right after the summary line hides.
       Collapsing in one atomic layout change removes the ~12-frame window for that to go wrong. */
    transition: opacity 200ms ease;
  }
  /* A real height/padding collapse, not just a transform, so .sheet-scroll (flex: 1 1 auto right
     below it) grows to reclaim most of the freed space instead of leaving a gap where the handle
     used to be — but not ALL the way to 0: collapsing the full strip away left the list's top
     edge landing dead flush against the map with no breathing room, reported live as reading like
     "a complete straight line" cutting across the screen. This keeps a small rounded glass cap
     (the background/border/radius/blur all stay — only the height, and therefore the drag-bar +
     text clipped inside it via the base rule's overflow:hidden, shrinks) so there's still a soft
     edge and a sliver of margin above the list even while scrolled deep into it. padding-top is
     deliberately NOT zeroed here (only padding-bottom is) — the base rule's own 10px top padding
     carries through unchanged, matching .sheet-handle-bar's own 10px margin-bottom, so the pill
     keeps the same breathing room above it collapsed as it has expanded, rather than sitting
     flush against the cap's own top edge. */
  .sheet-drag-handle.handle-collapsed {
    max-height: 20px;
    padding-bottom: 0;
  }
  .sheet-handle-bar { width: 40px; height: 5px; border-radius: 999px; background: var(--border-strong); margin: 0 auto 10px; }
  .sheet-summary { font-size: 0.8125rem; font-weight: 600; color: var(--text); text-align: center; }
  /* No background/border/radius of its own — .results-sheet above owns the one glass frame for
     the whole sheet (handle + this scroll area together), so the map/pins show through the gaps
     around and between cards without this element competing with the handle for a rounded corner.
     Each .deal-card keeps its own solid --surface fill, so the actual listing content stays fully
     readable regardless of what's behind the sheet. */
  /* Mobile only (desktop turns this off below): scroll-snap, not a masking trick, is what
     actually makes "the property preview chip's own rounded corner stays persistent under the
     grab bar" true. .deal-card already rounds and clips its own top corners correctly (see
     .card-photo-viewport's clip-path) — but border-radius only rounds a card's OWN top edge, and
     free-scroll momentum settles wherever it settles, which is usually mid-card, not at that
     edge. Mid-card, the visible boundary is a flat horizontal slice through a photo — correctly
     square, because there's no corner there to round. scroll-snap-type + scroll-snap-align:start
     forces every scroll gesture to settle with SOME card's real top edge exactly at the visible
     boundary, so that card's own already-correct rounded corner is what's always showing there —
     no parent element needs its own competing radius.
     mandatory, not proximity. This was proximity for a while: mandatory had been reported broken
     on the real device for cards carrying .assumptions-note.warn (~526px against a ~755px
     scrollport), because mandatory + -webkit-overflow-scrolling momentum is a documented source of
     stuck snapping in iOS Safari when snap targets vary a lot in height. That condition no longer
     exists — the warning block moved to the property detail page, and cards were measured live on
     a 375x812 viewport at just TWO distinct heights, 302px and 324px: a 22px spread, with the
     tallest card 0.43x the scrollport instead of 0.70x. With near-uniform targets under half the
     viewport there is no tall-item momentum for mandatory to fight.
     Proximity's own cost is what forced this back: it only nudges the rest position when a gesture
     already ends near a snap point, so most flicks settled mid-card — reported live as "lots of
     times it leaves a property midway", which is exactly the mid-card flat photo slice this whole
     mechanism exists to prevent. If the iOS glitch ever returns, the fix is to re-check card
     height spread FIRST; proximity is the fallback, not the goal. */
  .sheet-scroll {
    flex: 1 1 auto;
    overflow-y: auto;
    /* No top padding on mobile. Every snapped rest position puts a card's own top edge exactly at
       the scrollport boundary, but a 16px top padding made the FIRST card the one exception —
       resting 16px lower when scrolled to the very top, so the top of the list was the only place
       the card did not line up with the edge. Zero here makes position 0 a real snap position
       rather than a near-miss, which is what "the top card is always aligned to the top" needs.
       Side and bottom padding stay; desktop puts its own padding-top back below. */
    padding: 0 16px 16px;
    -webkit-overflow-scrolling: touch;
    scroll-snap-type: y mandatory;
  }

  /* ---- Settings: API usage ------------------------------------------------
     A meter, not a chart. One provider with a hard monthly ceiling has exactly two numbers worth
     showing (spent, left), and a bar reads those faster than any plotted series would. Colour is
     the alert channel and carries a text percentage beside it, so the state survives both a
     greyscale render and a red-green colour deficiency. */
  .settings-section { margin-bottom: 28px; }
  .settings-section h2 {
    font-size: 0.82rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--muted);
    margin: 0 0 10px;
  }

  /* One definition for both the Settings usage/rate cards and the property value chart, so a chart
     looks the same wherever it appears rather than two near-identical card styles drifting apart. */
  .usage-card,
  .chart-card {
    background: var(--surface);
    border: 0.5px solid var(--border);
    border-radius: 16px;
    padding: 16px;
  }
  .chart-card { margin: 0 0 10px; }
  .usage-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 10px;
  }
  .usage-name { font-weight: 650; }
  /* Tabular figures stop the count jittering sideways as digits change. */
  .usage-count { font-variant-numeric: tabular-nums; font-weight: 700; }
  .usage-ok { color: var(--text); }
  .usage-warn { color: var(--warn); }
  .usage-critical { color: var(--bad); }

  .usage-track {
    height: 10px;
    border-radius: 999px;
    background: var(--surface-alt);
    overflow: hidden;
  }
  .usage-fill {
    height: 100%;
    border-radius: 999px;
    background: var(--accent);
    /* Width only - the bar is a value readout, so it should land at its number on load rather
       than animate up to it and misreport for the duration. */
    transition: none;
  }
  .usage-fill-warn { background: var(--warn); }
  .usage-fill-critical { background: var(--bad); }

  .usage-stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
    gap: 12px;
    margin: 14px 0 0;
  }
  .usage-stats dt {
    font-size: 0.76rem;
    color: var(--muted);
    margin-bottom: 2px;
  }
  .usage-stats dd {
    margin: 0;
    font-weight: 650;
    font-variant-numeric: tabular-nums;
  }

  /* ---- Settings: mortgage rate history -----------------------------------
     One series, so no legend and no colour-coding to decode — the heading names it. Grid and
     axis text stay recessive so the line is the only thing with visual weight. */
  .rate-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
  .rate-now { font-size: 1.9rem; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
  .rate-asof { font-size: 0.78rem; color: var(--muted); margin-top: 2px; }
  .rate-change { font-size: 0.82rem; font-weight: 650; color: var(--text); margin-top: 10px; }
  /* Direction is carried by the arrow and the sign in the markup; colour only reinforces it, so
     this stays legible in greyscale and for a red/green colour deficiency. */
  .rate-change.rate-up { color: var(--bad); }
  .rate-change.rate-down { color: var(--good); }
  .rate-change-value { font-variant-numeric: tabular-nums; }
  .rate-change-note { font-weight: 500; color: var(--muted); font-size: 0.74rem; margin-left: 6px; }

  /* Zoom control. Radios live outside the visible chips (the label drives them via `for`), so the
     group is a real radio group — arrow-key navigable and announced as one choice — while the
     chips reuse .chip-radio's exact look so this reads as the same control as the map-mode
     switch rather than a second, similar-but-different one. */
  .rate-range-input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
  /* Six chips do not fit a 320px phone in one row, and wrapping them into a ragged second line
     reads as two controls. Scroll instead, so the group stays one visual row at any width. */
  .rate-zoom-controls {
    display: flex;
    flex-wrap: nowrap;
    gap: 6px;
    margin: 12px 0 2px;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .rate-zoom-controls::-webkit-scrollbar { display: none; }
  .rate-zoom-chip {
    display: inline-flex;
    align-items: center;
    padding: 6px 12px;
    border-radius: 999px;
    border: 1px solid var(--border-strong);
    background: var(--surface);
    color: var(--muted);
    font-size: 0.78rem;
    font-weight: 650;
    cursor: pointer;
    white-space: nowrap;
    -webkit-user-select: none;
    user-select: none;
  }
  /* Only the checked range's panel is in the layout; the rest are rendered but not displayed, so
     switching costs no request and no re-render. */
  .rate-panel { display: none; }
  #rate-range-6m:checked ~ .rate-zoom-controls label[for="rate-range-6m"],
  #rate-range-1y:checked ~ .rate-zoom-controls label[for="rate-range-1y"],
  #rate-range-2y:checked ~ .rate-zoom-controls label[for="rate-range-2y"],
  #rate-range-5y:checked ~ .rate-zoom-controls label[for="rate-range-5y"],
  #rate-range-10y:checked ~ .rate-zoom-controls label[for="rate-range-10y"],
  #rate-range-max:checked ~ .rate-zoom-controls label[for="rate-range-max"] {
    background: var(--accent);
    border-color: var(--accent);
    color: #fff;
  }
  /* Keyboard focus has to be visible on the LABEL, since the input it belongs to is off-screen. */
  #rate-range-6m:focus-visible ~ .rate-zoom-controls label[for="rate-range-6m"],
  #rate-range-1y:focus-visible ~ .rate-zoom-controls label[for="rate-range-1y"],
  #rate-range-2y:focus-visible ~ .rate-zoom-controls label[for="rate-range-2y"],
  #rate-range-5y:focus-visible ~ .rate-zoom-controls label[for="rate-range-5y"],
  #rate-range-10y:focus-visible ~ .rate-zoom-controls label[for="rate-range-10y"],
  #rate-range-max:focus-visible ~ .rate-zoom-controls label[for="rate-range-max"] {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  #rate-range-6m:checked ~ .rate-panels .rate-panel-6m,
  #rate-range-1y:checked ~ .rate-panels .rate-panel-1y,
  #rate-range-2y:checked ~ .rate-panels .rate-panel-2y,
  #rate-range-5y:checked ~ .rate-panels .rate-panel-5y,
  #rate-range-10y:checked ~ .rate-panels .rate-panel-10y,
  #rate-range-max:checked ~ .rate-panels .rate-panel-max {
    display: block;
  }

  .rate-table-note { font-size: 0.74rem; color: var(--muted); margin: 8px 0 0; }

  /* Axes. Recessive by design: a reference to read the data against, never competing with it.
     The gridlines are lighter than the axis baseline, which is lighter than any mark. */
  .axis-grid { stroke: var(--border); stroke-width: 1; opacity: 0.55; }
  .axis-line { stroke: var(--border-strong); stroke-width: 1; }
  .axis-tick { stroke: var(--border-strong); stroke-width: 1; }

  /* The value chart's own zoom panels — same mechanism as the rate chart, separate id namespace. */
  #value-range-6m:checked ~ .rate-zoom-controls label[for="value-range-6m"],
  #value-range-1y:checked ~ .rate-zoom-controls label[for="value-range-1y"],
  #value-range-2y:checked ~ .rate-zoom-controls label[for="value-range-2y"],
  #value-range-max:checked ~ .rate-zoom-controls label[for="value-range-max"] {
    background: var(--accent);
    border-color: var(--accent);
    color: #fff;
  }
  #value-range-6m:focus-visible ~ .rate-zoom-controls label[for="value-range-6m"],
  #value-range-1y:focus-visible ~ .rate-zoom-controls label[for="value-range-1y"],
  #value-range-2y:focus-visible ~ .rate-zoom-controls label[for="value-range-2y"],
  #value-range-max:focus-visible ~ .rate-zoom-controls label[for="value-range-max"] {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  #value-range-6m:checked ~ .rate-panels .value-panel-6m,
  #value-range-1y:checked ~ .rate-panels .value-panel-1y,
  #value-range-2y:checked ~ .rate-panels .value-panel-2y,
  #value-range-max:checked ~ .rate-panels .value-panel-max {
    display: block;
  }
  /* Chips first, chart under them — the control reads as belonging to the chart it scopes. */
  .value-zoom .rate-zoom-controls { margin-top: 0; }
  .rate-table-note a { color: var(--accent); }

  .rate-figure { margin: 10px 0 0; }
  /* Readout sits above the plot and reserves its own line, so revealing a value never reflows the
     chart underneath the finger that asked for it. */
  .rate-readout {
    min-height: 1.25rem;
    display: flex;
    align-items: baseline;
    gap: 8px;
    font-size: 0.82rem;
  }
  /* Value leads, label follows — the reader has the series and wants the number. */
  .rate-readout-value { font-size: 1rem; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text); }
  .rate-readout-label { color: var(--muted); }

  /* pan-y, not none: a vertical swipe still scrolls the page, while a horizontal drag scrubs.
     touch-action: none here would trap the finger and make the section impossible to scroll past. */
  .rate-chart { touch-action: pan-y; }
  .rate-crosshair,
  .rate-scrub-dot { opacity: 0; pointer-events: none; }
  .rate-crosshair { stroke: var(--border-strong); stroke-width: 1; }
  .rate-scrub-dot { fill: var(--chart-series); stroke: var(--surface); stroke-width: 2; }
  .rate-chart.is-scrubbing .rate-crosshair,
  .rate-chart.is-scrubbing .rate-scrub-dot { opacity: 1; }
  .rate-chart:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
  .rate-chart { display: block; width: 100%; height: auto; overflow: visible; }
  .rate-line { fill: none; stroke: var(--chart-series); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
  .rate-grid { stroke: var(--border); stroke-width: 1; }
  .rate-dot { fill: var(--chart-series); }
  /* A surface-coloured ring separates the current marker from the line passing under it. */
  .rate-dot-current { stroke: var(--surface); stroke-width: 2; }
  .rate-label { fill: var(--muted); font-size: 11px; font-weight: 600; }
  .rate-caption {
    display: flex;
    justify-content: space-between;
    gap: 8px;
    font-size: 0.72rem;
    color: var(--muted);
    margin-top: 4px;
  }

  .rate-table-toggle { margin-top: 12px; }
  .rate-table-toggle > summary { cursor: pointer; font-size: 0.8rem; color: var(--muted); font-weight: 600; }
  /* Capped and scrollable: 52 rows would otherwise push everything below it off the page. */
  .rate-table-scroll { max-height: 220px; overflow: auto; margin-top: 8px; }
  .rate-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
  .rate-table th, .rate-table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--border); }
  .rate-table td:last-child, .rate-table th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
  .rate-table thead th { position: sticky; top: 0; background: var(--surface); color: var(--muted); font-weight: 600; }

  /* ---- Property value + listing history ----------------------------------
     Two series, one axis, distinguished by mark type as well as hue: a continuous line for the
     county's annual assessed value, discrete dots for the moments a price was named. Same mark
     specs as the rate chart (2px line, recessive grid, selective labels). */
  .value-figure { margin: 0 0 8px; }
  /* The chart fills whatever width it is given, and none of its TEXT scales — because none of its
     text is in the SVG.
     An SVG scales its entire user space, so a 640-unit viewBox rendered at 1266px magnified a 10px
     label to 27px, and at 306px shrank it to 6px: the repeated "the numbers change with the window
     size" report. Two earlier attempts failed. Capping the width fixed the type but wasted the
     space. Counter-scaling the SVG font-size from JS was correct arithmetically but depended on a
     ResizeObserver that stopped delivering — writing font-size on the observed element made the
     callback perturb its own observed box, and the browser silently drops notifications rather than
     looping, so the size went stale at some widths and not others.
     So the labels are HTML positioned over the plot instead. They take rem straight from the app's
     type scale, which is also what "the numbers should match the numbers all around the app"
     asks for, and there is no mechanism left that could get it wrong. Strokes stay in the SVG and
     hold their weight through vector-effect: non-scaling-stroke. */
  .value-plot { position: relative; }
  .value-chart {
    display: block;
    width: 100%;
    height: auto;
    overflow: visible;
    touch-action: pan-y;
  }
  /* Pointer-transparent so the whole plot stays scrubbable underneath the labels. */
  .value-labels { position: absolute; inset: 0; pointer-events: none; }
  .vl {
    position: absolute;
    font-size: 0.7rem;
    font-weight: 600;
    color: var(--muted);
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
    line-height: 1;
  }
  /* Y values sit just outside the plot on the right, vertically centred on their gridline. */
  .vl-y { right: 0; transform: translateY(-50%); }
  /* X dates hang below the baseline, centred on their tick, with the outermost two pulled inward
     so neither can overhang the card edge. */
  .vl-x { bottom: 0; transform: translateX(-50%); }
  .vl-x-first { transform: none; }
  .vl-x-last { transform: translateX(-100%); }
  .vl-value { color: var(--chart-series); font-weight: 700; transform: translate(-50%, -100%); }
  .vl-listing { color: var(--chart-series-2); font-weight: 700; transform: translate(-50%, -100%); }
  /* A label on the newest point would otherwise run past the plot into the y-axis column. */
  .vl-end { transform: translate(-100%, -100%); }
  /* Strokes keep their designed weight at any scale — so scaling DOWN on a phone does not thin the
     median line and gridlines into invisibility either. */
  .value-band,
  .value-line,
  .listing-line,
  .axis-grid,
  .axis-line,
  .axis-tick,
  .rate-crosshair,
  .rate-scrub-dot,
  .listing-dot { vector-effect: non-scaling-stroke; }
  .value-chart:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }

  /* Primary series: the value line. */
  /* The band is the uncertainty; the line through it is the median. Deliberately low-alpha so the
     listing dots and the median line both stay legible on top of it. */
  .value-band { fill: color-mix(in srgb, var(--chart-series) 20%, transparent); stroke: none; }
  .value-line { fill: none; stroke: var(--chart-series); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }

  /* Overlay series: listing events. The connector is faint on purpose — it orders the dots, it
     does not assert a price between them. */
  .listing-line { fill: none; stroke: var(--chart-series-2); stroke-width: 1.25; opacity: 0.4; stroke-linejoin: round; }
  .listing-dot { fill: var(--chart-series-2); }
  /* A sale is a different kind of fact from an ask, so it differs in SIZE and carries a surface
     ring — legible with the colour removed entirely. */
  .listing-dot-sold { stroke: var(--surface); stroke-width: 2; }
  /* The live asking price. Largest of the three markers and double-ringed, because it is the one
     number on this chart a reader is deciding against — everything else is history. */
  .listing-dot-now {
    stroke: var(--surface);
    stroke-width: 2.5;
    filter: drop-shadow(0 0 0 var(--chart-series-2));
  }

  .value-caption {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 8px;
    font-size: 0.72rem;
    color: var(--muted);
    margin-top: 2px;
  }
  .value-key { display: inline-flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
  .value-key-item { display: inline-flex; align-items: center; gap: 4px; }
  /* The key mirrors the mark: a stroke for the line series, dots for the dot series. */
  /* The key mirrors the mark: a filled swatch for the band, dots for the dot series. */
  .value-key-band {
    width: 14px;
    height: 9px;
    border-radius: 2px;
    background: color-mix(in srgb, var(--chart-series) 22%, transparent);
    border-top: 2px solid var(--chart-series);
  }
  .value-key-dot { width: 7px; height: 7px; border-radius: 999px; background: var(--chart-series-2); }
  .value-key-dot-sold { width: 9px; height: 9px; box-shadow: 0 0 0 1.5px var(--surface); }
  .value-key-dot-now { width: 10px; height: 10px; box-shadow: 0 0 0 1.5px var(--surface), 0 0 0 3px color-mix(in srgb, var(--chart-series-2) 35%, transparent); }

  /* Readout gains a unit suffix and an overlay line on this chart. */
  .rate-readout-suffix { color: var(--muted); font-size: 0.78rem; }
  .rate-readout-event { color: var(--chart-series-2); font-weight: 650; }

  .rate-readout-range { color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; }
  .value-provider-toggle { margin: 6px 0 0; }
  .value-provider-toggle > summary { cursor: pointer; font-size: 0.8rem; color: var(--muted); font-weight: 600; }
  .value-provider-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; margin-top: 8px; }
  .value-provider-table th, .value-provider-table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--border); }
  .value-provider-table th:last-child, .value-provider-table td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
  .value-provider-table thead th { color: var(--muted); font-weight: 600; }
  .value-provider-note { font-size: 0.74rem; color: var(--muted); margin: 8px 0 0; }

  /* Desktop: a fixed-width results column beside the map (Zillow's own split view) instead of a
     bottom sheet dragged up over it — a horizontal divider only ever made sense as a mobile
     pattern, where there's no room for map and list side by side. Above this width there is, so
     the map is narrowed by exactly the panel's width (map.js's own visibleMapBounds() already
     degrades correctly to "the whole map div" once sheetCoverY is never set — see the
     DESKTOP_SPLIT_QUERY gate in map.js, keyed to this same breakpoint) rather than the panel
     floating over/covering it. */
  @media (min-width: 900px) {
    .map-full { right: var(--results-panel-width); }
    .overlay-top { right: var(--results-panel-width); }
    /* Solid here, not glass — unlike the mobile sheet (which floats OVER the map, hence the glass
       frame above), the desktop panel is its own column beside the map with the plain page
       background behind it, so there's nothing meaningful for translucency to reveal. Only the
       left edge keeps a border (against the map), and the corner rounding drops entirely — a
       side-by-side column has no floating top edge to round. */
    .results-sheet {
      left: auto;
      width: var(--results-panel-width);
      overflow: visible;
      border-radius: 0;
      background: var(--surface);
      -webkit-backdrop-filter: none;
      backdrop-filter: none;
      border: none;
      border-left: 1px solid var(--border);
      box-shadow: none;
    }
    .sheet-drag-handle { display: none; }
    .sheet-scroll {
      padding-top: 16px;
      /* No grab bar to stay clear of here (square panel, no rounded top edge to protect), and a
         2-column grid at wider widths makes "snap to a row" a needless behavior change — this is
         a mobile-only fix for a mobile-only visual. */
      scroll-snap-type: none;
    }
  }
  /* A wider desktop window gets a proportionally wider panel (not just more empty margin around
     a fixed 420px column) so the card grid below can step up to 2 columns instead of stretching
     one column uncomfortably wide — same "more columns as more room shows up" idea as the mobile
     .deal-grid breakpoints above, just for the panel's own width instead of the full viewport's. */
  @media (min-width: 1280px) {
    :root { --results-panel-width: 640px; }
  }

  .county-fallback { margin-top: 4px; }
  .county-record-card {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 14px 16px;
    margin-bottom: 12px;
    background: var(--surface);
  }
  .county-record-address { font-weight: 700; margin-bottom: 10px; }
  .county-record-card .fact-grid { margin: 0; }

  /* Custom teardrop pin (single-property maps: neighborhood mini/full view) and softened
     chrome, styled to read closer to Apple Maps. */
  .pin { filter: drop-shadow(0 2px 3px rgba(20,18,15,0.35)); }
  .pin path { fill: var(--accent); }
  .pin circle { fill: #ffffff; }
  .pin.school-pin circle:first-of-type { fill: var(--warn); }
  .pin.school-pin circle:last-of-type { fill: #ffffff; }

  /* Results map: price-tag pins (Redfin/Zillow-style ribbon shape, pointed tail at the bottom)
     instead of plain markers — one per property, positioned via an inner-element CSS transform
     (see markerElement/priceChipHtml in map.js) so it never fights MapLibre's own positioning
     transform on the outer marker element it owns. The transform also gives this element its own
     containing block, so the ::after tail can anchor to it with position:absolute without an
     explicit position. */
  .price-pin {
    /* A plain block element sizes to the outer marker wrapper's own (zero) box — display:
       inline-block sizes this to its own text content instead, independent of that. */
    display: inline-block;
    transform: translate(-50%, -100%);
    background: var(--pin-accent);
    color: #ffffff;
    font-size: 0.625rem;
    font-weight: 700;
    line-height: 1.2;
    padding: 3px 6px;
    border-radius: 6px;
    box-shadow: 0 1px 4px rgba(20,18,15,0.3);
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
    transition: background-color 120ms ease-out, box-shadow 120ms ease-out;
  }
  .price-pin::after {
    content: '';
    position: absolute;
    left: 50%;
    bottom: -4px;
    transform: translateX(-50%);
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-top: 4px solid var(--pin-accent);
    transition: border-top-color 120ms ease-out;
  }
  /* A building with several listings shows how many, not one unit's price — a single price would
     claim to speak for 80 different units. Round rather than the price chip's rectangle, so a
     count reads as "a place with several" instead of as a price. */
  .price-pin.building-pin {
    min-width: 22px;
    text-align: center;
    border-radius: 999px;
    padding: 3px 7px;
    font-variant-numeric: tabular-nums;
  }

  /* Unit list inside a building's popup. Capped and scrollable: a tower can hold 80 listings, and
     an 80-row popup would run off the screen with no way back. */
  .map-popup-units {
    display: flex;
    flex-direction: column;
    max-height: 220px;
    overflow-y: auto;
    margin-top: 6px;
    border-top: 1px solid var(--border);
  }
  .map-popup-unit {
    display: grid;
    grid-template-columns: auto 1fr auto;
    gap: 8px;
    align-items: baseline;
    padding: 6px 2px;
    border-bottom: 1px solid var(--border);
    color: var(--text);
    text-decoration: none;
    font-size: 0.75rem;
  }
  .map-popup-unit:last-child { border-bottom: none; }
  .map-popup-unit:hover { background: var(--surface-alt); }
  .map-popup-unit-name { font-weight: 700; white-space: nowrap; }
  .map-popup-unit-meta { color: var(--muted); font-size: 0.7rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .map-popup-unit-price { font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; }

  .pin.pin-highlight .price-pin { background: var(--pin-highlight); box-shadow: 0 4px 10px rgba(20,18,15,0.4); }
  .pin.pin-highlight .price-pin::after { border-top-color: var(--pin-highlight); }

  /* Dataset colouring. Scoped to a class on the map container (map.js sets it from the mapMode in
     the URL), so it applies to every pin the layer renders — badge, its tail, and the dot form —
     without map.js having to know about colours at all.
     The focused/hovered pin (.pin-highlight, the enlarged badge) follows the mode colour too. It
     was briefly left on the shared highlight blue on the theory that "this is the one you're
     pointing at" is a separate axis from which dataset is shown — but in practice the enlarged
     badge is the most prominent thing on the map, so leaving it blue in Sold mode read as a
     for-sale pin sitting among sold ones. Size already communicates focus; colour communicates
     dataset, consistently. */
  .map-mode-rent .price-pin { background: var(--pin-rent); }
  .map-mode-rent .price-pin::after { border-top-color: var(--pin-rent); }
  .map-mode-rent .cluster-dot { background: var(--pin-rent); }

  /* Dark ink on yellow — white at 0.625rem/700 on #e0a300 is unreadable. */
  .map-mode-sold .price-pin { background: var(--pin-sold); color: var(--text); }
  .map-mode-sold .price-pin::after { border-top-color: var(--pin-sold); }
  .map-mode-sold .cluster-dot { background: var(--pin-sold); }

  /* The enlarged/focused badge keeps the mode's colour rather than reverting to highlight blue. */
  .map-mode-rent .pin.pin-highlight .price-pin { background: var(--pin-rent); }
  .map-mode-rent .pin.pin-highlight .price-pin::after { border-top-color: var(--pin-rent); }
  .map-mode-sold .pin.pin-highlight .price-pin { background: var(--pin-sold); color: var(--text); }
  .map-mode-sold .pin.pin-highlight .price-pin::after { border-top-color: var(--pin-sold); }
  /* Hovering a card in the results list — and, on the desktop split view, whichever card is
     clipped to the top of the results column — enlarges that property's pin so it's unmistakable
     which of a few dozen pins is meant (map.js also forces the badge form and un-hides the pin
     for the duration — see updatePinFocus). 1.5x, halved from an initial 3x, which read as
     oversized on a desktop map where the badge is already legible at rest.

     Scaled on .pin (the marker's wrapper), NOT on .price-pin itself, and this matters: .price-pin
     carries its own `transform: translate(-50%,-100%)` to anchor its bottom-center to the marker
     point, and the individual `scale` property is applied BEFORE `transform` in the CSS Transforms
     Level 2 order (translate → rotate → scale → transform). Putting both on one element therefore
     resolves that -50%/-100% offset in the already-scaled coordinate system, multiplying the
     anchoring offset by the scale factor and dragging the badge up and left off the spot it marks
     — reported live as the enlarged badge "not pointing exactly where the property dot is".
     .pin has no transform of its own (price markers are built with a zero anchor offset — see
     addMarker in map.js), so scaling it composes cleanly instead.

     The origin is the tail's tip, not the wrapper's corner: .pin's own 0,0 IS the geographic
     point, but the ::after tail hangs 4px below the badge's bottom edge, so the tip a reader sees
     as "the spot" sits at y=4px. Growing from there keeps that tip pinned in place. */
  .pin.pin-hover-zoom {
    scale: 1.5;
    transform-origin: 0 4px;
    transition: scale 140ms ease-out;
  }
  /* A focused pin shows its price badge AND keeps the dot that marks the exact spot (see
     updatePinFocus in map.js). The dot is normally the marker's only child and sits in normal
     flow; with the badge alongside it, it has to be taken out of flow and pinned back to the
     marker's own origin — .pin's 0,0 IS the geographic point — or it would stack below the badge
     instead of sitting under its tail. Its own translate(-50%,-50%) then centers it on that
     point, exactly as it does unfocused.

     The badge needs position + z-index to sit ON TOP of that dot. Painting order, not geometry:
     the dot is positioned and the badge is not, so by default the dot paints over the badge and
     swallows the bottom ~11px of it including the whole tail — the badge is what carries the
     price, so it belongs in front. */
  .pin.pin-hover-zoom { position: relative; }
  /* Counter-scaled so the dot stays its normal size while the badge above it grows: the scale
     that enlarges the badge lives on .pin (see above), so every child inherits it, and a dot that
     grows with the badge stops reading as a precise point. 0.6667 is 1/1.5 — the exact inverse of
     the wrapper scale just above, so the dot lands back at its natural 14px. Both are literals
     rather than one shared custom property: `scale: var(--x)` is silently dropped when it comes
     from a stylesheet in this engine (the custom property itself still resolves, so the bug
     presents as the enlargement simply not happening, with no error) — confirmed by measuring
     computed `scale` as "none" while the same var read back correctly on the same element. If you
     change one of these, change the other.

     Centering switches from transform: translate(-50%,-50%) to a static -7px margin (half the
     dot's fixed 14px) for the same reason the badge's scale had to move off .price-pin: `scale`
     applies before `transform`, so a percentage translate on a scaled element resolves in scaled
     coordinates and the centering offset comes out wrong. A fixed margin is immune to that. */
  .pin.pin-hover-zoom .cluster-dot {
    position: absolute;
    left: 0;
    top: 0;
    z-index: 1;
    transform: none;
    margin: -7px 0 0 -7px;
    scale: 0.6667;
  }
  .pin.pin-hover-zoom .price-pin { pointer-events: none; position: relative; z-index: 2; }
  /* Zoomed-out stand-in for a price badge (see DOT_ZOOM in MAP_SCRIPT) — every property still
     gets its own marker, never merged with neighbors; this is just what that same marker's icon
     looks like below the zoom level where price text would overlap illegibly. Centered on the
     point (not anchored above it) since a dot doesn't need to "point" the way a tag does. */
  .cluster-dot {
    transform: translate(-50%, -50%);
    width: 14px;
    height: 14px;
    border-radius: 999px;
    background: var(--pin-accent);
    border: 2px solid #ffffff;
    box-shadow: 0 1px 4px rgba(20,18,15,0.35);
  }
  .pin.pin-highlight .cluster-dot { background: var(--pin-highlight); }
  /* "You are here" marker (see USER_LOCATION_HTML in map.js) — a solid dot with a pulsing ring
     around it, the familiar convention for the device's own position, distinguishing it from
     every property pin (which use the app's navy accent, not this blue). position:relative here
     is what the three children's own position:absolute + left/top:50% anchor against — MapLibre
     positions the OUTER marker wrapper it owns, not this inner element, so this element needs its
     own explicit containing block rather than inheriting one implicitly. */
  .user-location-marker { position: relative; }
  .user-location-dot {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 14px;
    height: 14px;
    border-radius: 999px;
    background: var(--pin-highlight);
    border: 2px solid #ffffff;
    box-shadow: 0 1px 4px rgba(20,18,15,0.35);
  }
  .user-location-pulse {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 14px;
    height: 14px;
    border-radius: 999px;
    background: var(--pin-highlight);
    transform: translate(-50%, -50%) scale(0.6);
    opacity: 0.55;
    animation: user-location-pulse 2.2s ease-out infinite;
  }
  @keyframes user-location-pulse {
    0% { transform: translate(-50%, -50%) scale(0.6); opacity: 0.55; }
    70% { transform: translate(-50%, -50%) scale(2.6); opacity: 0; }
    100% { transform: translate(-50%, -50%) scale(2.6); opacity: 0; }
  }
  @media (prefers-reduced-motion: reduce) {
    .user-location-pulse { animation: none; opacity: 0; }
  }
  .user-location-chip {
    position: absolute;
    left: 50%;
    bottom: 100%;
    transform: translate(-50%, -6px);
    white-space: nowrap;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
    color: var(--text);
    font-size: 0.6875rem;
    font-weight: 600;
    padding: 3px 9px;
    border-radius: 999px;
  }
  /* Above every marker tier (Z_DOT/Z_BADGE/Z_FOCUS in map.js, 1-3). Markers carry explicit
     z-index values so price badges layer over dots, and without a higher value here those pins
     paint straight through an open property preview — dots showing over the popup's photo and
     text, reported live. */
  .maplibregl-popup { z-index: 10; }
  .maplibregl-popup-content { border-radius: 14px; box-shadow: 0 12px 28px rgba(20,18,15,0.18); font-family: inherit; color: var(--text); font-size: 0.8438rem; margin: 12px 14px; }
  .maplibregl-popup-tip { display: none; }
  .maplibregl-ctrl-attrib { background: rgba(255,255,255,0.8) !important; color: var(--muted) !important; font-size: 0.6875rem !important; }
  .map-popup { width: 220px; }
  .map-popup-photo {
    display: block;
    width: 100%;
    height: 120px;
    object-fit: cover;
    border-radius: 10px;
    margin: -12px -14px 8px;
    /* .maplibregl-popup-content has its own 12px/14px margin (see above), which would otherwise
       leave an awkward gap around the photo on all four sides — pulling it out to the wrapper's
       own edge on top/left/right reads as a real card image, not a thumbnail floating inside
       padding. Only the bottom keeps normal spacing before the price line. */
    width: calc(100% + 28px);
  }
  .map-popup strong.map-popup-price { display: block; margin-bottom: 2px; font-size: 0.9375rem; }
  .map-popup-meta { color: var(--muted); font-size: 0.75rem; margin-bottom: 4px; }
  .map-popup-address { color: var(--muted); font-size: 0.75rem; margin-bottom: 6px; }
  .map-popup a { font-size: 0.8125rem; }

  .neighborhood-map-wrap {
    position: relative;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    overflow: hidden;
    margin-top: 14px;
    margin-bottom: 20px;
    cursor: pointer;
  }
  #neighborhood-mini-map { height: 200px; width: 100%; background: var(--surface-alt); }
  .street-view-preview {
    position: absolute;
    top: 8px;
    left: 8px;
    width: 96px;
    height: 72px;
    object-fit: cover;
    border-radius: 8px;
    border: 2px solid #fff;
    box-shadow: 0 1px 4px rgba(20,18,15,0.25);
    z-index: 1;
  }
  .map-click-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: flex-end;
    justify-content: flex-end;
    padding: 10px;
  }
  .map-click-overlay span {
    background: rgba(255,255,255,0.94);
    color: var(--text);
    font-size: 0.75rem;
    font-weight: 600;
    padding: 5px 11px;
    border-radius: 999px;
    box-shadow: 0 1px 4px rgba(20,18,15,0.2);
  }
  /* True full-screen, matching the persistent full-screen map on the main search page — this is
     the user's own explicit ask ("let me view it on full screen"), not a modal-sized preview.
     `inset:0; margin:0` overrides the <dialog> UA stylesheet's own auto-centering, which otherwise
     fights a 100vw/100vh size. display:flex column so the map fills whatever space the header/
     filter bar (fixed height) don't take, instead of a hardcoded map height fighting the viewport. */
  dialog.map-dialog {
    inset: 0;
    margin: 0;
    width: 100vw;
    height: 100vh;
    max-width: 100vw;
    max-height: 100vh;
    border: none;
    border-radius: 0;
    background: var(--surface);
    color: var(--text);
    padding: 0;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  /* An author-stylesheet `display` on the element itself outranks the UA stylesheet's own
     `dialog:not([open]){display:none}` default (author origin beats user-agent origin regardless
     of specificity), so the unconditional `display:flex` above would leave the dialog visible even
     while closed without this explicit override. */
  dialog.map-dialog:not([open]) { display: none; }
  dialog.map-dialog::backdrop { background: rgba(20,18,15,0.45); }
  /* This dialog is true fullscreen (inset:0, 100vh — see dialog.map-dialog above), so its header
     sits right at the physical top edge of the screen — padding-top needs the safe-area inset
     added in, same as .overlay-top on the main map page, or the Close button lands under the
     phone's status bar/notch and is unreachable (reported live on mobile: couldn't tap Close on
     the neighborhood map). */
  .map-dialog-header { display: flex; align-items: center; justify-content: space-between; padding: calc(14px + env(safe-area-inset-top)) 18px 14px; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
  .map-dialog-header h3 { margin: 0; font-size: 1.0625rem; font-weight: 700; }
  #neighborhood-full-map { flex: 1 1 auto; width: 100%; min-height: 0; }
  .map-dialog-filters { display: flex; align-items: center; gap: 8px; padding: 10px 18px; border-top: 1px solid var(--border); font-size: 0.8125rem; flex: 0 0 auto; }

  .deal-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 14px;
  }
  @media (min-width: 720px) {
    .deal-grid { grid-template-columns: repeat(2, 1fr); }
  }
  @media (min-width: 1080px) {
    .deal-grid { grid-template-columns: repeat(3, 1fr); }
  }
  /* Wins over both rules above by appearing after them at equal specificity (same reason it can't
     just live inside the split-view's own `min-width: 900px` block further up — a >=1080px window
     matches that AND the 3-column rule directly above, and source order decides the tie). Scoped
     to .results-sheet specifically — Favorites/My Homes reuse .deal-grid too, in the page's full
     ~1140px .wrap, with no side panel narrowing anything there, so THEIR grid should keep the
     normal 2/3-column behavior above unchanged. */
  @media (min-width: 900px) {
    .results-sheet .deal-grid { grid-template-columns: 1fr; }
  }
  /* Matches the panel widening to 640px at this same breakpoint (see --results-panel-width
     above) — appears after the 900px rule so it wins the tie at >=1280px, same reasoning as that
     rule winning over the mobile 2/3-column ones. */
  @media (min-width: 1280px) {
    .results-sheet .deal-grid { grid-template-columns: repeat(2, 1fr); }
  }

  .saved-no-underwriting { margin-top: 20px; }
  .saved-no-underwriting-list { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
  .saved-no-underwriting-list a { color: var(--accent); font-weight: 600; }

  .deal-card {
    position: relative;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    /* The full-bleed card photo already self-clips to its own matching top-corner radius (see
       .card-photo-viewport), but that relies on it precisely tracking the card's own edges via a
       negative margin — overflow:hidden here is the backstop that guarantees nothing (that photo,
       or anything added later) can ever visually poke a square corner out past this card's own
       rounded shape, rather than depending on every child getting its own clip exactly right.
       clip-path is the actual enforcement, not overflow:hidden: .photo-track (two levels down,
       inside .card-photo-viewport) gets `transform: translateX()` for the swipe gallery, which
       promotes it to its own composited layer — and a composited descendant's pixels can render
       outside an ancestor's overflow:hidden + border-radius clip in this engine (confirmed live:
       elementFromPoint at the card's own top-left corner, well inside the 10px radius, hit the
       photo-track directly with no clip). clip-path doesn't have that gap — it clips composited
       layers correctly — so it's what's actually doing the work; overflow:hidden stays as the
       backstop for anything non-composited. */
    overflow: hidden;
    clip-path: inset(0 round var(--radius));
    padding: 16px;
    display: flex;
    flex-direction: column;
    gap: 9px;
    transition: box-shadow 150ms ease-out, border-color 150ms ease-out;
    /* A grid item's default min-width is auto, which for a card containing an <img> can resolve
       to that image's real INTRINSIC pixel width rather than 0 — so once card photos were
       upgraded to full-resolution "od" originals (some 1500px+ wide), a card whose photo happened
       to be unusually large pushed its own 1fr column wider than its siblings', breaking the
       "all columns equal" grid on desktop (reported live, screenshot showed wildly uneven card
       widths). min-width: 0 lets the card shrink to its actual grid-cell width regardless of any
       image inside it — a longstanding, well-known CSS Grid/Flexbox gotcha, not a layout redesign. */
    min-width: 0;
    /* Pairs with .sheet-scroll's scroll-snap-type: y mandatory (mobile only) — see the comment
       there. This is what makes the card actually settle at the snap point instead of just
       being snap-eligible. */
    scroll-snap-align: start;
  }
  .deal-card:hover { border-color: var(--border-strong); box-shadow: 0 4px 14px rgba(32,30,26,0.08); }
  .deal-card .address { font-weight: 600; font-size: 0.9375rem; padding-right: 52px; }
  .deal-card .subline { color: var(--muted); font-size: 0.8125rem; }
  .deal-card .price-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
  .deal-card .price { font-size: 1.1875rem; font-weight: 700; font-variant-numeric: tabular-nums; }
  .deal-card .price-hoa { font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
  .card-actions { position: absolute; top: 14px; right: 14px; z-index: 5; display: flex; gap: 6px; }
  .detail-header-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
  .detail-header-title h1 { flex: 1 1 auto; }
  /* One row: back link on the left, property actions on the right. Previously these were two
     stacked rows of chrome above the address, because the back link was emitted by the route and
     the actions were nested beside the h1. */
  .detail-topbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 18px;
  }
  /* The back link carries its own margin for the pages that still render it standalone
     (Favorites, My Homes, error pages); inside the topbar the row owns the spacing. */
  .detail-topbar .back-link { margin-bottom: 0; }
  .detail-header-actions { display: flex; gap: 6px; flex: 0 0 auto; }
  /* Glass pills, matching the back link they now sit beside and the card actions elsewhere. These
     sit on the plain page background rather than a photo, so the icon keeps --text: there's no
     unpredictable imagery behind them to fight, and --border-strong read as a faint outline that
     was easy to miss entirely. */
  .detail-header-actions > .favorite-btn,
  .detail-header-actions > .mine-btn,
  .detail-header-actions > .hide-btn,
  .detail-header-actions > .fixdata-btn {
    width: 32px;
    height: 32px;
    align-items: center;
    justify-content: center;
    border-radius: 999px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
    color: var(--text);
    cursor: pointer;
  }
  .detail-header-actions > .fixdata-btn { display: flex; }
  /* Dim, don't move — the shared rule for every glass surface in this app. */
  .detail-header-actions > .favorite-btn:hover,
  .detail-header-actions > .mine-btn:hover,
  .detail-header-actions > .hide-btn:hover,
  .detail-header-actions > .fixdata-btn:hover { opacity: 0.7; }
  .favorite-btn, .mine-btn, .hide-btn {
    border: none;
    background: none;
    padding: 2px;
    display: flex;
    color: var(--border-strong);
    cursor: pointer;
  }
  .favorite-btn:hover { color: var(--bad); }
  .favorite-btn.favorited { color: var(--bad); }
  .favorite-btn.favorited svg path { fill: currentColor; }
  .mine-btn:hover { color: var(--accent); }
  .mine-btn.marked { color: var(--accent); }
  .mine-btn.marked svg path { fill: var(--accent-soft); }
  .hide-btn:hover { color: var(--text); }
  /* .card-actions only (not .detail-header-actions, which sits in the plain page header, not on
     a photo) — these float directly over the card's own photo, and a photo's content is
     unpredictable (bright sky, dark interior), so var(--border-strong) with no backdrop reads as
     a barely-visible thin outline on plenty of real listing photos.

     Liquid glass, per the app's own convention: all four properties together — translucent fill,
     blur WITH saturate (without it the backdrop goes muddy grey), a hairline light border, and a
     soft shadow. Chrome-tier 16px blur, matching the search pill and nav.

     The icon colour INVERTS to dark here, which is the whole trick: --glass-bg is a near-white
     fill, so the white icon that worked on the old dark scrim would disappear into it on a bright
     photo. Dark ink on light glass reads on any backdrop — a bright sky and a dark interior both
     end up behind the same pale pane. */
  .card-actions .favorite-btn,
  .card-actions .hide-btn {
    width: 28px;
    height: 28px;
    align-items: center;
    justify-content: center;
    border-radius: 999px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
    color: var(--text);
  }
  /* Press/hover = dim, never move (liquid-glass rule): a transform on a glass surface reads as it
     detaching from the page. */
  .card-actions .favorite-btn:hover,
  .card-actions .hide-btn:hover { opacity: 0.7; }
  /* --bad reads correctly again now the pill behind it is a light glass pane; the bright on-dark
     red this briefly used was only needed while the backdrop was a dark scrim. */
  .card-actions .favorite-btn.favorited { color: var(--bad); }

  /* Tighter than the card's own 9px flex gap: price / est. rent / cash flow read as one compact
     block of figures rather than three loosely-spaced rows, which also buys back enough height
     to fit more cards on screen at once. */
  .deal-card .metric-row { margin-top: -4px; }
  .metric-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; font-size: 0.8438rem; font-variant-numeric: tabular-nums; }
  .metric-row .label { color: var(--muted); font-variant-numeric: normal; }
  /* Keep a value and its trailing badge ("$2,498 medium confidence") on one line — in a narrow
     card column the badge otherwise wrapped onto its own line under the number, which pushed the
     row to double height and broke the alignment across a grid of cards. The label side is the
     one allowed to shrink/ellipsize, since it's the predictable half. */
  .metric-row > :not(.label) { white-space: nowrap; flex: 0 0 auto; }
  .metric-row .label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .cashflow-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 999px; font-weight: 700; }
  .cashflow-pass { background: var(--good-soft); color: var(--good); }
  .cashflow-fail { background: var(--bad-soft); color: var(--bad); }
  .suggested-offer-value { font-weight: 700; color: var(--accent); }
  .assumptions-note.suggested-offer-note { border-top: none; padding-top: 0; margin-top: -4px; color: var(--muted); }

  .badge {
    display: inline-block;
    font-size: 0.6875rem;
    font-weight: 700;
    padding: 2px 8px;
    border-radius: 999px;
    letter-spacing: 0.01em;
  }
  .badge-high { background: var(--good-soft); color: var(--good); }
  .badge-medium { background: var(--warn-soft); color: var(--warn); }
  .badge-low { background: var(--bad-soft); color: var(--bad); }

  .assumptions-note {
    font-size: 0.75rem;
    color: var(--muted);
    border-top: 1px solid var(--border);
    padding-top: 9px;
    margin-top: 3px;
  }
  .assumptions-note.warn { color: var(--warn); border-top-color: var(--warn-soft); }

  .zillow-link {
    font-size: 0.8125rem;
    color: var(--accent);
    font-weight: 600;
    text-decoration: none;
  }
  .zillow-link:hover { color: var(--accent-hover); text-decoration: underline; }
  /* Pending is a warning, not a neutral fact: it means this property can't be bought at the price
     being analysed, so it should catch the eye rather than blend into the grey subline. */
  .status-pending { color: var(--warn); font-weight: 700; }
  /* Same weight as .status-pending so the two read as one system: the status word is always bold
     and coloured, and only the hue says which case you are looking at. --good is the palette's
     existing positive grade (6.2:1 on --surface), not a new green. */
  .status-for-sale { color: var(--good); font-weight: 700; }
  /* A pill, not just coloured text like the status words — these are exceptions to the normal
     case, and an enclosed shape reads as a label rather than as part of the sentence. */
  .distress-badge {
    display: inline-block;
    margin-left: 6px;
    padding: 1px 7px;
    border-radius: 999px;
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.01em;
    background: var(--warn-soft);
    color: var(--warn);
    vertical-align: 1px;
  }
  /* Auction is the louder of the two: its list price is an opening bid, so every figure on the
     page is provisional in a way a plain foreclosure's is not. */
  .distress-badge-auction { background: var(--bad-soft); color: var(--bad); }
  .listing-agent-note { font-size: 0.75rem; color: var(--muted); margin-top: 2px; }

  .address-link { color: var(--text); text-decoration: none; cursor: pointer; display: block; }
  .address-link:hover .address { color: var(--accent); text-decoration: underline; }

  .card-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
  /* Both the real photo viewport and this no-photo fallback bleed to the card's own top/left/
     right edges via a negative margin matching .deal-card's own 16px padding (same "pull the
     image out to the wrapper's real edge" trick as .map-popup-photo elsewhere in this file) —
     only the top two corners are rounded, at the card's OWN radius rather than a reduced one,
     since the photo now sits flush against those edges instead of floating inset within them. */
  .card-thumb-placeholder {
    width: calc(100% + 32px);
    aspect-ratio: 2 / 1;
    border-radius: var(--radius) var(--radius) 0 0;
    margin: -16px -16px 4px;
    background: var(--border);
  }

  .card-photo-viewport {
    position: relative;
    aspect-ratio: 2 / 1;
    border-radius: var(--radius) var(--radius) 0 0;
    overflow: hidden;
    /* Same composited-child clip gap as .deal-card above — .photo-track (the direct grandchild
       carrying the transform for the swipe gallery) needs clip-path, not just overflow:hidden, to
       actually get clipped to this corner. */
    clip-path: inset(0 round var(--radius) var(--radius) 0 0);
    width: calc(100% + 32px);
    margin: -16px -16px 4px;
    background: var(--border);
    /* pan-y, not none — matches .photo-track-viewport below (the property detail page's own
       gallery), which got this right the first time. none was tried here specifically to avoid
       the browser's compositor committing to a vertical scroll the instant it sees ANY vertical
       component in the first touchmove pixels (before photo-gallery.js's own JS decides the
       gesture is actually horizontal), so a horizontal swipe wouldn't cause a slight incidental
       scroll. But none blocks ALL native touch scrolling on this element, not just during an
       active horizontal swipe — so a touch that starts on a card's photo and moves vertically has
       no native scroll to fall back on, and the JS here only drives horizontal drags, leaving
       vertical touches completely dead. Reported live: on mobile, touching a listing's photo to
       scroll the results list down did nothing — only left/right swipes worked. That's a much
       worse regression than the minor scroll wobble pan-y risks during a deliberate horizontal
       swipe, so this reverts to pan-y: the browser handles vertical panning natively (the list
       scrolls normally when a drag starts on a photo), and the touchmove handler below still
       claims the gesture (preventDefault) once it detects a horizontal-dominant drag. */
    touch-action: pan-y;
    user-select: none;
  }
  .card-photo-link { display: block; height: 100%; }
  .card-photo-viewport .photo-track { height: 100%; }
  /* Glass, matching .card-actions above — same four properties, same chrome-tier blur, same dark
     ink for the same reason (a near-white fill would swallow a white glyph on a bright photo). */
  .card-photo-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 28px;
    height: 28px;
    border-radius: 999px;
    border: 0.5px solid var(--glass-border);
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    box-shadow: var(--glass-shadow);
    color: var(--text);
    font-size: 1.125rem;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    opacity: 0;
    pointer-events: none;
    transition: opacity 120ms ease;
    z-index: 2;
  }
  .card-photo-prev { left: 8px; }
  .card-photo-next { right: 8px; }
  /* Hover-reveal is desktop-only on purpose (matches the request) — a touch device has no hover
     state to reveal them with, so pointer-events stays off there and tapping the card just
     navigates to the detail page instead, same as the rest of the card. */
  @media (hover: hover) and (pointer: fine) {
    .card-photo-viewport:hover .card-photo-arrow { opacity: 1; pointer-events: auto; }
  }

  .photo-gallery { display: flex; flex-direction: column; gap: 8px; margin: 4px 0; }

  /* Shared by the inline hero and the lightbox: a fixed-size window over a horizontal .photo-track
     of full-width slides, moved via translateX (see PHOTO_GALLERY_SCRIPT). touch-action: pan-y
     lets the browser keep handling vertical scroll natively while our own JS owns horizontal drag. */
  .photo-track-viewport {
    position: relative;
    overflow: hidden;
    border-radius: var(--radius);
    background: var(--border);
    cursor: grab;
    touch-action: pan-y;
    user-select: none;
  }
  /* height: 100% is load-bearing: without it, a percentage height on the img children (below)
     has nothing definite to resolve against, so each image falls back to its own intrinsic
     aspect ratio scaled to the track's width — a landscape photo then only fills the TOP portion
     of its box, leaving a large blank gap below it (a real bug, reported live on the mobile
     full-screen preview). The ancestor viewport (.photo-gallery-hero / .photo-lightbox-viewport /
     .card-photo-viewport) is what actually establishes a definite height (via aspect-ratio or
     flex:1 auto inside a column flex dialog) for this to inherit. */
  .photo-track { display: flex; height: 100%; }
  .photo-track img { flex: 0 0 100%; width: 100%; object-fit: cover; display: block; pointer-events: none; }

  .photo-gallery-hero { aspect-ratio: 16 / 10; max-height: 420px; }
  .photo-gallery-hero .photo-track img { height: 100%; }

  .photo-counter {
    position: absolute;
    right: 10px;
    bottom: 10px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
    color: var(--text);
    font-size: 0.75rem;
    font-weight: 600;
    padding: 3px 9px;
    border-radius: 999px;
    pointer-events: none;
  }

  .photo-gallery-strip {
    display: flex;
    gap: 8px;
    overflow-x: auto;
    padding-bottom: 4px;
  }
  .photo-gallery-thumb {
    flex: 0 0 auto;
    width: 96px;
    height: 72px;
    border-radius: 8px;
    overflow: hidden;
    background: var(--border);
    border: 2px solid transparent;
    padding: 0;
    cursor: pointer;
  }
  .photo-gallery-thumb.active { border-color: var(--accent); }
  .photo-gallery-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

  /* Full-screen preview — a plain <dialog> (see DIALOG_SCRIPT for the shared open/close wiring)
     so any photo is reachable in one tap via its own thumbnail strip, instead of stepping through
     the set one at a time. */
  /* display is set ONLY on the [open] variant below — a bare display: flex on the un-opened
     dialog.photo-lightbox-dialog selector overrides the UA stylesheet's default display: none for
     a closed <dialog> and makes it render (and its close button sit on top of the inline gallery)
     all the time, not just while actually open. This was a real bug, not a hypothetical one. */
  dialog.photo-lightbox-dialog {
    display: none;
    margin: 0;
    inset: 0;
    width: 100vw;
    height: 100vh;
    max-width: 100vw;
    max-height: 100vh;
    border: none;
    border-radius: 0;
    background: var(--surface);
    color: var(--text);
    padding: 16px;
  }
  dialog.photo-lightbox-dialog[open] { display: flex; flex-direction: column; gap: 10px; }
  dialog.photo-lightbox-dialog::backdrop { background: rgba(10,9,8,0.9); }
  .photo-lightbox-viewport {
    flex: 1 1 auto;
    min-height: 0;
    /* One tone behind the photo, not two. .photo-track-viewport paints var(--border) so a loading
       or short image has something to sit against in the inline strip, but here the dialog itself
       is var(--surface) and the image is object-fit: contain — so that grey showed as a band above
       and below the photo, against white padding on all four sides. Transparent lets the dialog's
       own background carry the whole area. */
    background: transparent;
  }
  .photo-lightbox-viewport .photo-track img { height: 100%; object-fit: contain; }
  /* Same fullscreen-dialog-vs-notch issue as .map-dialog-header: this dialog is inset:0/100vh, so
     a flat top:20px can land under the phone's status bar/notch on devices with a safe-area
     inset, out of reach to tap. */
  .photo-lightbox-close {
    position: absolute;
    top: calc(20px + env(safe-area-inset-top));
    right: 20px;
    z-index: 3;
    width: 32px;
    height: 32px;
    border-radius: 999px;
    border: none;
    background: rgba(20,18,15,0.65);
    color: #fff;
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
  }
  /* Glass, matching the card-photo arrows — same four properties, same chrome-tier blur, and dark
     ink for the same reason: the near-white fill would swallow a white glyph on a bright photo. */
  .photo-nav-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 40px;
    height: 40px;
    border-radius: 999px;
    border: 0.5px solid var(--glass-border);
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    box-shadow: var(--glass-shadow);
    color: var(--text);
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .photo-nav-prev { left: 10px; }
  .photo-nav-next { right: 10px; }
  .photo-lightbox-counter { bottom: 12px; right: 12px; }

  /* A real glass pill rather than a bare text link — it's the primary way back out of a detail or
     saved-list page, and a plain link at the top of a long scrolling page reads as incidental text.
     inline-flex so the padding actually wraps the label; the arrow and text stay one unit. */
  .back-link {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    color: var(--accent);
    text-decoration: none;
    font-size: 0.8438rem;
    font-weight: 600;
    margin-bottom: 18px;
    padding: 7px 14px;
    border-radius: 999px;
    background: var(--glass-bg);
    -webkit-backdrop-filter: blur(16px) saturate(180%);
    backdrop-filter: blur(16px) saturate(180%);
    border: 0.5px solid var(--glass-border);
    box-shadow: var(--glass-shadow);
  }
  /* Dim, don't move — same press/hover rule as every other glass surface here. */
  .back-link:hover { color: var(--accent-hover); opacity: 0.8; }

  .detail-header h1 { font-size: 1.375rem; font-weight: 700; margin: 0 0 3px; letter-spacing: -0.01em; }
  .detail-header .subline { color: var(--muted); font-size: 0.8438rem; margin-bottom: 6px; }

  /* Formerly the swipe-between-properties container (see PHOTO_GALLERY_SCRIPT's header comment
     for why that gesture was retired in favor of swiping through a listing's own photos). Left
     as a plain wrapper — data-prev-href/data-next-href are unused now but harmless to leave. */

  .fact-chips {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 8px;
    margin-bottom: 20px;
  }
  .fact-chip {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 12px;
    background: var(--surface-alt);
    border-radius: 10px;
  }
  .fact-chip-icon { flex: 0 0 auto; color: var(--accent); display: flex; }
  .fact-chip-text { display: flex; flex-direction: column; min-width: 0; }
  .fact-chip-label { font-size: 0.6875rem; color: var(--muted); }
  .fact-chip-value {
    font-size: 0.8125rem;
    font-weight: 600;
    color: var(--text);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .fact-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 10px 18px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 16px;
    margin-bottom: 20px;
    font-size: 0.8438rem;
  }
  @media (min-width: 560px) {
    .fact-grid { grid-template-columns: repeat(3, 1fr); }
  }
  .fact-grid .fact .label { color: var(--muted); font-size: 0.75rem; display: block; margin-bottom: 1px; }

  details.fact-more { margin: -6px 0 20px; }
  details.fact-more summary {
    cursor: pointer;
    list-style: none;
    font-size: 0.8125rem;
    font-weight: 600;
    color: var(--accent);
    padding: 8px 0;
    display: inline-flex;
    align-items: center;
    gap: 7px;
  }
  details.fact-more summary::-webkit-details-marker { display: none; }
  details.fact-more summary:hover { color: var(--accent-hover); }
  details.fact-more summary::before {
    content: "";
    width: 0;
    height: 0;
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-top: 5px solid var(--accent);
    transition: transform 150ms ease-out;
  }
  details.fact-more[open] summary::before { transform: rotate(-180deg); }
  details.fact-more .fact-grid-more { margin-top: 6px; }

  section.detail-section { margin-bottom: 26px; }
  section.detail-section h2 {
    font-size: 1.0625rem;
    font-weight: 700;
    margin: 0 0 12px;
    border-bottom: 1px solid var(--border);
    padding-bottom: 9px;
  }

  /* Monthly is the default view; flip visibility when the Yearly radio is checked. Scoped with
     :has() so it doesn't need the toggle and the values to be adjacent siblings in the DOM. */
  .detail-section .value-yearly { display: none; }
  .detail-section:has(#period-yearly:checked) .value-monthly { display: none; }
  .detail-section:has(#period-yearly:checked) .value-yearly { display: inline; }

  /* Property information / Property features (PropertyDetailsSection) — each MLS `details`
     category keeps its own heading (see the component's own doc comment for why), with its short
     fact/feature strings as a wrapped pill list rather than a plain comma-separated line, so a
     long category (Building and Construction routinely runs 8-10 items) stays scannable instead
     of reading as one dense run-on sentence. */
  .property-detail-group { margin-bottom: 16px; }
  .property-detail-group:last-child { margin-bottom: 0; }
  .property-detail-group h3 {
    font-size: 0.75rem;
    font-weight: 700;
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: 0.02em;
    margin: 0 0 8px;
  }
  .property-detail-group ul {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 6px 8px;
  }
  .property-detail-group li {
    background: var(--surface-alt);
    border: 1px solid var(--border);
    border-radius: 999px;
    padding: 4px 10px;
    font-size: 0.8125rem;
    color: var(--text);
  }

  /* EnrichmentLoader's placeholder (see index.tsx) — pulses opacity only, never a layout property
     (width/height/padding), which would cause reflow/thrash; a plain h2-shaped bar plus a few
     text-shaped bars roughly mimics RealPropertyDataSection's real shape so nothing visibly shifts
     when the real content swaps in. */
  .skeleton-bar {
    height: 14px;
    border-radius: 6px;
    background: var(--border);
    animation: skeleton-pulse 1.4s ease-in-out infinite;
  }
  .skeleton-bar + .skeleton-bar { margin-top: 8px; }
  .skeleton-bar-title { width: 45%; height: 18px; margin-bottom: 14px; }
  .skeleton-bar-short { width: 60%; }
  /* Stands in for a result card's photo on the search page's deferred first load (see
     DealCardSkeleton) — same full-bleed negative margin and 2/1 ratio as .card-photo-viewport, so
     a skeleton card occupies the same box a real one will and the grid doesn't jump on swap. */
  .skeleton-bar-photo {
    aspect-ratio: 2 / 1;
    height: auto;
    width: calc(100% + 32px);
    margin: -16px -16px 4px;
    border-radius: var(--radius) var(--radius) 0 0;
  }
  .deal-card.skeleton-section { gap: 9px; }
  @keyframes skeleton-pulse {
    0%, 100% { opacity: 0.5; }
    50% { opacity: 1; }
  }
  @media (prefers-reduced-motion: reduce) {
    .skeleton-bar { animation: none; opacity: 0.7; }
  }

  table.line-items { width: 100%; border-collapse: collapse; font-size: 0.8438rem; }
  table.line-items td { padding: 8px 4px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
  table.line-items td:last-child { text-align: right; font-weight: 600; }
  table.line-items tr.section-label td {
    color: var(--muted);
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    padding-top: 18px;
    border-bottom: none;
    font-variant-numeric: normal;
  }
  table.line-items tr.section-label:first-child td { padding-top: 4px; }
  table.line-items tr.section-label .segmented-toggle { text-transform: none; letter-spacing: normal; font-weight: 600; }
  table.line-items tr.subtotal td { border-top: 1px solid var(--border-strong); border-bottom: 1px solid var(--border-strong); font-weight: 700; }

  .table-scroll { overflow-x: auto; }
  table.comp-table, table.projection-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; min-width: 560px; }
  table.comp-table td, table.projection-table td { font-variant-numeric: tabular-nums; }
  table.comp-table th, table.comp-table td, table.projection-table th, table.projection-table td {
    padding: 8px 10px;
    border-bottom: 1px solid var(--border);
    text-align: left;
    white-space: nowrap;
  }
  table.comp-table th, table.projection-table th { color: var(--muted); font-weight: 600; font-size: 0.75rem; }
  table.comp-table tbody tr:hover, table.projection-table tbody tr:hover { background: var(--surface-alt); }
  .comp-row-hidden { display: none; }
  .comps-expanded .comp-row-hidden { display: table-row; }
  /* Annual property tax and Listings history (see ExpandableRows in index.tsx) both have exactly
     3 columns but otherwise unrelated content, so plain browser auto-layout sizes each table's
     columns from its own content independently — "Year"/"Assessed value"/"Tax paid" has no reason
     to land on the same widths as "Date"/"Event"/"Price" by coincidence. Fixed, shared
     percentages make the two boxes' columns (and therefore their left/right margins) line up. */
  table.history-table { table-layout: fixed; }
  table.history-table th:nth-child(1), table.history-table td:nth-child(1) { width: 20%; }
  table.history-table th:nth-child(2), table.history-table td:nth-child(2) { width: 40%; }
  table.history-table th:nth-child(3), table.history-table td:nth-child(3) { width: 40%; }
  .show-more-btn {
    display: block;
    margin: 10px 0 0;
    padding: 7px 14px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    color: var(--text);
    font-size: 0.8125rem;
    font-weight: 600;
    cursor: pointer;
  }
  .show-more-btn:hover { background: var(--surface-alt); }

  .pager { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 4px 0 16px; }
  .pager-btn {
    padding: 8px 16px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    color: var(--text);
    font-size: 0.8125rem;
    font-weight: 600;
    text-decoration: none;
  }
  .pager-btn:hover { background: var(--surface-alt); }
  .pager-btn-disabled { color: var(--muted); opacity: 0.5; }
  .pager-status { color: var(--muted); font-size: 0.8125rem; }

  .comp-tabs { display: flex; gap: 6px; margin-bottom: 14px; flex-wrap: nowrap; }
  .comp-tab-btn {
    flex: 1 1 auto;
    min-width: 0;
    padding: 7px 13px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    color: var(--muted);
    font-size: 0.8125rem;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    cursor: pointer;
  }
  @media (max-width: 480px) {
    .comp-tabs { gap: 4px; }
    .comp-tab-btn { padding: 6px 6px; font-size: 0.7188rem; }
  }
  .comp-tab-btn:hover { background: var(--surface-alt); }
  .comp-tab-btn.active { background: var(--text); color: var(--surface); border-color: var(--text); }
  .comp-tab-panel { display: none; }
  .comp-tab-panel.active { display: block; }
  .comp-tab-note { color: var(--muted); font-size: 0.75rem; margin: 0 0 10px; }

  .comp-view-toggle { display: flex; gap: 4px; margin-bottom: 10px; }
  .comp-view-btn {
    padding: 5px 12px;
    border: 1px solid var(--border);
    border-radius: 999px;
    background: var(--surface);
    color: var(--muted);
    font-size: 0.75rem;
    font-weight: 600;
    cursor: pointer;
  }
  .comp-view-btn:hover { background: var(--surface-alt); }
  .comp-view-btn.active { background: var(--accent); color: var(--surface); border-color: var(--accent); }
  .comp-view-map { margin-top: 4px; }
  .comp-map-canvas { height: 280px; border-radius: 12px; overflow: hidden; }

  .comp-address-trigger {
    background: none;
    border: none;
    padding: 0;
    margin: 0;
    font: inherit;
    font-weight: 600;
    color: var(--text);
    cursor: pointer;
    text-align: left;
  }
  .comp-address-trigger:hover { color: var(--accent); text-decoration: underline; }

  .comp-address-link { color: var(--text); font-weight: 600; text-decoration: none; }
  .comp-address-link:hover { color: var(--accent); text-decoration: underline; }

  .projection-summary, .price-stats { display: flex; gap: 24px; flex-wrap: wrap; font-size: 0.8438rem; }
  .projection-summary { margin-bottom: 16px; }
  .price-stats { margin: 10px 0; justify-content: space-between; align-items: center; }
  .quick-facts { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
  .quick-fact {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: 0.8125rem;
    font-weight: 600;
    color: var(--text);
  }
  .quick-fact svg { color: var(--muted); flex: 0 0 auto; }
  .stat .label { color: var(--muted); font-size: 0.75rem; display: block; }
  .stat .value { font-size: 1.1875rem; font-weight: 700; font-variant-numeric: tabular-nums; }
  .stat .value.stat-muted { color: var(--muted); font-weight: 600; font-size: 0.9375rem; }
  .suggested-offer-callout {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 6px 10px;
    padding: 8px 12px;
    margin: 4px 0 10px;
    border-radius: 12px;
    background: var(--accent-soft, var(--good-soft));
    font-size: 0.8125rem;
  }
  .suggested-offer-callout .label { color: var(--muted); font-size: 0.75rem; }
  .suggested-offer-callout .suggested-offer-value { font-size: 1.0625rem; }
  .suggested-offer-callout .suggested-offer-note { color: var(--muted); flex-basis: 100%; }

  .metric-trigger {
    background: none;
    border: none;
    padding: 0;
    margin: 0;
    font: inherit;
    font-weight: 700;
    color: var(--text);
    cursor: pointer;
    text-decoration: underline dotted;
    text-decoration-color: var(--border-strong);
    text-underline-offset: 3px;
    font-variant-numeric: tabular-nums;
  }
  .metric-trigger:hover { color: var(--accent); text-decoration-color: var(--accent); }

  dialog.calc-dialog {
    max-width: 480px;
    width: calc(100% - 32px);
    border: 1px solid var(--border);
    border-radius: 14px;
    background: var(--surface);
    color: var(--text);
    padding: 20px;
    box-shadow: 0 16px 40px rgba(32,30,26,0.22);
  }
  dialog.calc-dialog::backdrop { background: rgba(20,18,15,0.45); }
  dialog.calc-dialog h3 { margin: 0 0 8px; font-size: 1.0625rem; font-weight: 700; }
  .calc-formula {
    font-size: 0.8438rem;
    color: var(--muted);
    font-style: italic;
    margin: 0 0 12px;
  }
  table.calc-steps { width: 100%; border-collapse: collapse; font-size: 0.8438rem; margin-bottom: 12px; }
  table.calc-steps td { padding: 6px 0; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
  table.calc-steps td:last-child { text-align: right; font-weight: 600; }
  .calc-result {
    font-size: 0.9375rem;
    font-weight: 700;
    background: var(--accent-soft);
    color: var(--accent);
    border-radius: 8px;
    padding: 11px 12px;
    margin: 0 0 16px;
    font-variant-numeric: tabular-nums;
  }
  .tag {
    font-size: 0.625rem;
    font-weight: 700;
    padding: 1px 7px;
    border-radius: 999px;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    margin-left: 4px;
  }
  .tag-data { background: var(--accent-soft); color: var(--accent); }
  .tag-comp { background: var(--good-soft); color: var(--good); }
  .tag-assumption { background: var(--warn-soft); color: var(--warn); }
  .tag-derived { background: var(--surface-alt); color: var(--muted); }
  button.dialog-close {
    padding: 9px 18px;
    border-radius: 8px;
    border: 1px solid var(--border-strong);
    background: transparent;
    color: var(--text);
    font-weight: 600;
    cursor: pointer;
  }
  button.dialog-close:hover { background: var(--surface-alt); }

  footer.disclaimer {
    margin-top: 36px;
    padding-top: 18px;
    border-top: 1px solid var(--border);
    color: var(--muted);
    font-size: 0.75rem;
  }
