/* ==========================================================================
   GaffFinder — "the drafting board"
   The product reads floorplans, so the page is the surface a floorplan is
   drawn on: cool vellum, a faint measured grid, drafting-ink type, and the
   architect's red pencil reserved for ONE thing — the dimension line that
   carries every square-footage figure. Colour is otherwise rationed to the
   value signal (£/sqft against the area median). Plex Mono sets every
   figure; Plex Sans Condensed sets every label; Plex Sans does the reading.
   ========================================================================== */

/* ------------------------------------------------------------------ fonts */
/* Self-hosted, on purpose. A <link> to fonts.googleapis.com discloses every
   visitor's IP and referring URL to a third party before the first paint —
   the security review flagged it, and a typeface is not worth that. Latin
   subsets only (~90 KB total); IBM Plex is SIL OFL, licence in fonts/. */

@font-face {
    font-family: "IBM Plex Sans";
    src: url("fonts/plex-sans-var.woff2") format("woff2-variations"),
         url("fonts/plex-sans-var.woff2") format("woff2");
    font-weight: 100 700;          /* one variable file covers the range */
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: "IBM Plex Sans Condensed";
    src: url("fonts/plex-cond-500.woff2") format("woff2");
    font-weight: 500;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: "IBM Plex Sans Condensed";
    src: url("fonts/plex-cond-600.woff2") format("woff2");
    font-weight: 600;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: "IBM Plex Mono";
    src: url("fonts/plex-mono-400.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: "IBM Plex Mono";
    src: url("fonts/plex-mono-500.woff2") format("woff2");
    font-weight: 500;
    font-style: normal;
    font-display: swap;
}

/* ----------------------------------------------------------------- tokens */

:root {
    /* surfaces — cool drafting vellum, never cream */
    --paper: #F7F8FA;
    --paper-2: #EDF1F6;
    --card: #FFFFFF;
    --grid: #E4E9F0;
    --ink: #12233A;                /* drafting ink: deep blue-black */
    --ink-soft: #5A6B80;
    /* 2.6:1 on white — below AA, so --ink-faint is for DECORATION only
       (placeholders, separators, hairline hovers). Anything a reader has
       to read, however small, uses --ink-soft (5.4:1). */
    --ink-faint: #94A2B4;
    --rule: #C6D0DC;               /* hairline drawing rule */
    --annotate: #C8452D;           /* architect's red pencil — MEASUREMENTS ONLY */
    --value: #0E7C5A;              /* the deal signal */

    /* Legacy aliases: settings.html and older rules still name these. */
    --line: var(--rule);
    --line-strong: var(--rule);

    /* Deal tiers, re-pointed at the palette above. "Expensive" is
       deliberately NOT red — red belongs to the dimension line, and a
       warning colour would overstate what is only a median comparison. */
    --deal-great: var(--value);
    --deal-good: #2F7F63;
    --deal-high: var(--ink-soft);
    --deal-great-bg: #E3F0EA;
    --deal-high-bg: var(--paper-2);

    --danger: #C8452D;
    --ok: var(--value);

    /* type */
    --sans: "IBM Plex Sans", -apple-system, "Segoe UI", system-ui, sans-serif;
    --cond: "IBM Plex Sans Condensed", "IBM Plex Sans", -apple-system, system-ui, sans-serif;
    --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
    /* kept so settings.html's older rules resolve to something sane */
    --serif: var(--sans);

    /* scale */
    --t-eyebrow: 0.6875rem;        /* 11px  condensed caps */
    --t-small: 0.8125rem;          /* 13px */
    --t-body: 0.9375rem;           /* 15px */
    --t-lead: 1.0625rem;           /* 17px */

    /* shape & rhythm — drafting corners are sharp */
    --radius: 3px;
    --radius-lg: 3px;
    --shadow: 0 1px 1px rgba(18, 35, 58, 0.04), 0 6px 18px rgba(18, 35, 58, 0.05);
    --shadow-lift: 0 2px 4px rgba(18, 35, 58, 0.07), 0 16px 34px rgba(18, 35, 58, 0.10);
    --pad: clamp(14px, 2.5vw, 26px);
    --max: 1560px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html { scrollbar-gutter: stable; }

body {
    font-family: var(--sans);
    color: var(--ink);
    font-size: var(--t-body);
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
    /* The measured grid: minor 8px, major 40px. Faint enough to read as
       paper texture — if it ever reads as a table it is too strong. */
    background-color: var(--paper);
    background-image:
        repeating-linear-gradient(to right, var(--grid) 0 1px, transparent 1px 40px),
        repeating-linear-gradient(to bottom, var(--grid) 0 1px, transparent 1px 40px),
        repeating-linear-gradient(to right,
            color-mix(in srgb, var(--grid) 42%, transparent) 0 1px, transparent 1px 8px),
        repeating-linear-gradient(to bottom,
            color-mix(in srgb, var(--grid) 42%, transparent) 0 1px, transparent 1px 8px);
    background-attachment: fixed;
}

::selection { background: var(--ink); color: var(--paper); }

b, strong { font-weight: 600; }

.sr-only {
    position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

:focus-visible {
    outline: 2px solid var(--ink);
    outline-offset: 2px;
    border-radius: 1px;
}

/* Every figure is set in mono: prices, sizes, counts, distances. Marking
   them with <b> keeps the markup readable and the rule in one place. */
.listing-count b, .pagination-info b, .spec-row b, .topbar-stats b,
.count-median b {
    font-family: var(--mono);
    font-weight: 500;
    font-size: 0.95em;
    letter-spacing: -0.01em;
}

/* Section headers / eyebrows: condensed, uppercase, tracked out. */
.eyebrow, .field-label {
    font-family: var(--cond);
    font-size: var(--t-eyebrow);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--ink-soft);
}

/* ============================================================ THE SIGNATURE
   The dimension line. Every square footage on this site is drawn as an
   architectural dimension annotation — a hairline in red pencil, a tick at
   each end, the measurement sitting on it:   |———— 782 SQFT ————|
   Pure CSS: the arms are the two pseudo-elements, each painting its own
   end tick plus its half of the rule with background gradients. Used in
   exactly two places, the hero stat and the card, and nowhere else — it is
   the one thing this page is remembered by. */

.dim {
    --dim-tick: 9px;
    display: flex;
    align-items: center;
    color: var(--annotate);
    width: 100%;
}

.dim::before,
.dim::after {
    content: "";
    flex: 1 1 auto;
    min-width: 14px;
    height: var(--dim-tick);
    /* end tick (1px wide, full height) + the rule (1px tall, full width) */
    background:
        linear-gradient(currentColor, currentColor) no-repeat left center / 1px 100%,
        linear-gradient(currentColor, currentColor) no-repeat center / 100% 1px;
}
.dim::after {
    background:
        linear-gradient(currentColor, currentColor) no-repeat right center / 1px 100%,
        linear-gradient(currentColor, currentColor) no-repeat center / 100% 1px;
}

.dim-body {
    display: inline-flex;
    align-items: baseline;
    gap: 0.45em;
    padding: 0 0.7em;
    white-space: nowrap;
}

.dim-fig {
    font-family: var(--mono);
    font-weight: 500;
    letter-spacing: -0.02em;
    font-variant-numeric: lining-nums tabular-nums;
}

/* THE SEPARATOR SQUEEZE, and the two places it must not happen.
   fig() in index.html wraps every comma and decimal point in .nsep so a
   monospace figure can pull them out of their own empty cell — without it
   "14,363" is set as "14 , 363" and reads as two numbers.

   It is OFF by default and switched on per monospace context below,
   because the trick is a fix for one font. In the condensed face the comma
   is already narrow, and -0.2em drags it clean on top of the digit before
   it: "£1,842" came out as "£1̶842", which reads as struck-through text —
   a sibling listing looking like a cancelled price. So a figure only gets
   the squeeze where the type it is set in actually needs it, and a fig()
   dropped into some new proportional-font corner is correct by default. */
.nsep { margin-inline: 0; }

.listing-count b .nsep, .pagination-info b .nsep, .spec-row b .nsep,
.topbar-stats b .nsep, .count-median b .nsep, .dim-fig .nsep,
.ppsf-fig .nsep, .price-pcm .nsep, .reduced-tag .nsep {
    margin-inline: -0.2em;
}

/* ...and off again under a line-through, even in mono. The negative
   margins make the separator's inline box overlap its neighbours', and the
   browser paints the strike per box, so the rule crosses the squeezed
   comma twice — a thickening and a nick, right where the eye is already
   working to read a cancelled figure. Struck text gives the separator its
   cell back: one atom wider, and legible instead of scarred.

   `.struck` is the ONLY place a line-through is drawn (grep it), so the
   decoration and its exemption cannot drift apart — anything that wants
   text struck takes this class rather than declaring text-decoration
   itself. This rule must stay AFTER the mono list above, which it ties
   with on specificity. */
.struck { text-decoration: line-through; }
.struck .nsep { margin-inline: 0; }

.dim-unit {
    font-family: var(--cond);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    font-size: 0.68em;
}

/* hero stat */
.dim--hero {
    --dim-tick: 11px;
    max-width: 27rem;
    margin-top: clamp(13px, 1.8vw, 19px);
}
.dim--hero .dim-fig { font-size: clamp(1.4rem, 2.5vw, 1.75rem); }
.dim--hero .dim-unit { font-size: 0.72rem; }

/* card */
.dim--card {
    --dim-tick: 8px;
    margin-bottom: 2px;
}
.dim--card .dim-fig { font-size: 1.05rem; }
.dim--card .dim-unit { font-size: 0.66rem; }

/* Stated by the agent rather than measured off a plan: same annotation,
   no red pencil — we did not draw this one. */
.dim--stated { color: var(--ink-faint); }

/* No floorplan we could read: the dimension is dashed, the way an unknown
   run is dashed on a drawing. */
.dim--none { color: var(--ink-faint); }
.dim--none::before {
    background:
        linear-gradient(currentColor, currentColor) no-repeat left center / 1px 100%,
        repeating-linear-gradient(to right, currentColor 0 3px, transparent 3px 6px)
            no-repeat center / 100% 1px;
}
.dim--none::after {
    background:
        linear-gradient(currentColor, currentColor) no-repeat right center / 1px 100%,
        repeating-linear-gradient(to right, currentColor 0 3px, transparent 3px 6px)
            no-repeat center / 100% 1px;
}

/* THE one orchestrated moment on the page: the hero's rule draws outward
   from the centre as the page settles. Nothing else moves on load, and
   nothing at all moves on scroll. */
@keyframes dim-draw-left  { from { clip-path: inset(0 0 0 100%); } to { clip-path: inset(0); } }
@keyframes dim-draw-right { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0); } }
@keyframes dim-settle     { from { opacity: 0; } to { opacity: 1; } }

