  :root {
    /* Light, not "light dark": light is the default theme now, so scrollbars and
       form controls must not go dark under a dark OS while the page stays light.
       The dark blocks below say dark, which covers the toggle and the reader who
       picks "systeem" on a dark machine. */
    color-scheme: light;

    --bg: #f4f6fb;
    --surface: #ffffff;
    --surface-2: #f0f4ff;
    --text: #1a1a2e;
    --muted: #6b7280;
    --border: #e5e7eb;

    --accent: #3a5bd9;
    --accent-hover: #2a4bc9;
    --accent-soft: #e4e9ff;

    /* The one ramp that says "score": the chip, the bars and the EPC pill all
       read from it, so a card no longer runs four colour systems at once.
       Darker than the old #2dc653 — that was a colour you had to look twice at
       under .78rem text. */
    --score-hi: #157f3c;
    --score-mid: #b45309;
    --score-lo: #c1121f;
    --on-score: #fff;

    --ok: #1a7f37;
    --warn: #b45309;
    --err: #e63946;

    --radius: 12px;
    --shadow: 0 4px 24px rgba(0, 0, 0, .08);
    --shadow-hover: 0 8px 32px rgba(0, 0, 0, .12);
    /* A "Bekijk op" button is always a third of the row wide, whether the listing
       has one source or four; the gap is a token because the width is measured
       against it. */
    --visit-btn-gap: .5rem;
    /* The dark plate behind the rank badge, the media tabs and the expand button.
       One value, not one per theme: it sits on top of an arbitrary photo, not on
       the page's own chrome, so it has nothing to do with light or dark mode. */
    --media-plate: rgba(26, 26, 46, .72);
  }

  /* Dark values are written twice on purpose: CSS cannot alias a block, and the
     toggle has to beat the OS in both directions. Keep the two lists in step. */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      color-scheme: dark;
      --bg: #12141c;
      --surface: #1b1e29;
      --surface-2: #232735;
      --text: #e8eaf2;
      --muted: #9aa3b8;
      --border: #2e3342;
      --accent: #7d95ff;
      --accent-hover: #93a7ff;
      --accent-soft: #242b45;
      --score-hi: #38d477;
      --score-mid: #f0a94c;
      --score-lo: #ff6b74;
      /* The dark-mode stops are light enough that white text on the chip is
         near-unreadable; flip the ink to a dark tone here instead. */
      --on-score: #12141c;
      --ok: #4ade80;
      --warn: #fbbf24;
      --err: #ff6b74;
      --shadow: 0 4px 24px rgba(0, 0, 0, .5);
      --shadow-hover: 0 8px 32px rgba(0, 0, 0, .6);
    }
  }

  :root[data-theme="dark"] {
    color-scheme: dark;
    --bg: #12141c;
    --surface: #1b1e29;
    --surface-2: #232735;
    --text: #e8eaf2;
    --muted: #9aa3b8;
    --border: #2e3342;
    --accent: #7d95ff;
    --accent-hover: #93a7ff;
    --accent-soft: #242b45;
    --score-hi: #38d477;
    --score-mid: #f0a94c;
    --score-lo: #ff6b74;
    /* The dark-mode stops are light enough that white text on the chip is
       near-unreadable; flip the ink to a dark tone here instead. */
    --on-score: #12141c;
    --ok: #4ade80;
    --warn: #fbbf24;
    --err: #ff6b74;
    --shadow: 0 4px 24px rgba(0, 0, 0, .5);
    --shadow-hover: 0 8px 32px rgba(0, 0, 0, .6);
  }

  /* One tile provider, one URL: dark mode is a filter on the tile layer only,
     never the map container — that would invert the pin and the attribution
     right along with the basemap. Written twice for the same reason the
     custom-property blocks above are: no aliasing a block, keep both in step.
     #map-overlay is Task 12's overlay map; the selector costs nothing here.

     On .leaflet-tile-pane, not .leaflet-tile-container: Leaflet's own CSS puts
     `mix-blend-mode: plus-lighter` on every tile img (a Chromium seam fix), an
     additive blend that only ever lightens against whatever is behind it. A
     filter on the container computed correctly but painted nothing, because
     each container still blended additively against the pane below it. CSS
     gives an element with a filter its own isolated blending group, so the
     filter has to sit on the pane — the ancestor of every tile-container — for
     that additive blend to stay inside the filtered, already-dark result
     instead of leaking through to a lighter backdrop. */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .card-map .leaflet-tile-pane,
    :root:not([data-theme="light"]) #map-overlay .leaflet-tile-pane {
      filter: invert(1) hue-rotate(180deg) brightness(.92) contrast(.9);
    }
  }

  :root[data-theme="dark"] .card-map .leaflet-tile-pane,
  :root[data-theme="dark"] #map-overlay .leaflet-tile-pane {
    filter: invert(1) hue-rotate(180deg) brightness(.92) contrast(.9);
  }

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

  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); padding: 2rem 1rem; }
  /* ---------- header ---------- */
  /* One row, left aligned. The old centred lockup — a gradient-clipped 3.4rem
     title over a 3.5rem rule — cost 200px before the first card. */
  .page-head {
    position: relative; max-width: 1300px; margin: 0 auto 1rem;
    display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
  }
  .page-head-titles { flex: 1 1 auto; min-width: 0; }
  .page-head h1 {
    font-size: clamp(1.35rem, 4vw, 1.75rem);
    font-weight: 700; letter-spacing: -.02em; line-height: 1.15; color: var(--text);
  }
  .page-head-actions { display: flex; align-items: center; gap: .5rem; flex: 0 0 auto; }
  /* Left aligned under the title now, not a three-column grid centred on a dot. */
  .meta {
    display: flex; align-items: baseline; gap: .4rem; flex-wrap: wrap;
    margin-top: .2rem; font-size: .82rem; color: var(--muted);
  }
  .meta-dot { color: var(--border); }
  /* ---------- settings behind the gear ---------- */
  /* Criteria, a fresh scrape and notifications are all "how this page is set up",
     so they share one panel and leave the page above the cards to the title, the
     count and the two controls that change what you are looking at. */
  .icon-btn {
    width: 38px; height: 38px; flex: 0 0 auto;
    display: flex; align-items: center; justify-content: center;
    background: var(--surface); border: 1px solid var(--border); border-radius: 50%;
    color: var(--muted); cursor: pointer; transition: color .15s, border-color .15s;
  }
  .icon-btn:hover, .icon-btn[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
  .icon-btn svg { width: 19px; height: 19px; display: block; }
  /* .gear keeps only what is specific to it: its place in the header and the dot. */
  .gear { position: relative; }
  /* The panel is shut most of the time, so the gear carries the one thing worth
     seeing from outside it: whether a scrape is running, done or failed. */
  .gear-dot {
    position: absolute; top: 1px; right: 1px; width: 10px; height: 10px;
    border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
  }
  .gear-dot.ok { background: var(--ok); }
  .gear-dot.error { background: var(--err); }
  .settings-panel {
    position: absolute; top: 46px; right: 0; z-index: 20; width: 300px;
    text-align: left; background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: var(--shadow); padding: .3rem;
  }
  .panel-row { display: grid; gap: .35rem; padding: .5rem .55rem; }
  /* display: grid beats the browser's own [hidden] rule, and a row that cannot
     work would otherwise sit in the panel offering a button behind no endpoint. */
  .panel-row[hidden] { display: none; }
  .panel-row + .panel-row { border-top: 1px solid var(--border); }
  .panel-row[hidden] + .panel-row { border-top: 0; }
  .panel-row .btn { width: 100%; }
  .panel-link {
    display: flex; align-items: center; justify-content: space-between; gap: .5rem;
    font-size: .85rem; font-weight: 600; color: var(--text); text-decoration: none;
    padding: .55rem .55rem; border-radius: 8px;
  }
  .panel-link:hover { background: var(--surface-2); color: var(--accent); }
  .panel-link > span:first-child { display: grid; gap: .15rem; }
  .panel-link > span:last-child { color: var(--muted); }
  .panel-link:hover > span:last-child { color: var(--accent); }
  /* The threshold that dropped them is set on the page this link goes to, so the
     count belongs here rather than beside the number of houses on show. */
  .panel-note { font-size: .74rem; font-weight: 500; color: var(--warn); }
  .panel-link:hover .panel-note { color: var(--warn); }
  /* An empty status would otherwise hold open a row of whitespace. */
  .panel-row .save-status:empty { display: none; }
  .panel-row .save-status { line-height: 1.4; }
  /* ---------- criteria editor (settings page) ---------- */
  .config-page {
    max-width: 860px; margin: 0 auto 1.25rem;
    background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow);
    overflow: hidden;
  }
  .config-inner { padding: 1.25rem; }
  .config-section {
    font-size: .9rem; font-weight: 700; padding: .85rem 1.25rem;
    background: var(--surface-2); border-bottom: 1px solid var(--border);
    display: flex; align-items: center; gap: .5rem;
  }
  /* The weight total sits with the section heading, where it stays in view while
     the sliders below it move. */
  .section-total { margin-left: auto; font-size: .8rem; font-weight: 600; color: var(--muted); }
  .section-total strong { color: var(--accent); }
  .config-hint { font-size: .82rem; line-height: 1.5; color: var(--muted); margin-bottom: 1rem; }
  .sliders { display: grid; gap: .3rem; }
  .slider-block { display: grid; gap: 0; }
  .slider-row { display: grid; grid-template-columns: 140px 1fr 48px 28px; align-items: center; gap: .75rem; padding: .35rem 0; }
  .slider-row label { font-size: .88rem; font-weight: 600; text-align: right; }
  input[type=range] { width: 100%; accent-color: var(--accent); cursor: pointer; }
  .slider-val { font-size: .85rem; font-weight: 700; color: var(--accent); text-align: right; }
  .info-btn {
    width: 24px; height: 24px; border-radius: 50%; border: 1.5px solid var(--accent);
    background: var(--surface); color: var(--accent); font-size: .8rem; font-weight: 700;
    cursor: pointer; line-height: 1; display: flex; align-items: center; justify-content: center;
    flex-shrink: 0; transition: background .15s, color .15s;
  }
  .info-btn:hover, .info-btn.active { background: var(--accent); color: white; }
  .info-panel {
    margin: 0 0 .5rem 157px; /* align with slider track */
    background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
    padding: .6rem .8rem; animation: fadeIn .15s ease;
    /* The editor rows are the widest thing in the panel; let them scroll inside
       it rather than pushing the page sideways. */
    overflow-x: auto;
  }
  /* Setpoint editor controls */
  .sp-input {
    width: 5.5rem; padding: .2rem .35rem; font: inherit; font-size: .78rem;
    border: 1px solid var(--border); border-radius: 5px; background: var(--surface);
  }
  .sp-wide { width: 100%; max-width: 17rem; }
  .sp-add, .sp-del {
    padding: .15rem .5rem; font: inherit; font-size: .75rem; cursor: pointer;
    border: 1px solid var(--border); border-radius: 5px; background: var(--surface);
    color: var(--muted);
  }
  .sp-add:hover, .sp-del:hover { background: var(--surface-2); color: var(--accent); }
  @keyframes fadeIn { from { opacity:0; transform:translateY(-4px) } to { opacity:1; transform:none } }
  .scale-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
  .scale-table th { color: var(--muted); font-weight: 600; text-align: left; padding: .15rem .4rem; border-bottom: 1px solid var(--border); }
  .scale-table td { padding: .2rem .4rem; vertical-align: middle; }
  .scale-label { font-weight: 500; white-space: nowrap; color: var(--text); width: 140px; }
  .scale-bar { width: 120px; }
  .scale-score { font-weight: 700; color: var(--accent); text-align: right; white-space: nowrap; }
  .mini-bar { height: 7px; border-radius: 3px; min-width: 3px; }
  .scale-note td { color: var(--muted); font-style: italic; font-size: .75rem; padding-top: .3rem; }
  /* Points of interest: not a criterion, so it sits under the sliders with a rule
     above it rather than behind one of the ℹ buttons. */
  .poi-block { margin-top: 1.1rem; padding-top: .9rem; border-top: 1px solid var(--border); }
  .poi-head { font-size: .82rem; font-weight: 700; margin-bottom: .4rem; }
  .poi-block .config-hint { margin-bottom: .7rem; }
  /* Name, address, remove — the address takes whatever is left, because it is the
     only field that holds a full line of text. */
  .poi-row {
    display: grid; grid-template-columns: 8rem 1fr auto;
    gap: .4rem; align-items: center; margin-bottom: .4rem;
  }
  .poi-row .sp-input { width: 100%; }
  /* One action row instead of two loose lines of buttons and labels. */
  .config-actions {
    display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .7rem;
    margin-top: 1rem; padding-top: .9rem; border-top: 1px solid var(--border);
  }
  .btn {
    padding: .4rem .85rem; border: 1px solid var(--border); border-radius: 8px;
    background: var(--surface); color: var(--text); font: inherit; font-size: .82rem;
    font-weight: 600; cursor: pointer; text-decoration: none;
    transition: background .15s, border-color .15s, color .15s;
  }
  .btn:hover { background: var(--surface-2); border-color: var(--accent); color: var(--accent); }
  /* A running scrape disables its own button; without this it still looks pressable. */
  .btn:disabled { opacity: .5; cursor: default; }
  .btn:disabled:hover { background: var(--surface); border-color: var(--border); color: var(--text); }
  .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
  .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
  .save-status { font-size: .78rem; color: var(--muted); }
  .save-status.ok { color: var(--ok); }
  .save-status.error { color: var(--err); }
  .save-status.dirty { color: var(--warn); }
  /* ---------- toolbar: order and kantoor ---------- */
  /* No card of its own: a bordered white bar reads as one more card, and these
     are controls over the cards rather than something to look at. Two columns
     so the kantoor picker can grow downwards without moving the order. */
  .toolbar {
    max-width: 1300px; margin: 0 auto 1.25rem; padding: 0 .15rem;
    display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .4rem .8rem;
    align-items: start;
  }
  .toolbar-group { display: flex; align-items: center; gap: .45rem; flex-wrap: wrap; }
  .toolbar-label { font-size: .75rem; font-weight: 600; color: var(--muted); }
  /* One control with three positions, not three buttons that happen to be
     exclusive. */
  .sort-seg {
    display: flex; background: var(--surface);
    border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
  }
  .sort-btn {
    font: inherit; font-size: .8rem; font-weight: 600; color: var(--muted);
    background: transparent; border: 0; padding: .3rem .75rem; cursor: pointer;
    transition: background .15s, color .15s;
  }
  .sort-btn + .sort-btn { border-left: 1px solid var(--border); }
  .sort-btn:hover { background: var(--surface-2); color: var(--accent); }
  /* Tinted rather than filled: which order is in force still reads at a glance
     without a block of accent colour sitting above the ranking. */
  .sort-btn.on, .sort-btn.on:hover { background: var(--accent-soft); color: var(--accent); font-weight: 700; }
  /* Shut by default and deliberately quiet: which kantoor is the rarer question,
     so the summary states the answer and the chips stay out of the way. */
  .source-picker { justify-self: end; }
  .source-picker[open] { justify-self: stretch; }
  .source-picker > summary {
    list-style: none; cursor: pointer; width: max-content; margin-left: auto;
    display: flex; align-items: center; gap: .35rem;
    font-size: .8rem; color: var(--muted); padding: .35rem .5rem; border-radius: 8px;
  }
  .source-picker > summary::-webkit-details-marker { display: none; }
  .source-picker > summary:hover { background: var(--surface-2); color: var(--accent); }
  .source-picker[open] > summary { color: var(--accent); }
  .source-picker > summary strong { color: var(--text); font-weight: 600; }
  .source-picker[open] > summary strong { color: var(--accent); }
  .source-caret { width: 11px; height: 11px; transition: transform .15s; }
  .source-picker[open] .source-caret { transform: rotate(180deg); }
  /* A tray rather than a rule across the row: the picker only owns its own
     column, so a border-top would draw a line that stops halfway. Content-wide
     and hugging the right so it does not read as a bar of its own, with a
     max-width that makes the chips wrap instead of running off the page. */
  .source-chips {
    display: flex; flex-wrap: wrap; gap: .3rem; justify-content: flex-end;
    width: max-content; max-width: 100%; margin: .35rem 0 0 auto; padding: .4rem .45rem;
    background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
  }
  .source-chip {
    display: flex; align-items: center; gap: .35rem; font: inherit; font-size: .78rem;
    color: var(--muted); background: transparent; border: 1px solid transparent;
    border-radius: 999px; padding: .2rem .6rem; cursor: pointer;
    transition: background .15s, border-color .15s, color .15s;
  }
  .source-chip:hover { background: var(--surface-2); border-color: var(--border); color: var(--text); }
  /* Picked: only the selected kantoren are shown. Nothing picked = all of them,
     and then the "Alle" chip is the one lit up. */
  .source-chip.on, .source-chip.on:hover {
    background: var(--surface-2); border-color: var(--accent); color: var(--accent); font-weight: 600;
  }
  .legend-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
  /* ---------- filter tray ---------- */
  /* Its own tray beside the kantoor picker, shut by default, for the same reason:
     most visits filter nothing, and a bar that is always open costs a band of the
     page above the ranking every time. */
  /* The panel floats rather than taking a row of its own: opening it used to
     span the toolbar and push the kantoor picker onto a second line, where it
     read as orphaned. The gear panel above the cards already works this way,
     so the toolbar keeps its shape however many trays are open. */
  .filter-tray { justify-self: end; position: relative; }
  .filter-tray > summary {
    list-style: none; cursor: pointer; width: max-content; margin-left: auto;
    display: flex; align-items: center; gap: .35rem;
    font-size: .8rem; color: var(--muted); padding: .35rem .5rem; border-radius: 8px;
  }
  .filter-tray > summary::-webkit-details-marker { display: none; }
  .filter-tray > summary:hover { background: var(--surface-2); color: var(--accent); }
  .filter-tray > summary strong { color: var(--text); font-weight: 600; }
  .filter-tray[open] > summary { color: var(--accent); }
  .filter-tray[open] > summary strong { color: var(--accent); }
  .filter-tray[open] .source-caret { transform: rotate(180deg); }
  .filter-panel {
    position: absolute; top: calc(100% + .35rem); right: 0; z-index: 20;
    width: min(720px, calc(100vw - 2rem)); padding: .7rem .8rem;
    background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
    box-shadow: var(--shadow);
    display: grid; gap: .45rem;
  }
  .filter-hint { font-size: .75rem; color: var(--muted); margin-bottom: .2rem; }
  .filter-row { display: grid; grid-template-columns: 9rem 1fr 8rem; gap: .6rem; align-items: center; }
  .filter-row label, .filter-label { font-size: .8rem; font-weight: 600; color: var(--muted); }
  .filter-row output {
    font-size: .8rem; font-weight: 700; color: var(--accent);
    font-variant-numeric: tabular-nums;
  }
  .filter-row input[type=range]:disabled { opacity: .4; }
  .filter-chips { display: flex; flex-wrap: wrap; gap: .3rem; grid-column: 2 / -1; }
  .filter-chip {
    font: inherit; font-size: .78rem; color: var(--muted);
    background: transparent; border: 1px solid var(--border); border-radius: 999px;
    padding: .2rem .6rem; cursor: pointer;
    transition: background .15s, border-color .15s, color .15s;
  }
  .filter-chip:hover { background: var(--surface-2); color: var(--text); }
  .filter-chip.on, .filter-chip.on:hover {
    background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600;
  }
  .filter-actions { display: flex; justify-content: flex-end; }
  /* ---------- results ---------- */
  /* min() keeps the track from forcing a 440px column onto a narrower phone,
     which pushed the cards wider than the viewport. */
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(440px, 100%),1fr)); gap: 2rem; max-width: 1300px; margin: 0 auto; }
  /* ---------- card ---------- */
  .house-card {
    background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow);
    overflow: hidden; transition: transform .2s, box-shadow .2s;
  }
  .house-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover); }
  /* A pane rather than a strip above the photo: the badges cost a whole row of
     card height for three things that fit in the photo's corners. Flex with one
     child today, two once the map lands, so neither needs a width of its own. */
  .card-media {
    position: relative; display: flex; gap: 1px;
    background: var(--border); aspect-ratio: 16 / 10; overflow: hidden;
  }
  .card-media > .house-img { flex: 1 1 auto; min-width: 0; }
  .house-img { width: 100%; height: 100%; object-fit: cover; display: block; }
  /* Class, not style.display: a hidden img leaves the pane empty, while the
     no-img treatment fills it the same as a photo would. */
  .no-img {
    background: var(--surface-2); color: var(--muted); font-size: .9rem;
    display: flex; align-items: center; justify-content: center;
  }

  /* The second child of the media pane. 38% of the width on a card wide enough to
     read two things side by side, and a tab pair below that — measured with a
     container query, because the grid hands a card anything from a phone's full
     width to 440px in a three-column row. */
  .house-card { container-type: inline-size; }
  /* container-type above gives .house-card layout containment, which makes it a
     stacking context of its own. Leaflet's tile pane starts at z-index 400 and
     its controls at 1000; without a stacking context of its own on the map, those
     panes stack directly against the card's badges, tabs and expand button (2-4)
     inside that same context — and outrank every one of them. isolation: isolate
     boxes Leaflet's internal z-indices back inside the map, the same fix
     #map-overlay-canvas needs one level further in (see below). */
  .card-map { display: none; isolation: isolate; }
  .card-map--none {
    background: var(--surface-2); color: var(--muted); font-size: .78rem;
    align-items: center; justify-content: center; text-align: center; padding: .5rem;
  }
  /* Shown only once Leaflet is in: without it the pane would be a grey block that
     says nothing, so the photo keeps the whole width. */
  .leaflet-ready .card-map { display: flex; flex: 0 0 38%; min-width: 0; height: 100%; }
  /* A pane the tile host has refused (maps.js's tileerror handler) collapses the
     same way an unbuilt one does — the photo, already flex: 1 1 auto, takes the
     width back. Higher specificity than the rule above rather than an inline
     style, so it survives a rebuild that never happens once the pane is marked. */
  .leaflet-ready .card-map.card-map--failed { display: none; }
  /* Leaflet paints its own container; the tokens keep it in the card's palette. */
  .card-map .leaflet-container { width: 100%; height: 100%; background: var(--surface-2); font: inherit; }
  .card-map .leaflet-control-attribution { font-size: .6rem; }

  /* Only on a narrow card, and only when there is a second thing to switch to. */
  .media-tabs { display: none; position: absolute; bottom: .55rem; right: .55rem; z-index: 3; gap: .25rem; }
  .media-tab {
    font: inherit; font-size: .72rem; font-weight: 700; cursor: pointer;
    padding: .18rem .5rem; border-radius: 999px; border: 0; color: #fff;
    background: var(--media-plate); backdrop-filter: blur(3px);
  }
  .media-tab.on { background: var(--accent); }

  @container (max-width: 519px) {
    .leaflet-ready .card-map { display: none; }
    .leaflet-ready .card-media.show-map > .house-img { display: none; }
    .leaflet-ready .card-media.show-map > .card-map { display: flex; flex: 1 1 auto; }
    .leaflet-ready .media-tabs { display: flex; }
    /* A failed pane stays collapsed even behind the Kaart tab, and the photo
       comes back rather than leaving the tab pointing at empty space. */
    .leaflet-ready .card-media.show-map > .card-map.card-map--failed { display: none; }
    .leaflet-ready .card-media:has(.card-map--failed).show-map > .house-img { display: block; }
  }

  /* Over the photo, so both read against an unknown image: a shadow, not a plate. */
  .card-media .rank-badge,
  .card-media .score-chip,
  .card-media .card-sources { position: absolute; z-index: 2; }
  .card-media .rank-badge { top: .55rem; left: .55rem; }
  .card-media .score-chip { top: .55rem; right: .55rem; }
  .card-media .card-sources { bottom: .55rem; left: .55rem; display: flex; gap: .3rem; }
  /* The chip shifts left only on a card that has an expand button to sit beside;
     a town-line card keeps the corner. */
  .card-media:has(.map-expand) .score-chip { right: 2.4rem; }
  .map-expand {
    position: absolute; top: .55rem; right: .55rem; z-index: 4;
    width: 26px; height: 26px; border: 0; border-radius: 8px; cursor: pointer;
    color: #fff; background: var(--media-plate); backdrop-filter: blur(3px);
    font-size: .8rem; line-height: 1;
  }
  .rank-badge {
    font-size: .95rem; font-weight: 800; color: #fff;
    padding: .2rem .6rem; border-radius: 999px; min-width: 56px; text-align: center;
    background: var(--media-plate); backdrop-filter: blur(3px);
    box-shadow: 0 1px 6px rgba(0, 0, 0, .35);
  }
  .score-chip {
    font-size: 1.05rem; font-weight: 800; color: var(--on-score);
    padding: .2rem .6rem; border-radius: 10px;
    box-shadow: 0 1px 6px rgba(0, 0, 0, .35);
    font-variant-numeric: tabular-nums;
  }
  .source-dot {
    width: 10px; height: 10px; border-radius: 50%;
    border: 1.5px solid rgba(255, 255, 255, .85);
    box-shadow: 0 1px 4px rgba(0, 0, 0, .4);
  }

  /* ---------- overlay map ---------- */
  /* Above 1000: a card's own mini map has no positioned ancestor that boxes in
     Leaflet's control z-index (1000), so it leaks past anything lower and its
     zoom buttons paint — and click — through the backdrop. */
  #map-overlay {
    position: fixed; inset: 0; z-index: 2000; padding: 2rem 1rem;
    background: rgba(10, 12, 18, .6); display: flex; align-items: center; justify-content: center;
  }
  /* A display of its own beats the UA [hidden] rule, so say it explicitly. */
  #map-overlay[hidden] { display: none !important; }
  .map-overlay-inner {
    position: relative; width: min(1000px, 100%); height: min(700px, 100%);
    background: var(--surface); border-radius: var(--radius); overflow: hidden;
    box-shadow: var(--shadow);
  }
  /* Same leak, one pane further in: #map-overlay boxes Leaflet's z-index 1000
     controls in above the page, but .map-overlay-close (z-index 2) is a sibling
     of this canvas inside that same context, so Leaflet's panes and controls
     still outrank it unless the canvas gets a stacking context of its own. */
  #map-overlay-canvas { width: 100%; height: 100%; isolation: isolate; }
  .map-overlay-close {
    position: absolute; top: .5rem; right: .5rem; z-index: 2;
    width: 32px; height: 32px; border: 0; border-radius: 8px; cursor: pointer;
    background: var(--surface); color: var(--text); box-shadow: var(--shadow); font-size: .9rem;
  }
  .map-star { color: var(--warn); font-size: 20px; line-height: 22px; text-align: center; text-shadow: 0 1px 3px rgba(0,0,0,.5); }

  .card-body { padding: 1.1rem 1.25rem 1.25rem; }
  /* One line, always. A scraped title runs from two words to a full sentence, and
     a card whose title wrapped stood a line taller than the one beside it — the
     rest of the card is fixed height, so the whole grid row grew with it. The
     full text stays reachable as the element's tooltip. */
  .house-title {
    font-size: 1.05rem; font-weight: 700; margin-bottom: .25rem; line-height: 1.3;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .house-location { color: var(--muted); font-size: .9rem; margin-bottom: .2rem; }
  .house-price {
    font-size: 1.5rem; font-weight: 800; margin-bottom: .8rem;
    font-variant-numeric: tabular-nums; letter-spacing: -.01em;
  }
  /* Beside the price, not under it: it is the same number seen from further back,
     and a line of its own would push every card with a drop out of step with the
     cards without one. Green because a cut is good news for the buyer. */
  .price-drop {
    display: inline-block; margin-left: .5rem; vertical-align: .12em;
    background: var(--surface-2); color: var(--ok);
    font-size: .78rem; font-weight: 700;
    padding: .15rem .5rem; border-radius: 999px;
    white-space: nowrap; cursor: help;
  }
  /* The address keeps its quiet grey; the dotted underline is the only hint that
     it opens the map, so it never competes with the price below it. */
  .loc-link { color: inherit; text-decoration: none; border-bottom: 1px dotted currentColor; }
  .loc-link:hover, .loc-link:focus-visible { color: var(--accent); }
  /* One line, clipped, tooltip carries the rest — the same rule the POI line had,
     for the same reason: equal card heights across a grid row. */
  .card-meta {
    color: var(--muted); font-size: .78rem; margin-bottom: .5rem;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  /* No date and no POIs leaves this empty; without the rule its margin still held
     open a stray gap between the address and the price. */
  .card-meta:empty { display: none; }
  /* Only the offset is coloured; the labels stay grey, or four names in three
     colours would be the loudest thing on the card. Darker than the score palette
     because this is .78rem text rather than a bar: #2dc653 on white at this size
     is a colour you have to look twice at. Green for nearer than today, the same
     green as the price cut above it — both mean the listing gained something. */
  .poi-delta { font-weight: 700; }
  .poi-delta.near { color: var(--ok); }
  .poi-delta.far { color: var(--err); }
  .poi-delta.same { color: var(--muted); }
  .specs { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
  /* Quiet pills: the numbers are the point, not the badge around them. */
  .spec {
    background: var(--surface-2); color: var(--muted);
    font-size: .8rem; font-weight: 600; padding: .2rem .6rem; border-radius: 999px;
  }
  /* No green of its own — it takes the ramp colour of the EPC score it names. */
  .spec.epc { color: var(--on-score); }
  /* No row borders: eight rows of rules on every card drew forty lines that said
     nothing. The bar column is what separates them. */
  .criteria-table { width: 100%; border-collapse: collapse; font-size: .82rem; margin-bottom: 1rem; }
  /* The three column names say nothing a reader of the rows needs — but they stay
     in the markup, clipped rather than display:none, so a screen reader still
     names the columns it is reading out. */
  .criteria-table thead th {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; padding: 0; border: 0;
  }
  .criteria-table td { padding: .28rem .5rem .28rem 0; border: 0; vertical-align: middle; }
  .crit-name { font-weight: 600; white-space: nowrap; }
  .crit-bar { width: 120px; }
  .crit-reason { color: var(--muted); }
  /* dim rows for criteria with zero weight */
  .criteria-table tr[data-criterion].zero-weight td { opacity: .3; }
  .bar-wrap { display: flex; align-items: center; gap: .4rem; }
  /* The bar grows inside a track of its own so the number keeps its place. With
     the bar directly in the flex row, every score sat at a different distance
     from the left and the column read as ragged. The track now carries a real
     background, so a low score reads as a part-filled track rather than a stub. */
  .bar-track { flex: 1 1 auto; min-width: 0; height: 6px; border-radius: 3px; background: var(--surface-2); }
  .bar { height: 6px; border-radius: 3px; min-width: 4px; transition: background .3s, width .3s; }
  .bar-label {
    font-size: .75rem; font-weight: 700; white-space: nowrap;
    flex: 0 0 auto; min-width: 2.2rem; text-align: right;
    font-variant-numeric: tabular-nums;
  }
  /* "Bekijk op" reads once, above the row, so the buttons hold nothing but the
     name of the site and can all be the same width. */
  .visit-label {
    display: block; margin: .2rem 0 .35rem;
    font-size: .72rem; font-weight: 700; letter-spacing: .06em;
    text-transform: uppercase; color: var(--muted);
  }
  /* Three to a row, wrapping after that: a card with one source gets a button the
     same size as a card with three, so the buttons line up down the page. */
  .visit-btns { display: flex; flex-wrap: wrap; gap: var(--visit-btn-gap); }
  /* Above the media query, not after it: a media query adds no specificity, so
     an identical selector further down the sheet wins on source order and the
     phone override would never apply. */
  /* Neutral surface plus the kantoor's own dot: three saturated colour blocks per
     card were the loudest thing in the grid, and the colour coding survives. */
  .visit-btn {
    display: flex; align-items: center; justify-content: center; gap: .4rem;
    background: var(--surface-2); color: var(--text);
    text-decoration: none; padding: .5rem .75rem; border-radius: 8px;
    border: 1px solid var(--border);
    font-size: .85rem; font-weight: 600; transition: border-color .15s, color .15s;
    /* Neither growing nor shrinking: exactly a third of the row minus the two
       gaps between three buttons, so the width never depends on how many sources
       the listing has or how long their names are. */
    flex: 0 0 calc((100% - 2 * var(--visit-btn-gap)) / 3);
    min-width: 0; text-align: center;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .visit-btn:hover, .visit-btn:focus-visible { border-color: var(--accent); color: var(--accent); }
  .visit-btn .source-dot { border: 0; box-shadow: none; width: 8px; height: 8px; }

  /* Phones: the criteria table is the widest thing on the card, so give the
     reason column room by shrinking the fixed bar column and the padding. */
  @media (max-width: 640px) {
    body { padding: 1rem .5rem; }
    .grid { gap: 1rem; }
    .card-body { padding: .9rem; }
    .criteria-table { font-size: .78rem; table-layout: fixed; }
    .criteria-table td { padding: .3rem .25rem; }
    .crit-name { white-space: normal; width: 26%; }
    .crit-bar { width: 92px; }
    .crit-reason { width: auto; overflow-wrap: anywhere; }
    .rank-badge { min-width: 48px; font-size: .85rem; }
    .score-chip { font-size: .95rem; }
    .house-title { font-size: 1.05rem; }
    .house-price { font-size: 1.15rem; }
    .price-drop { font-size: .72rem; margin-left: .4rem; }
    .config-page, .page-head { margin-left: 0; margin-right: 0; }

    /* The editor is built for a wide screen: a 140px label column, a fixed %
       column and a button leave a phone about 60px for the slider itself, and
       the info panel's 157px indent puts the setpoint table off the right edge
       entirely. Give the label its own line and drop the indent. */
    .config-inner { padding: .9rem .75rem; }
    .slider-row { grid-template-columns: 1fr 2.6rem 24px; gap: .5rem; padding: .5rem 0 .25rem; }
    .slider-row label { grid-column: 1 / -1; text-align: left; }
    .info-panel { margin-left: 0; padding: .6rem; }
    /* Three columns do not fit a phone: the name gets the first row, the address
       the second, the remove button sits beside it. */
    .poi-row { grid-template-columns: 1fr auto; }
    .poi-row .poi-address { grid-column: 1; }
    .scale-table { font-size: .75rem; }
    .scale-table td { padding: .2rem .25rem; }
    .scale-label { width: auto; white-space: normal; }
    .sp-input { width: 4.5rem; }
    /* No font-size override: the title is clamped against the viewport width. */
    .config-actions { gap: .45rem; }
    .config-actions .btn { flex: 1 1 auto; }
    /* One column: the order control takes the first line, the kantoor picker
       the second, both still right-to-left readable. */
    .toolbar { grid-template-columns: minmax(0, 1fr); }
    .source-picker, .source-picker[open] { justify-self: stretch; }
    .filter-tray, .filter-tray[open] { justify-self: stretch; }
    /* One column here, so the panel is as wide as the toolbar itself. */
    .filter-panel { width: 100%; }
    .filter-row { grid-template-columns: 1fr auto; }
    .filter-row label, .filter-label { grid-column: 1 / -1; }
    .sort-btn { padding: .35rem .6rem; font-size: .8rem; }
    /* Aligned with the summary above them rather than pushed to the edge. */
    .source-chips { justify-content: flex-start; }
    /* The panel is wider than a narrow screen minus the body padding. */
    .settings-panel { width: min(300px, calc(100vw - 3rem)); }
    /* Keep the visit row on one line on a narrow screen too. */
    /* The token, not the gap itself: the button width is calculated from it. */
    .visit-btns { --visit-btn-gap: .35rem; }
    .visit-btn { font-size: .78rem; padding: .5rem .6rem; }
  }
  footer { text-align: center; margin-top: 3rem; color: var(--muted); font-size: .8rem; }