.dim--hero::before { animation: dim-draw-left  400ms cubic-bezier(.22,.7,.2,1) both; }
.dim--hero::after  { animation: dim-draw-right 400ms cubic-bezier(.22,.7,.2,1) both; }
.dim--hero .dim-body { animation: dim-settle 400ms ease both; }

/* --------------------------------------------------------------- top bar */

.topbar {
    position: sticky;
    top: 0;
    z-index: 40;
    background: color-mix(in srgb, var(--paper) 86%, transparent);
    backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--rule);
}

.topbar-inner {
    max-width: var(--max);
    margin: 0 auto;
    padding: 11px var(--pad);
    display: flex;
    align-items: baseline;
    gap: 18px;
}

.wordmark {
    font-family: var(--cond);
    font-size: 1.05rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.16em;
    color: var(--ink);
    text-decoration: none;
    white-space: nowrap;
}

.topbar-note {
    font-family: var(--cond);
    font-size: var(--t-eyebrow);
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--ink-soft);
    padding-left: 16px;
    border-left: 1px solid var(--rule);
    align-self: center;
}

.topbar-stats {
    display: flex;
    gap: 16px;
    font-size: var(--t-small);
    color: var(--ink-soft);
    flex: 1;
    min-width: 0;
    flex-wrap: wrap;
}

.topbar-actions { display: flex; gap: 8px; margin-left: auto; }

/* Public build: the bar's one action — the aside's Telegram button
   restated at a size that defers to the hero. */
.topbar-cta {
    margin-left: auto;
    align-self: center;
    padding: 5px 12px;
    font-size: 0.75rem;
}

/* --------------------------------------------------------------- buttons */

.btn {
    display: inline-block;
    font-family: var(--cond);
    font-size: 0.82rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    padding: 8px 16px;
    border-radius: var(--radius);
    border: 1px solid transparent;
    cursor: pointer;
    text-decoration: none;
    text-align: center;
    white-space: nowrap;
    transition: background 0.15s, border-color 0.15s, color 0.15s;
}

.btn-primary { background: var(--ink); color: var(--paper); }
.btn-primary:hover { background: #0A1728; }

.btn-quiet {
    background: transparent;
    color: var(--ink-soft);
    border-color: var(--rule);
}
.btn-quiet:hover { border-color: var(--ink-soft); color: var(--ink); }

.btn-lg { padding: 12px 22px; font-size: 0.86rem; }

.btn-disabled { opacity: 0.35; pointer-events: none; }

/* ------------------------------------------------------- search deck/hero */

.deck { padding: 16px 0; }

.deck-inner {
    max-width: var(--max);
    margin: 0 auto;
    padding: 0 var(--pad);
}

/* Public build: the search instrument IS the hero — a sheet pinned to the
   drafting board, with the display line above it. The vertical budget is
   set by the laptop it will actually be read on: at 1440x900 the search
   row and the first row of cards have to be on screen without scrolling,
   because the search box is the product and a masthead nobody scrolled
   past is decoration. Nothing is removed to get there — the display line,
   the sub and the measured-flats rule all still lead — they are simply
   drawn at the size a page with results on it can afford. */
.deck--hero { padding: clamp(22px, 3.2vw, 38px) 0 clamp(8px, 1.4vw, 14px); }

.hero { max-width: 52rem; margin-bottom: clamp(14px, 2vw, 22px); }

.hero-line {
    font-size: clamp(1.95rem, 4vw, 2.7rem);
    font-weight: 600;
    line-height: 1.03;
    letter-spacing: -0.032em;
}

.hero-sub {
    margin-top: 9px;
    font-size: clamp(0.95rem, 1.35vw, 1.06rem);
    line-height: 1.45;
    color: var(--ink-soft);
    max-width: 34rem;
}

/* ----------------------------------------------------------- search form */

.searchform {
    background: var(--card);
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    padding: 15px 16px;
}

.searchform-row {
    display: flex;
    align-items: flex-end;
    gap: 18px;
    flex-wrap: wrap;
}

.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
/* The area box takes the slack: it is the control people reach for first,
   and a wide field is the difference between "type a place" and "pick from
   a list of 554". */
.field--area { flex: 1 1 280px; max-width: 620px; }
/* Keywords sit beside Area and take slack after it: the place is what
   people reach for first, the words are the refinement. Capped narrow so
   the numeric fields stay on the same line on a laptop — the row wraps
   before it squeezes. */
.field--q { flex: 1 1 170px; max-width: 260px; }
/* type="search" paints a platform clear button in WebKit that ignores the
   field's own border radius and height; the field already has a chip and a
   Clear link that do the same job. */
.field--q input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }

.field select,
.field input {
    font-family: var(--sans);
    font-size: 0.9rem;
    color: var(--ink);
    background: var(--card);
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    padding: 7px 10px;
    height: 38px;
    min-width: 0;
}
.field input::placeholder { color: var(--ink-faint); }
.field select:focus, .field input:focus {
    outline: none;
    border-color: var(--ink);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--ink) 9%, transparent);
}
.field select { min-width: 190px; max-width: 300px; }

.range-pair { display: flex; align-items: center; gap: 5px; }
.range-pair input { width: 80px; font-family: var(--mono); font-size: 0.85rem; }
.field--beds .range-pair input { width: 58px; }
.range-dash { color: var(--ink-faint); }

/* hide number spinners; they fight the compact layout */
.range-pair input::-webkit-outer-spin-button,
.range-pair input::-webkit-inner-spin-button { -webkit-appearance: none; }
.range-pair input[type="number"] { -moz-appearance: textfield; appearance: textfield; }

.searchform-actions { display: flex; gap: 8px; align-items: center; margin-left: auto; }
.searchform-actions .btn { height: 38px; line-height: 22px; }

.filters-advanced {
    display: none;
    gap: 22px;
    flex-wrap: wrap;
    margin-top: 14px;
    padding-top: 13px;
    border-top: 1px solid var(--rule);
}
.filters-advanced.show { display: flex; }

.checkbox-label {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: var(--t-small);
    color: var(--ink-soft);
    cursor: pointer;
}
.checkbox-label input { accent-color: var(--ink); width: 15px; height: 15px; }

/* --------------------------------------------------------- area combobox */

.combo { position: relative; }
.combo > input { width: 100%; }

.combo-list {
    position: absolute;
    z-index: 60;
    top: calc(100% + 5px);
    left: 0;
    right: 0;
    max-height: 340px;
    overflow-y: auto;
    list-style: none;
    background: var(--card);
    border: 1px solid var(--ink-faint);
    border-radius: var(--radius);
    box-shadow: var(--shadow-lift);
}
.combo-list[hidden] { display: none; }

.combo-opt {
    display: flex;
    align-items: baseline;
    gap: 10px;
    padding: 8px 12px;
    cursor: pointer;
}
.combo-opt + .combo-opt { border-top: 1px solid var(--paper-2); }
.combo-opt.active, .combo-opt:hover { background: var(--paper-2); }

.combo-name { flex: 1 1 auto; font-size: 0.9rem; min-width: 0; }
.combo-kind {
    font-family: var(--cond);
    font-size: 0.62rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--ink-soft);
}
.combo-opt.active .combo-kind { color: var(--ink-soft); }
.combo-n {
    font-family: var(--mono);
    font-size: 0.76rem;
    color: var(--ink-soft);
    font-variant-numeric: tabular-nums;
}
.combo-opt.active .combo-n { color: var(--ink-soft); }

.combo-empty {
    padding: 10px 12px;
    font-size: var(--t-small);
    color: var(--ink-soft);
}

/* -------------------------------------------------------------- view bar */

.viewbar {
    max-width: var(--max);
    margin: 0 auto;
    /* Part of the same vertical budget as the hero above: every band
       between the search box and the first card is a band of nothing on
       a 900px-tall laptop. */
    padding: 16px var(--pad) 0;
    display: flex;
    align-items: center;
    gap: 14px;
    flex-wrap: wrap;
}

.viewbar-group {
    display: flex;
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    overflow: hidden;
    background: var(--card);
}

.viewtab {
    font-family: var(--cond);
    font-size: 0.78rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ink-soft);
    text-decoration: none;
    padding: 7px 13px;
    border-right: 1px solid var(--rule);
    transition: background 0.15s, color 0.15s;
}
.viewtab:last-child { border-right: none; }
.viewtab:hover { color: var(--ink); background: var(--paper-2); }
.viewtab.active { background: var(--ink); color: var(--paper); }

.chips { display: flex; gap: 8px; flex-wrap: wrap; }

.chip {
    display: inline-flex;
    align-items: center;
    gap: 7px;
    font-family: var(--cond);
    font-size: 0.76rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--ink);
    background: var(--card);
    border: 1px dashed var(--rule);
    border-radius: var(--radius);
    padding: 5px 10px;
    text-decoration: none;
    transition: border-color 0.15s, color 0.15s;
}
.chip:hover { border-color: var(--danger); color: var(--danger); }
.chip-x { font-size: 1rem; line-height: 1; color: var(--ink-faint); }
.chip:hover .chip-x { color: var(--danger); }

/* ------------------------------------------------------------- the sheet */

.sheet {
    max-width: var(--max);
    margin: 0 auto;
    padding: 16px var(--pad) 60px;
}

.listing-head {
    display: flex;
    align-items: baseline;
    gap: 14px;
    flex-wrap: wrap;
    margin-bottom: 14px;
    padding-bottom: 10px;
    border-bottom: 1px solid var(--rule);
}

.listing-title {
    font-family: var(--cond);
    font-size: 1.05rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
}

.listing-count { font-size: var(--t-small); color: var(--ink-soft); }
.count-median { color: var(--ink-soft); }

/* ====================================================== SQFT OR SQUARE METRES
   Every size and every £-per-size is rendered TWICE by the server — once
   in each unit — and these two rules show one. That is the whole switch:
   no measurement is ever converted in the browser, so a card cannot be
   caught half-way between units, and the page stays byte-identical for
   every visitor (one render-cache entry, one CDN object) while the choice
   stays per-visitor.

   NEITHER RULE SETS A DISPLAY VALUE, only `none` — the twins are flex
   items (.dim-body on the card's dimension line; .ppsf-fig, because
   .ppsf-row is a flex container; the label and chip twins, whose parents
   are flex too), inline spans and a <small>, and each must keep whatever
   display its own rule and its own parent give it.

   The default arm is `:not([data-units="m2"])`, so it also covers the
   attribute being ABSENT: with JavaScript off nothing writes it, the m²
   twin never shows, and the page is square feet exactly as it shipped. */
:root:not([data-units="m2"]) .u-m2 { display: none; }
[data-units="m2"] .u-sqft { display: none; }

/* The toggle wears the viewbar's segmented-control clothes (.viewbar-group
   + .viewtab) and adds only what a <button> needs that an <a> did not.
   It is offered ONLY once the head script has written data-units: a
   control that cannot work must not be on the page. */
.unit-toggle { display: none; margin-left: auto; }
[data-units] .unit-toggle { display: flex; }
.unitbtn {
    background: none;
    border: none;
    border-right: 1px solid var(--rule);
    /* The button-as-text reset (.hidden-note, .stub-remove) is
       `font: inherit`. Not the shorthand here: this rule comes after
       .viewtab, which already restores the family, size and weight a
       <button> does not inherit, and the shorthand would undo them.
       line-height is the one part .viewtab leaves at the UA's `normal`,
       so without this the pills sit shorter than the <a> tabs they are
       dressed as. */
    line-height: inherit;
    cursor: pointer;
}
.unitbtn:last-child { border-right: none; }
/* The pressed state is read off <html>, not off a class some script has
   to keep in step with the figures — the same attribute that decides
   which twin is showing decides which button looks chosen. */
[data-units="sqft"] .unitbtn[data-unit="sqft"],
[data-units="m2"] .unitbtn[data-unit="m2"] {
    background: var(--ink);
    color: var(--paper);
}

.grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
    gap: 20px;
}

/* ------------------------------------------------------------------ card */

.card {
    background: var(--card);
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    overflow: hidden;
    box-shadow: var(--shadow);
    position: relative;
    display: flex;
    flex-direction: column;
    transition: box-shadow 0.18s, border-color 0.18s;
}
.card:hover { box-shadow: var(--shadow-lift); border-color: var(--ink-faint); }

.card.hidden { opacity: 0.55; }
.card.hidden::after {
    content: "hidden";
    position: absolute;
    top: 10px;
    left: 10px;
    font-family: var(--cond);
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    background: var(--ink);
    color: var(--paper);
    padding: 3px 8px;
    border-radius: var(--radius);
}

/* Photograph dominant, floorplan inset.
   These two images do different jobs and an equal split served neither.
   The floorplan is the PROOF — it is where the sqft on this card was
   measured, and it stays visible and zoomable. But it is mostly white
   space with hairlines in it, so at half the card it read as a blank
   panel while the photograph, the thing that makes someone want the
   flat, was cropped into a letterbox. So: the photo takes the whole
   media area and the plan sits on top of it as a small framed inset,
   the way a plan sits on an estate agent's board. */
.card-images {
    position: relative;
    display: flex;
    height: 204px;
    background: var(--paper-2);
    border-bottom: 1px solid var(--rule);
}

/* The blank underneath. Every card carries it; images simply sit on top,
   so a listing whose photo AND plan both fail upstream degrades to a
   quiet piece of drafting board with a label — never an empty box, and
   never a layout that shifts when an image drops out. */
.card-images::before {
    content: "no image";
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: var(--cond);
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--ink-faint);
    background-image:
        repeating-linear-gradient(to right, var(--rule) 0 1px, transparent 1px 22px),
        repeating-linear-gradient(to bottom, var(--rule) 0 1px, transparent 1px 22px);
    opacity: 0.75;
}

.card-images img {
    position: relative;   /* above the blank */
    flex: 1;
    min-width: 0;
    object-fit: cover;
    cursor: zoom-in;
    /* Opaque while loading, so the blank underneath is only ever seen
       once an image has actually FAILED. Without this, every card flashes
       "no image" for as long as its photo takes to arrive. */
    background: var(--paper-2);
}

.card-images img.floorplan {
    position: absolute;
    right: 10px;
    bottom: 10px;
    width: 37%;
    max-width: 134px;
    height: 50%;
    flex: none;
    object-fit: contain;
    background: #fff;
    /* Framed and lifted: a plan is nearly all white, so without an edge
       and a shadow it dissolves into a pale photograph instead of
       reading as a separate object laid on top of one. */
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    box-shadow: 0 2px 10px rgb(0 0 0 / 0.22);
    padding: 3px;
}
.card-images img.floorplan:hover { box-shadow: 0 3px 14px rgb(0 0 0 / 0.32); }

/* The photographs' own box. It exists so that the phone breakpoint has
   something to turn into a scroll-snap track WITHOUT taking the plan and
   the curation buttons with it — those are absolutely positioned over
   the media area, and an absolutely positioned child of a scroller
   scrolls away with the content.

   Here on desktop it is transparent by construction: a flex row that
   fills the media area, holding one visible photograph that .card-images
   img already styles (flex:1, object-fit:cover). The card renders
   exactly as it did before it existed. */
.card-gallery {
    flex: 1;
    min-width: 0;
    height: 100%;
    display: flex;
}

/* THE SWIPE TRACK'S EXTRA SLIDES ARE NOT A DESKTOP THING.
   display:none, not visibility and not opacity, and that is the point: a
   display:none <img> whose loading is "lazy" is never fetched, so a
   desktop reader downloads exactly the two PICTURES they downloaded
   before this feature existed. (display:none stops a lazy image, never
   an eager one — which is why the card script arms its prefetch on a
   track's own scroll event, an event a box that is not a scroller
   cannot fire. The extra MARKUP does ship to desktop: ~+7 KB gzip on a
   48-card page, measured 2026-09-10.)

   This rule is also what photoSurvives reads through getComputedStyle
   when it decides whether a card still has a photograph on it: on this
   side of the breakpoint a track full of healthy slides is still a card
   with nothing to show. Everything that makes .card-gallery a scroller
   is in the phone breakpoint.

   IT IS NO LONGER THE ONLY THING SAYING SO. Every slide also ships with
   the `hidden` attribute (templates/index.html), which says the same
   thing from the markup and therefore says it to a browser that does not
   have THIS FILE — the one client this rule could never reach. A phone
   holding a style.css from before the gallery existed found no rule here
   at all and painted all five photographs at full size, one under the
   next; that is the 2026-09-10 iPhone report. The two are not redundant:
   `hidden` is a UA-stylesheet display:none that the phone rule below
   overrides in the ordinary way, and this rule is the one the cascade
   and photoSurvives can both be asked about. */
.card-gallery img.slide { display: none; }

/* Sole survivor. The photo died upstream (or was never scraped) and the
   plan is all this listing has left, so it takes the whole area back —
   an inset floating on a blank drafting board would be a worse card than
   the plan itself. This should now be RARE: a dead photo arms the listing
   for a details re-fetch (db.mark_photo_stale), so seeing many of these
   means healing is not keeping up.

   THE CARD NOW STATES THIS, where the rule used to infer it.
   img.floorplan:only-of-type asked "is the plan the only <img> in this
   box", which was an exact proxy while a card held at most two pictures.
   It is not one any more: the photographs moved into .card-gallery, so
   the plan is ALWAYS the only <img> that is a direct child, and the rule
   would fire on every card that has a plan. Explicit writers replace it,
   between them covering both ways a card can end up with no
   photograph — Jinja writes .photos-dead when the listing has none at
   all, and at runtime imgGone and slideFailed each add it when the
   picture they have just given up on was the last one being painted.
   Nothing infers it any more.

   The empty photo box goes with it, and it has to: .card-gallery is a
   flex:1 child of the media area, so an empty one left in place would
   take half the width off a plan that is supposed to have taken the
   whole of it — a regression invisible in the markup and obvious on the
   card. */
.card-images.photos-dead .card-gallery { display: none; }
.card-images.photos-dead img.floorplan {
    position: relative;
    inset: auto;
    width: auto;
    height: auto;
    max-width: none;
    flex: 1;
    border: none;
    border-radius: 0;
    box-shadow: none;
    padding: 4px;
}

.hide-btn {
    position: absolute;
    top: 8px;
    right: 8px;
    width: 30px;
    height: 30px;
    border: none;
    border-radius: 50%;
    background: color-mix(in srgb, var(--ink) 55%, transparent);
    color: #fff;
    font-size: 1.15rem;
    line-height: 1;
    cursor: pointer;
    opacity: 0.65;
    transition: opacity 0.15s, background 0.15s;
    backdrop-filter: blur(2px);
}
.hide-btn:hover { opacity: 1; background: var(--ink); }

/* touch devices get no hover reveal — keep it comfortably tappable */
@media (hover: none) {
    .hide-btn { opacity: 0.85; width: 42px; height: 42px; font-size: 1.4rem; }
}

/* Bottom-LEFT: bottom-right is where the floorplan inset now sits. */
.unhide-btn {
    position: absolute;
    bottom: 8px;
    left: 8px;
    border: none;
    border-radius: var(--radius);
    background: var(--ink);
    color: var(--paper);
    font-size: 0.78rem;
    font-weight: 600;
    padding: 5px 10px;
    cursor: pointer;
}

/* ------------------------------------------------- curation (public) */
/* The heart and the per-browser ×. Same circle as the private hide-btn,
   but these never talk to the server — they write localStorage and the
   page repaints itself. Monochrome on purpose: red is the dimension
   line's colour and a saved heart is not a measurement. */
.save-btn,
.dismiss-btn {
    position: absolute;
    top: 8px;
    width: 30px;
    height: 30px;
    border: none;
    border-radius: 50%;
    background: color-mix(in srgb, var(--ink) 55%, transparent);
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    opacity: 0.65;
    transition: opacity 0.15s, background 0.15s;
    backdrop-filter: blur(2px);
}
.save-btn { right: 8px; }
.dismiss-btn { left: 8px; font-size: 1.15rem; line-height: 1; }
.save-btn:hover,
.dismiss-btn:hover { opacity: 1; background: var(--ink); }
.save-btn svg {
    width: 16px;
    height: 16px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
}
.save-btn[aria-pressed="true"] {
    opacity: 1;
    background: var(--ink);
}
.save-btn[aria-pressed="true"] svg { fill: #fff; }

@media (hover: none) {
    .save-btn, .dismiss-btn { opacity: 0.85; width: 42px; height: 42px; }
    .save-btn svg { width: 20px; height: 20px; }
    .dismiss-btn { font-size: 1.4rem; }
}

/* A dismissed card leaves the flow; "n hidden by you — show" brings this
   page's dismissals back dimmed, restore buttons and all. */
.card.dismissed { display: none; }
body.show-dismissed .card.dismissed { display: flex; opacity: 0.55; }

.hidden-note {
    border: none;
    background: none;
    padding: 0;
    margin-left: 6px;
    font: inherit;
    font-size: var(--t-small);
    color: var(--ink-soft);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}
.hidden-note:hover { color: var(--ink); }

/* Topbar: the way back to the list. The count badge is painted by the
   curation script and hidden at zero. */
.topbar-saved {
    margin-left: auto;
    align-self: center;
    font-family: var(--cond);
    font-size: var(--t-eyebrow);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--ink-soft);
    text-decoration: none;
    white-space: nowrap;
}
.topbar-saved:hover, .topbar-saved.active { color: var(--ink); }
.saved-count {
    display: inline-block;
    min-width: 17px;
    margin-left: 5px;
    padding: 1px 5px;
    border-radius: 999px;
    background: var(--ink);
    color: var(--paper);
    font-size: 0.68rem;
    text-align: center;
    letter-spacing: 0;
}
/* display:inline-block above would defeat the `hidden` attribute the
   script relies on at zero — an empty dark pill on every page. */
.saved-count[hidden] { display: none; }
/* Two margin-left:auto flex items split the free space between them,
   floating "Saved" mid-bar. The link takes the whole push; the CTA
   after it sits flush against it. */
.topbar-saved + .topbar-cta { margin-left: 0; }

/* /saved head — no hero, no form, just the name of the thing. */
.saved-title {
    font-family: var(--cond);
    font-size: 1.5rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--ink);
    margin: 0 0 4px;
}
.saved-sub { font-size: var(--t-small); color: var(--ink-soft); margin: 0; }
.saved-sub a { color: inherit; }

/* A saved flat the catalogue has let go: the client's stub, built from
   what the browser remembered at save time. */
.card-stub { border-style: dashed; box-shadow: none; }
.card-stub .card-body { gap: 5px; }
.stub-label {
    font-family: var(--cond);
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--ink-faint);
    margin: 0;
}
.stub-price { font-size: var(--t-small); color: var(--ink-soft); margin: 0; }
.stub-remove {
    border: none;
    background: none;
    padding: 0;
    margin-left: auto;
    font: inherit;
    font-size: var(--t-small);
    color: var(--ink-soft);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}
.stub-remove:hover { color: var(--ink); }

/* "Let agreed"/"removed" on a shortlist card — the fact said plainly. */
.gone-tag {
    color: var(--danger);
    font-weight: 600;
}

.card-body {
    padding: 13px 15px 14px;
    display: flex;
    flex-direction: column;
    gap: 7px;
    flex: 1;
}

/* £/sqft — the value question, in mono because it is a figure */
.ppsf-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 10px;
}

.ppsf-fig {
    font-family: var(--mono);
    font-size: 1.55rem;
    font-weight: 500;
    letter-spacing: -0.03em;
    font-variant-numeric: lining-nums tabular-nums;
    color: var(--ink);
    line-height: 1.1;
}

.ppsf-fig small {
    font-family: var(--cond);
    font-size: 0.66rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--ink-soft);
    margin-left: 4px;
}

.ppsf-fig.na { color: var(--ink-faint); }

.deal-great .ppsf-fig { color: var(--deal-great); }
.deal-good .ppsf-fig { color: var(--deal-good); }
.deal-high .ppsf-fig { color: var(--ink); }

.deal-tag {
    font-family: var(--cond);
    font-size: 0.72rem;
    font-weight: 600;
    /* Sentence case, not uppercase. The tag says "49% below London
       median" — a phrase, and uppercase set it shouting in a register
       that also cost it the noun ("49% BELOW LONDON" read as a label and
       had nowhere to put "median"). The tracking that made caps legible
       is dropped with them. */
    letter-spacing: 0.005em;
    padding: 3px 8px;
    border-radius: var(--radius);
    /* The tag now names the area it compares against, and London has
       "Kensington and Chelsea" in it. Wrapping inside the tag costs one
       short line on the few cards that need it; nowrap cost 27px of
       overflow on every card in that borough. */
    white-space: normal;
    text-wrap: balance;
    line-height: 1.25;
    text-align: right;
    max-width: 62%;
}
.deal-great .deal-tag { background: var(--deal-great-bg); color: var(--deal-great); }
.deal-good .deal-tag { background: var(--deal-great-bg); color: var(--deal-good); }
.deal-high .deal-tag { background: var(--deal-high-bg); color: var(--deal-high); }

/* Out of band for its peers. Deliberately the quietest tag on the card and
   drawn as an unfinished note — dashed rule, no fill — because it is not an
   accusation, it is an admission: we have not checked this one. The figure
   beside it loses its colour for the same reason. */
.deal-unchecked .ppsf-fig { color: var(--ink-faint); }
/* A price caveat is not the same admission. "Unchecked" doubts the FIGURE
   — the floorplan may have been read too large — so the figure fades with
   it. "Short let" and "price unclear" doubt only what the figure may be
   COMPARED with: £5.19/sqft is still £1,712 over 330 sqft and still true
   of this listing. It loses its colour, not its legibility. */
.deal-caveat .ppsf-fig { color: var(--ink-soft); }
/* ...and a third member of that set: a home whose map pin and postcode
   disagree by more than three miles, or whose postcode is not in London at
   all (location_audit.py: 147 + 5 live rows). It is the same kind of
   sentence as "unchecked" — an admission that WE could not settle
   something, not an accusation about the listing — so it wears the same
   dashed rule rather than a fourth visual language. */
.deal-unchecked .deal-tag,
.location-unverified .deal-tag,
.deal-caveat--unclear .deal-tag {
    color: var(--ink-faint);
    background: transparent;
    border: 1px dashed var(--rule);
    padding: 2px 7px;
}
/* ...and "short let" is neither doubt nor admission — it is a fact we
   read off the listing, so it is set as a plain label. The dashed rule is
   reserved for the two tags that mean "we could not check this". */
.deal-caveat--short .deal-tag {
    color: var(--ink-soft);
    background: var(--paper-2);
}
.price-row { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }

.price-pcm {
    font-family: var(--mono);
    font-size: 1rem;
    font-weight: 500;
    color: var(--ink-soft);
    font-variant-numeric: tabular-nums;
}
.price-pcm small {
    font-family: var(--cond);
    font-size: 0.66rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--ink-soft);
    margin-left: 3px;
}

.reduced-tag {
    font-family: var(--mono);
    font-size: 0.78rem;
    color: var(--value);
    text-decoration-color: color-mix(in srgb, var(--value) 55%, transparent);
}

.negotiable-tag {
    font-family: var(--cond);
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--ink-soft);
    background: var(--paper-2);
    border-radius: var(--radius);
    padding: 2px 8px;
    white-space: nowrap;
}

/* The same home, posted more than once. No longer a pill announcing a
   count — a lead-in and one link per sibling, because the only useful
   thing to do with "there is another listing" is open it. */
.group-row {
    font-family: var(--cond);
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    /* --ink-soft, not --ink-faint: this is a sentence a reader has to
       read, and --ink-faint is documented decoration-only (2.6:1). */
    color: var(--ink-soft);
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 4px 7px;
}

.group-sib {
    color: var(--ink-soft);
    background: var(--paper-2);
    border-radius: var(--radius);
    padding: 3px 8px;
    text-decoration: none;
    white-space: nowrap;
    transition: color 0.12s, background 0.12s;
}
.group-sib:hover {
    color: var(--ink);
    background: var(--rule);
}

/* Address as a drawing label: condensed, uppercase, tracked. */
.addr-row {
    font-family: var(--cond);
    font-size: 0.86rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.055em;
    color: var(--ink);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.spec-row { font-size: var(--t-small); color: var(--ink-soft); }
.spec-sep { color: var(--rule); margin: 0 3px; }
.spec-area { color: var(--ink-soft); }

.meta-row {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-top: auto;
    padding-top: 8px;
    border-top: 1px solid var(--paper-2);
    flex-wrap: wrap;
}

.meta-chip {
    font-family: var(--cond);
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--ink-soft);
    background: var(--paper-2);
    border-radius: var(--radius);
    padding: 2px 9px;
}

.meta-time {
    font-family: var(--cond);
    font-size: 0.73rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--ink-soft);
}

.card-link {
    margin-left: auto;
    font-family: var(--cond);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ink-soft);
    text-decoration: none;
    border-bottom: 1px solid var(--rule);
    transition: color 0.15s, border-color 0.15s;
}
.card-link:hover { color: var(--ink); border-color: var(--ink); }

/* -------------------------------------------------------------- map view */

.map-canvas {
    height: calc(100vh - 320px);
    min-height: 480px;
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    background: var(--paper-2);
}

.map-pop { font-family: var(--sans); min-width: 180px; }
.map-pop img { width: 100%; height: 110px; object-fit: cover; border-radius: 2px; margin-bottom: 6px; }
.map-pop-ppsf { font-family: var(--mono); font-size: 1.25rem; font-weight: 500; line-height: 1.1; }
.map-pop-ppsf small { font-family: var(--cond); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-soft); }
.map-pop-price { font-family: var(--mono); font-size: 0.8rem; color: var(--ink-soft); margin: 3px 0; }
.map-pop-addr { font-family: var(--cond); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink); margin-bottom: 4px; }
.map-pop a { font-family: var(--cond); font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-soft); }

/* ----------------------------------------------------------- empty state */

.empty-state {
    grid-column: 1 / -1;
    text-align: center;
    padding: 80px 20px;
    border: 1px dashed var(--rule);
    border-radius: var(--radius);
    background: color-mix(in srgb, var(--card) 60%, transparent);
}

.empty-title { font-size: 1.6rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; }
.empty-sub { color: var(--ink-soft); margin-bottom: 22px; }

/* ------------------------------------------------------------ pagination */

.pagination {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 16px;
    margin-top: 36px;
}

.pagination-info {
    font-family: var(--cond);
    font-size: var(--t-small);
    text-transform: uppercase;
    letter-spacing: 0.07em;
    color: var(--ink-soft);
}

/* --------------------------------------------------------- how it works */

.explain {
    margin-top: clamp(56px, 8vw, 92px);
    padding-top: 16px;
    border-top: 1px solid var(--ink);
}

.steps {
    list-style: none;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
    gap: clamp(20px, 3vw, 40px);
    margin-top: 22px;
}

.step { padding-top: 14px; border-top: 1px solid var(--rule); }

.step-n {
    font-family: var(--mono);
    font-size: 0.78rem;
    font-weight: 500;
    letter-spacing: 0.12em;
    color: var(--ink-soft);
}

.step-h { font-size: 1.05rem; font-weight: 600; margin: 8px 0 6px; letter-spacing: -0.01em; }
.step-p { font-size: var(--t-small); line-height: 1.6; color: var(--ink-soft); max-width: 32ch; }

/* -------------------------------------------------------------- telegram */

.cta {
    margin-top: clamp(36px, 5vw, 56px);
    background: var(--card);
    border: 1px solid var(--rule);
    border-left: 3px solid var(--ink);
    border-radius: var(--radius);
    padding: 22px clamp(18px, 3vw, 28px);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 24px;
    flex-wrap: wrap;
}

.cta-p {
    margin-top: 7px;
    font-size: var(--t-body);
    color: var(--ink-soft);
    max-width: 46ch;
}

/* -------------------------------------------------------------- lightbox */

.lightbox {
    position: fixed;
    inset: 0;
    z-index: 100;
    display: none;
    align-items: center;
    justify-content: center;
    background: color-mix(in srgb, var(--ink) 90%, transparent);
    cursor: zoom-out;
    padding: 3vh 3vw;
}
.lightbox.show { display: flex; }

.lightbox img {
    max-width: 100%;
    max-height: 100%;
    border-radius: var(--radius);
    background: #fff;
    box-shadow: var(--shadow-lift);
}

.lightbox-close {
    position: absolute;
    top: 16px;
    right: 20px;
    width: 40px;
    height: 40px;
    border: none;
    border-radius: 50%;
    background: var(--paper);
    color: var(--ink);
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
}

/* --------------------------------------------------------- settings page */
/* Private build only. `.section` is the settings container — the listing
   page uses `.sheet`, so the two can no longer collide. */

.section {
    max-width: var(--max);
    margin: 0 auto;
    padding: 18px var(--pad) 60px;
}

.section-title {
    font-family: var(--cond);
    font-size: 1rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    margin: 30px 0 14px;
}

.flash-messages { max-width: var(--max); margin: 14px auto 0; padding: 0 var(--pad); }

.flash {
    padding: 10px 14px;
    border-radius: var(--radius);
    font-size: 0.88rem;
    font-weight: 500;
    margin-bottom: 8px;
    border: 1px solid;
}
.flash-success { background: var(--deal-great-bg); color: var(--deal-great); border-color: color-mix(in srgb, var(--deal-great) 30%, transparent); }
.flash-error { background: #F7E4E0; color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, transparent); }

.search-cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
    gap: 20px;
}

.search-card, .add-search-card, .help-card {
    background: var(--card);
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    padding: 18px;
}

.search-card-header {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 10px;
    margin-bottom: 12px;
}

.search-card-header h3 { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.01em; }

.btn-delete {
    background: none;
    border: none;
    color: var(--ink-faint);
    font-size: 0.8rem;
    font-weight: 600;
    cursor: pointer;
    padding: 4px 6px;
    border-radius: var(--radius);
}
.btn-delete:hover { color: var(--danger); background: #F7E4E0; }

.search-form { display: flex; flex-direction: column; gap: 12px; }

.form-row-group { display: flex; gap: 12px; }
.form-row-group .form-row { flex: 1; }

.form-row { display: flex; flex-direction: column; gap: 4px; }

.form-row label {
    font-family: var(--cond);
    font-size: var(--t-eyebrow);
    font-weight: 600;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: var(--ink-faint);
}

.form-row input, .form-row select {
    font-family: var(--sans);
    font-size: 0.88rem;
    color: var(--ink);
    background: var(--card);
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    padding: 7px 9px;
}
.form-row input:focus, .form-row select:focus { outline: none; border-color: var(--ink); }
.form-row input[readonly] { background: var(--paper-2); color: var(--ink-soft); }

.search-status {
    display: flex;
    gap: 14px;
    flex-wrap: wrap;
    font-size: var(--t-small);
    color: var(--ink-faint);
    border-top: 1px solid var(--rule);
    padding-top: 10px;
}

.status-pending { color: var(--danger); font-weight: 600; }

.status-link {
    color: var(--ink-soft);
    font-weight: 600;
    text-decoration: none;
    border-bottom: 1px solid var(--rule);
}
.status-link:hover { color: var(--ink); border-color: var(--ink); }

.btn-save { align-self: flex-start; }
.btn-save:disabled { opacity: 0.4; cursor: default; }
.btn-save:not(:disabled) { background: var(--ink); color: var(--paper); }

.lookup-section { margin-bottom: 4px; }
.lookup-input-row { display: flex; gap: 8px; }
.lookup-input-row input { flex: 1; }

.lookup-status { font-size: 0.85rem; margin-top: 8px; }
.lookup-status.loading { color: var(--ink-faint); }
.lookup-status.error { color: var(--danger); font-weight: 500; }

.radio-option {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 0.88rem;
    padding: 6px 4px;
    cursor: pointer;
}
.radio-option input { accent-color: var(--ink); }

.divider { border: none; border-top: 1px solid var(--rule); margin: 16px 0; }

.help-text { font-size: 0.78rem; color: var(--ink-faint); }
.help-note { margin-top: 14px; font-size: 0.85rem; color: var(--ink-soft); }
.help-card ol { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
.help-card a { color: var(--ink); }
.help-card code {
    font-family: var(--mono);
    background: var(--paper-2);
    border-radius: 2px;
    padding: 1px 5px;
    font-size: 0.85em;
}

.no-searches { color: var(--ink-faint); font-style: italic; }

.inline-form { display: inline; }

/* ------------------------------------------------------------ responsive */

@media (max-width: 900px) {
    .searchform-actions { margin-left: 0; }
}

@media (max-width: 760px) {
    .topbar-inner { flex-wrap: wrap; gap: 6px 16px; }
    /* On a phone the bar stays one clean row — wordmark left, CTA right —
       and the tagline takes its own full line beneath. */
    .topbar-note { padding-left: 0; border-left: none; order: 3; flex-basis: 100%; }
    .topbar-stats { order: 3; flex-basis: 100%; }

    /* Below the desktop clamp's own floor, so the phone never gets MORE
       masthead than the laptop does. */
    .deck--hero { padding-top: 20px; }
    .searchform-row { gap: 14px; }
    .field--area { flex-basis: 100%; max-width: none; }
    .range-pair input { width: 72px; }

    .viewbar { gap: 10px; padding-top: 18px; }

    .grid { grid-template-columns: 1fr; }
    /* One card per row, so the media area is much wider than on desktop —
       give the photograph the height to match, and cap the inset in
       absolute terms so it does not scale into a second photograph. */
    .card-images { height: 240px; }
    .card-images img.floorplan { width: 34%; max-width: 124px; height: 48%; }

    /* THE SWIPE GALLERY. A phone gets one card per row and a thumb that
       already swipes everything else it touches, so the media area shows
       the listing's photographs rather than one of them.

       No carousel, no library, no JS. .card-gallery is already a flex
       row, so making it a scroller with x-mandatory snapping IS the
       whole mechanism: the browser owns the physics, the momentum and
       the rubber-banding, and none of that is code anyone here has to
       maintain or get wrong on a device nobody tested.

       It is a native scroll container, so it is announced as one and
       every photograph keeps its alt text in reading order — which a
       JS carousel does not give for free. Browsers that make a
       scrollable box keyboard-focusable when it holds no focusable
       descendants (Chrome 127+) therefore put each track in the tab
       order at this breakpoint, and that is LEFT ALONE deliberately: it
       is the only way a keyboard reaches the arrow keys that scroll the
       track, and taking it away with tabindex="-1" would make the extra
       photographs mouse-and-thumb-only. The cost is real and accepted —
       one stop per gallery card ahead of the page's own controls.

       .card-images keeps its FIXED HEIGHT, which is what stops layout
       shift today and matters more here than anywhere — a track of
       images with no intrinsic sizing, on the one surface this feature
       targets, is exactly how CLS gets reintroduced. Height fixed, each
       slide exactly one container wide: nothing inside this can move the
       card by a pixel.

       Only .has-gallery scrolls. A single-photograph card — roughly half
       the catalogue, since the archive covers ~35k listings against a
       catalogue of 80k+ — keeps the plain flex box it has always had:
       no scroll container, no snap points, nothing to swipe and nothing
       to hint at. That is a first-class state, not a fallback. */
    .card-images.has-gallery .card-gallery {
        overflow-x: auto;
        overflow-y: hidden;
        scroll-snap-type: x mandatory;
        /* Momentum on iOS, and no visible bar anywhere: the media area
           is a photograph, and a scrollbar ruled across the bottom of it
           reads as a rendering fault rather than an affordance.

           NOTHING REPLACES IT, AND THAT IS A DECISION. There is no peek:
           every panel is flex-basis 100% of the scrollport with no gap,
           no padding and no scroll-padding (the rule below), so at rest
           the next photograph is entirely outside the clip and a gallery
           card is pixel-identical to the single-photograph card beside
           it. The swipe is discovered by being tried. A ~92% basis would
           show a sliver of the next one, and it was weighed and refused
           on two counts:

             * scroll-snap-type here is `x mandatory`. Under 100% basis
               the LAST panel's start edge sits PAST the maximum scroll
               offset, so it is not a reachable snap point and the track
               rubber-bands off it — the final photograph becomes
               unreachable. Exact end padding cancels the difference on
               paper and lands precisely on the boundary, where sub-pixel
               rounding decides whether a reader ever sees it.
             * a sliver a reader can SEE is a picture the browser has to
               paint, and no engine defers loading="lazy" on an image it
               is painting. Panel two of every gallery card would be
               fetched at page load — up to 48 variant requests in one
               breath against a relay that encodes them at 4/s
               (image_relay.GALLERY_RATE_PER_SEC). That is the exact
               burst the card script was rewritten to stop
               (templates/index.html, warmNeighbours), bought back in
               CSS.

           So the cost of hiding the bar is a feature a reader may never
           find, and it is the smaller cost: a reader who never swipes
           gets the card that shipped before this existed. */
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
        /* A swipe that runs off the end must not become a swipe of the
           page behind it. */
        overscroll-behavior-x: contain;
    }
    .card-images.has-gallery .card-gallery::-webkit-scrollbar { display: none; }
    /* The thumbnail and the extra slides are the same thing here:
       full-width panels of the card's fixed height, each its own snap
       point. flex:0 0 100% overrides the flex:1 that would otherwise
       squeeze six photographs into one card's width.

       "THE SAME THING" IS A RULE THE SCRIPT OBEYS TOO. Because this
       selector is `> img` and not `img.slide`, the thumbnail is panel
       one of the track and not a picture standing beside it — so when it
       dies, imgGone hands it to slideFailed rather than removing it
       (templates/index.html). Removing it here would shorten a track a
       thumb is already moving through. The floorplan is untouched by
       this rule and stays removable: it is a sibling of .card-gallery,
       laid over the media area, not a panel of anything. */
    .card-images.has-gallery .card-gallery > img {
        flex: 0 0 100%;
        width: 100%;
        height: 100%;
        scroll-snap-align: start;
        /* One photograph per swipe. Without this a hard flick skates
           past three of them, which on a five-photograph gallery means
           the reader never sees most of it. */
        scroll-snap-stop: always;
    }
    .card-images.has-gallery .card-gallery img.slide { display: block; }
    /* A PANEL whose photograph failed — any panel, which is why this
       asks for .slide-failed inside the track and not for img.slide: a
       gallery card's thumbnail carries no class="slide" and is panel one
       all the same, so imgGone marks it with this and leaves it where it
       is. It KEEPS ITS PLACE in the track — removing it would shorten
       what a thumb is already moving through (see slideFailed) — and
       shows the same drafting board an empty card shows, so it reads as
       a missing picture and not as a fault.

       Scoped to this breakpoint on purpose, and harmless: outside it a
       track has no failed panels to paint. The slides are display:none
       so they never load and never fail, and a thumbnail that dies takes
       every panel of its card with it — nothing is left painted, so
       photoSurvives lands .photos-dead and the rule above hides the
       whole track.

       pointer-events:none because it is still an <img> carrying
       onclick="openLightbox(this)" and .card-images img's zoom-in
       cursor, while slideFailed has taken its src away: without this the
       dead panel offers a magnifying glass and opens an empty lightbox.
       This takes the offer away; openLightbox refuses an empty source
       too, so the click cannot do damage by any other route. */
    .card-gallery img.slide-failed {
        pointer-events: none;
        background-image:
            repeating-linear-gradient(to right, var(--rule) 0 1px, transparent 1px 22px),
            repeating-linear-gradient(to bottom, var(--rule) 0 1px, transparent 1px 22px);
        opacity: 0.75;
    }

    /* THE ONLY THING ON THE CARD THAT SAYS THE TRACK IS THERE.
       Every panel is one scrollport wide with no peek, deliberately (the
       rule above says why), so at rest a gallery card and a
       single-photograph card are the same picture and the swipe is
       discovered only by being tried. d asked for "a bunch of bubbles at
       the middle bottom of the image to indicate there is more"; this is
       them.

       THIS IS THE RULE THAT RENDERS THEM AT ALL. The markup ships
       `hidden` (templates/index.html), the same contract the slides took
       at f3cf815 — so a client holding a style.css that never heard of
       .card-gallery paints no indicator for a scroller it is not going to
       build, and the wide card needs no display:none of its own because
       rendering was never the default. `hidden` is a UA display:none and
       this is an author declaration, so this wins outright; no
       !important, the same way `img.slide { display: block; }` above
       wins.

       :not(.photos-dead) IS THE HONEST HALF. When the last photograph on
       a card dies, imgGone/slideFailed land .photos-dead, the track goes
       and the plan takes the media area back — dots floating over a
       floorplan would be pointing at nothing. It is written into the
       reveal rather than as a later override on purpose: a separate
       `.card-images.photos-dead .gallery-dots { display: none; }` would
       LOSE to this selector outright — :not() counts its argument, so
       the reveal is (0,4,0) against the override's (0,3,0), regardless
       of source order (measured in WebKit and Chromium, 2026-09-10).
       One rule that cannot be out-ordered beats two rules whose fight
       an author has to remember.

       ABSOLUTELY POSITIONED, so it takes no space and cannot move the
       card by a pixel — the fixed-height media area is the whole CLS
       story on this surface and nothing here is allowed to join it. */
    .card-images.has-gallery:not(.photos-dead) .gallery-dots {
        display: flex;
        position: absolute;
        /* Bottom-CENTRE of the photo area, and it has to clear three
           things that are already on it. The curation buttons (heart,
           ×, hide) are all top:8px, so they are not in this band at all.
           The floorplan inset is the tight one: right:10px, width:34%
           capped at 124px. Six dots is the widest this can ever be —
           db.GALLERY_CARD_SLIDES caps the track at six panels — which is
           6*6 + 5*4 + 2*7 = 70px, so 35px either side of centre.
           Narrowest card the page admits is the 360px floor: 360 - 2*14
           (--pad clamps to its 14px minimum below 560px) = 332px, whose
           inset starts at 332 - 10 - 112.9 = 209px against a pill ending
           at 166 + 35 = 201px. It clears, and it clears by more at every
           width above that (12.9px at 390, ~190px at 760). The private
           build's .unhide-btn shares this band at left:8px and ends
           ~70px in, nowhere near the middle.

           Same 10px inset from the bottom as the floorplan, so the two
           pieces of furniture on this edge line up rather than nearly
           lining up. */
        left: 50%;
        transform: translateX(-50%);
        bottom: 10px;
        gap: 4px;
        padding: 4px 7px;
        /* A PILL, BECAUSE WHITE DOTS ALONE VANISH. Half this catalogue's
           first photograph is a bright room or a sky, and an unbacked
           white dot on it is invisible exactly when the card most needs
           to be saying something. Same translucent ink and the same blur
           as the curation buttons a few pixels above it, so the media
           area has one vocabulary of overlay furniture rather than two.
           Round, for the same reason those are round — the drafting
           squareness is the paper's language, not the photograph's. */
        border-radius: 999px;
        background: color-mix(in srgb, var(--ink) 48%, transparent);
        backdrop-filter: blur(2px);
        /* It is an indicator, not a control: the swipe is the track's,
           and a tap landing here must reach the photograph underneath. */
        pointer-events: none;
    }
    .gallery-dot {
        width: 6px;
        height: 6px;
        border-radius: 50%;
        background: rgb(255 255 255 / 0.45);
        transition: background 0.18s ease;
    }
    /* Where the reader is. Jinja lights the first one, so the count and
       the position are both readable with the script switched off; the
       track's own scroll handler moves it from there (markActiveDot). */
    .gallery-dot.active { background: #fff; }
    /* A DEAD PANEL'S DOT GOES. slideFailed keeps the panel — shortening a
       track under a moving thumb is the harm it exists to refuse — but
       the photograph in it is gone, and a dot offering it would be the
       indicator lying about the only thing it says. .gallery-dot declares
       no display of its own, so the platform's `hidden` would carry this
       unaided; it is stated because a display added to the rule above
       would silently outrank it and take the honesty with it (the
       .chips[hidden] lesson, at the end of this file). */
    .gallery-dot[hidden] { display: none; }

    .form-row-group { flex-direction: column; gap: 12px; }
}

/* 360px floor: nothing may overflow the viewport at the narrowest phone. */
@media (max-width: 420px) {
    .searchform { padding: 13px; }
    .searchform-row { gap: 12px; }
    .field { flex: 1 1 100%; }
    .range-pair { width: 100%; }
    .range-pair input { flex: 1 1 0; width: auto; }
    .searchform-actions { width: 100%; }
    .searchform-actions .btn { flex: 1 1 auto; padding: 8px 10px; }
    .hero-line { font-size: 2rem; }
    .viewbar-group { flex: 1 1 auto; }
    .viewtab { flex: 1 1 auto; text-align: center; padding: 7px 9px; }
    .cta .btn { width: 100%; }
}

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

/* The boundary and place-name licences ask to be credited; small, quiet,
   and at the end, where a credit belongs — but legible, not hidden. */
.attribution {
    max-width: var(--max);
    margin: 0 auto;
    padding: 18px var(--pad) 40px;
    border-top: 1px solid var(--rule);
}
.attribution p {
    margin: 0;
    font-size: var(--t-small);
    line-height: 1.6;
    color: var(--ink-soft);
}
/* Terms, privacy, contact: in the same quiet register as the credits,
   and only rendered once those pages exist. */
.legal-links {
    display: flex;
    flex-wrap: wrap;
    gap: 6px 18px;
    margin: 0 0 8px;
    font-size: var(--t-small);
}
.legal-links a { color: var(--ink-soft); text-underline-offset: 2px; }
.legal-links a:hover { color: var(--ink); }

/* The one plain sentence that every size on the page is an estimate.
   Under the listing head, in its small grey register: a caveat the
   reader can find, not a banner that shouts over the figures. */
.estimate-note {
    margin: -6px 0 14px;
    font-size: var(--t-small);
    line-height: 1.5;
    color: var(--ink-soft);
}

/* THE FACTS CARD — the image kill switch off (TASKS.md §6). No media
   area at all; the heart and the × that normally float over the
   photograph sit in a plain row above the facts instead. Static, not
   absolute: there is no picture for them to sit on, and over the
   dimension line they would cover the figure the card exists for. */
.card-actions {
    display: flex;
    flex-direction: row-reverse;
    justify-content: space-between;
    gap: 8px;
    padding: 8px 8px 0;
}
.card-actions .save-btn,
.card-actions .dismiss-btn,
.card-actions .hide-btn {
    position: static;
}
.card-actions:empty { display: none; }

/* ------------------------------------------------------------ legal pages */

.legal {
    max-width: 46rem;
    margin: 0 auto;
    padding: 28px var(--pad) 60px;
}
.legal h1 {
    font-family: var(--cond);
    font-size: 1.35rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    margin: 0 0 4px;
}
.legal .legal-updated {
    font-size: var(--t-small);
    color: var(--ink-soft);
    margin: 0 0 22px;
}
.legal h2 {
    font-family: var(--cond);
    font-size: 1rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.07em;
    margin: 26px 0 8px;
}
.legal p,
.legal li { line-height: 1.6; }
.legal p { margin: 0 0 10px; }
.legal ul { margin: 0 0 12px 1.2rem; }
.legal li { margin: 0 0 6px; }
.legal a { color: var(--ink); text-underline-offset: 2px; }
.legal address { font-style: normal; }


/* The station distance control. Sits under the area box rather than
   beside it: it only exists for one kind of answer, and giving it a
   column of its own would leave a permanent gap in the filter row for
   every search that is not a station. */
.field-radius { margin-top: 6px; }
.field-radius[hidden] { display: none; }
/* Sized to its own content, not to the area box above it: "within 0.5
   mi" in a 300px control reads as a field somebody forgot to fill in. */
.field-radius select { width: auto; min-width: 140px; }

/* The kind badge already exists for boroughs and postcodes; a station
   gets a stronger one because it is the distinction that actually
   misleads — "Clapham" the district and "Clapham High Street" the
   platform are different searches with nearly the same name. */
.combo-opt .combo-kind { white-space: nowrap; }

/* Selected areas, acknowledged inside the Area field itself: after each
   add the page reloads, and a chip on the viewbar's far right was
   invisible from the box (adding areas on a laptop read as a bug). */
.area-chips { margin-bottom: 6px; gap: 6px; }

/* In-place search updates: the swap is fast but not instant, so the
   stale results dim a touch while the fresh ones are fetched — enough
   for the click to visibly land, never a spinner. */
.fs-loading main.sheet,
.fs-loading .viewbar { opacity: 0.55; transition: opacity 0.12s ease; }
.fs-loading { cursor: progress; }

/* .chips wins the cascade against the UA's [hidden]{display:none} (author
   flex beats user-agent none at normal importance), so the always-rendered
   empty #area-chips container was "hidden" yet still holding a flex-gap
   slot — ~11px of phantom space above the area box on every no-selection
   page. State the obvious explicitly. */
.chips[hidden] { display: none; }

/* The distance control now PRECEDES the add box in the DOM — that is what
   pairs its value with the one selected place (or, on the empty page,
   with the first typed location via the server's lead-radius rule). It
   still PAINTS below the box, where it has always lived. */
.field--area .field-radius { order: 1; }

/* The pair store is a flex ITEM of the Area field (a span exists so the
   in-place script can swap the hidden pairs wholesale), and a zero-height
   flex item still claims a 5px gap slot — the same phantom-space defect
   as .chips[hidden] above. Hidden inputs serialize regardless. */
#pair-store { display: none; }
