/* Development single — "Available homes" plot grid.
   Figma reads: available card 35:713, greyed variants 35:837 (reserved) /
   35:1011 (sold) sampled from the 35:874–35:1011 subtree, badge group 35:263,
   promo strip 57:3 (fill) + 57:4 (text), heading 67:2863.

   NOTE on the heading node: the task brief pointed at 3:889 for the heading —
   that node is actually the filter-bar group (Reset / Sort by beds /
   Available only / Sort by price / Max price, Task 5's UI), not the
   "Available homes" heading. The real heading text node is 67:2863; sampled
   that instead. Flag to Liam. */

.vl-dev-plots {
    /* Section padding not in this task's read list (filter bar between
       heading and grid is Task 5). Top/bottom derived from frame deltas:
       anchor-bar bottom (1080+82=1162) to heading top (1251) = 89px;
       last card row bottom (2585+572=3157) to next section top
       (site-plan bg rect, y=3257) = 100px. */
    padding-block: 89px 100px;
}

.vl-dev-plots__heading {
    /* 40px below = gap to the plot grid on the same-row layout (the filter
       bar shares this heading's row per the frame — see .vl-plot-filters). */
    margin: 0 0 40px;
    color: var(--vl-black); /* node fill is literally #000000, not --vl-ink (same convention as dev-anchor.css) */
    font-size: var(--vl-text-display); /* 70px, node 67:2863 */
    font-weight: var(--vl-weight-regular);
    line-height: 1; /* = font-size at every breakpoint (Liam, 2026-07-23: big headings line-height equals size) */
}

.vl-dev-plots__heading em {
    /* Figma renders "homes" in a heavier face ("Omnes:Medium") than
       "Available" (Regular) — not italic. Semibold is the closest face
       this kit serves; established Vivly mixed-weight heading pattern. */
    font-style: normal;
    font-weight: var(--vl-weight-semibold);
}

/* ── Filter bar (Task 5) — Figma node 3:889 "Group 39". Metadata read gave
   exact x/width for every child (get_metadata, not just the code sample):
     Reset          x=876  w=43   (button, right edge 919)
     checkbox+label x=949  w=161  (box 949–999, label starts 1007 → 8px gap)
     Sort by beds   x=1140 w=200
     Sort by price  x=1350 w=200
     Max price      x=1560 w=200
   Gaps derived: Reset→check-group = 30px, check-group→selects = 30px,
   select→select = 10px (1350−1340, 1560−1550 — both exact).
   ⚠️ Liam's verbal style note called these "pill-shaped dropdowns", but the
   node's own rounded-rectangle radius is 10px (= --vl-radius-field, the
   existing newsletter-field token), not --vl-radius-pill (100px). Built to
   the node's real value; flag to Liam — the boxes read as gently-rounded
   rectangles, not full pills, in the actual frame. Same 10px radius/1px
   ink border/cream fill is shared by the checkbox and all three selects
   (all three "Rectangle 28" nodes are visually identical). No separate
   background "band" exists behind the row in the frame — the row sits on
   whatever the section background already is. */
.vl-plot-filters {
    display: flex;
    flex-wrap: wrap; /* mobile/tablet fallback — see breakpoint block below */
    align-items: center;
    gap: 30px;
    row-gap: 16px;
    /* Same-row layout per the frame (Liam review 2026-07-23): heading left,
       filters right-aligned to the content edge (group 3:889 ends at x=1760).
       The form must stay a direct container child for position:sticky to keep
       working, so it's pulled up beside the heading with a negative margin:
       heading 75px + its 40px gap + 5px (frame: filter boxes sit 11px below
       the heading top; minus this row's 16px padding) = -120px.
       It spans the full row (the ::before spacer pushes the controls right);
       the empty span over the heading is click-through — pointer-events
       re-enabled per control below. */
    /* Pull-up onto the heading's row, VERTICALLY CENTRED on it (Liam,
       2026-08-26). Was a hardcoded -120px, which measured 3px high of the
       heading's centre — close, but a magic number that would drift the moment
       the heading size or this row's padding changed.

       Derived instead: the row would naturally start at
           heading height (--vl-text-display, line-height 1)
         + the heading's own 40px margin-bottom
       and to centre a row of height R on a heading of height H it needs to sit
       (H − R) / 2 below the heading's top. R here is 82px = this row's 16px
       padding-block, twice, plus the 50px control height.

       ⚠️ If either the control height (50px) or padding-block (16px) changes,
       update the 82px — it is the one number here that cannot be read from a
       token. Verified by measuring the live page: control mid and heading-text
       mid land on the same pixel. */
    margin-top: calc((var(--vl-text-display) - 82px) / 2 - var(--vl-text-display) - 40px);
    margin-bottom: 70px; /* 70px above the card grid (Liam, 2026-07-23; was 40) */
    margin-inline: 0;
    padding-block: 16px; /* stuck bar needs an opaque body — see .is-stuck */
    position: sticky;
    top: var(--vl-dev-anchor-h); /* sticks directly beneath the anchor bar */
    z-index: 20; /* below the anchor bar (30); above ordinary section content */
    background: transparent; /* cream only when stuck — transparent on the
                                heading row so the heading shows through */
    transition: background-color 0.25s ease;
    pointer-events: none;
}

.vl-plot-filters > * {
    pointer-events: auto;
}

/* The slide (Liam, 2026-07-23): this spacer right-aligns the controls on the
   heading row; flex-grow IS animatable, so collapsing it when the bar pins
   slides the controls across to the left. The -30px start margin cancels the
   flex gap the zero-width spacer would otherwise leave before Reset once
   collapsed. */
.vl-plot-filters::before {
    content: '';
    flex-grow: 1;
    margin-inline-start: -30px;
    transition: flex-grow 0.35s ease;
}

/* Stuck (pinned under the anchor bar): the pre-row-layout look — full-width
   opaque cream band (cards never scroll illegibly beneath the controls:
   cold-review finding), controls slid over to the left. dev-filters.js
   toggles .is-stuck via IntersectionObserver; CSS alone can't sense a pin. */
.vl-plot-filters.is-stuck {
    background: var(--vl-cream);
    pointer-events: auto;
    box-shadow: var(--vl-shadow-chrome); /* the bottom-most stuck bar carries
                                            the shadow (Liam, 2026-07-23);
                                            the anchor bar above suppresses
                                            its own via :has() */
    /* Full-bleed when stuck (Liam): band + shadow run viewport edge to edge
       like the anchor bar above. Negative margins pull the box out of the
       container; identical padding pushes the controls back to the exact
       container-aligned position — no horizontal jump, slide unaffected.
       The ~half-scrollbar overhang this creates is clipped by html
       { overflow-x: clip } (base.css). */
    margin-inline: calc(50% - 50vw);
    padding-inline: calc(50vw - 50%);
}

.vl-plot-filters.is-stuck::before {
    flex-grow: 0;
}

/* Below 1478px the heading + filter row no longer fit side by side — revert to
   the stacked layout: controls left, always-opaque band (invisible against the
   matching section background until stuck).

   ⚠️ THE 1477 THRESHOLD WAS DERIVED WHEN THE CONTAINER WAS FLUID TO 1600.
   It came from heading 484 + 30 gap + 884 controls = 1398px of container, and
   container was viewport − 80. Since 2026-08-26 the container is CAPPED at
   1280 (--vl-container), so it never reaches 1398 at any viewport — which is
   exactly why the bar started overlapping the heading on wide screens even
   though this rule looked like it covered the case. Fixed by shrinking the
   controls to 714px (see the select widths below): 484 + 30 + 714 = 1228,
   which fits the 1280 container with ~50px to spare.

   The threshold is deliberately LEFT at 1477 rather than recomputed. With the
   narrower controls the same-row layout would now fit from about a 1308px
   viewport, so this stacks a little earlier than it strictly needs to — a
   valid, already-reviewed layout, and lowering it is a change nobody asked
   for. Recompute it as heading + 30 + controls if that ever matters. */
@media (max-width: 1477px) {
    .vl-plot-filters {
        margin: 0 0 70px; /* matches the desktop 70px gap above the grid (Liam, 2026-07-23) */
        background: var(--vl-cream);
        pointer-events: auto;
    }

    .vl-plot-filters::before {
        display: none;
    }
}

/* ── Filters toggle wrapper (Liam, 2026-07-24) ──
   ≤1023px the form collapses behind an icon+label toggle on the heading row
   (mirrors the anchor bar's Sections toggle) and the WRAPPER becomes the
   sticky element. On desktop the wrapper is display:contents — no box at
   all — so the form's established sticky/negative-margin desktop behaviour
   above stays untouched. */
.vl-plot-filterbar {
    display: contents;
}

.vl-plot-filterbar__toggle {
    display: none;
    align-items: center;
    gap: 15px; /* matches the Sections toggle gap (Liam, 2026-07-24) */
    padding: 0;
    border: none;
    background: none;
    cursor: pointer;
    color: var(--vl-black); /* matches the filter/link text colour */
    font-family: inherit;
    font-size: var(--vl-text-nav);
    font-weight: var(--vl-weight-regular);
    line-height: 1;
}

.vl-plot-filterbar__toggle svg {
    display: block;
    stroke: var(--vl-ink); /* icon family: header burger/X, Sections toggle */
    stroke-width: 2;
    stroke-linecap: round;
}

.vl-plot-filters__reset {
    padding: 0;
    border: 0;
    background: none;
    color: var(--vl-black); /* node fill is literally #000000 */
    font: inherit;
    font-size: var(--vl-text-nav); /* 18px, node 3:859 */
    font-weight: var(--vl-weight-regular);
    line-height: 22px;
    text-decoration: underline;
    cursor: pointer;
}

.vl-plot-filters__check {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 8px; /* node-derived: label left 1007 − box right (949+50=999) */
    color: var(--vl-black);
    font-size: var(--vl-text-nav);
    font-weight: var(--vl-weight-regular);
    line-height: 22px;
    cursor: pointer;
}

/* Real checkbox sits transparent over the visual box (accessible, keyboard
   + screen-reader operable) rather than styling the native control directly
   — avoids cross-browser ::before-on-replaced-element inconsistencies. */
.vl-plot-filters__check-input {
    position: absolute;
    inset: 0;
    width: 50px;
    height: 50px;
    margin: 0;
    opacity: 0;
    cursor: pointer;
}

.vl-plot-filters__box {
    position: relative;
    flex-shrink: 0;
    width: 50px;  /* node 3:876: 50×50 square */
    height: 50px;
    border: 1px solid var(--vl-ink); /* node fill #32383b */
    border-radius: var(--vl-radius-field); /* 10px, exact node match */
    background: var(--vl-cream); /* node fill #eee9de */
}

/* Checked state / focus ring: not shown in the static frame read — same
   "reused sitewide convention" caveat as dev-anchor.css's link hover. */
.vl-plot-filters__check-input:checked + .vl-plot-filters__box {
    background: var(--vl-ink);
}

.vl-plot-filters__check-input:checked + .vl-plot-filters__box::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 45%;
    width: 22px;
    height: 12px;
    border-left: 3px solid var(--vl-white);
    border-bottom: 3px solid var(--vl-white);
    transform: translate(-50%, -50%) rotate(-45deg);
}

.vl-plot-filters__check-input:focus-visible + .vl-plot-filters__box {
    outline: 2px solid var(--vl-cornflower);
    outline-offset: 2px;
}

.vl-plot-filters__selects {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 10px; /* node-derived: 1350−1340, 1560−1550, both exact */
}

.vl-plot-filters__select {
    position: relative;
}

/* A select with a real value chosen reads black, not placeholder-grey —
   .has-value toggled by dev-filters.js (Liam review 2026-07-23). */
.vl-plot-filters__select select.has-value {
    color: var(--vl-black);
}

.vl-plot-filters__select select {
    appearance: none;
    -webkit-appearance: none;
    -moz-appearance: none;
    /* ⚠️ REVERT NOTE: these widths are item 4 in the REVERT SWITCH in
       css/variables.css. They were narrowed ONLY because the container went
       1600 → 1280; restore the node's 200px (here AND on .ts-wrapper below,
       plus the 0 40px 0 20px padding, and drop the :last-child override) if
       --vl-container goes back to 1600.

       200px in the node (3:856/3:869/3:873) — narrowed on 2026-08-26 when
       --vl-container went 1600 → 1280 and the right-aligned control group
       (884px: Reset 43 + 30 + checkbox 161 + 30 + 3×200 with 10px between)
       started colliding with the "Available homes" heading.

       ⚠️ THESE WIDTHS ARE MEASURED, NOT ESTIMATED. Omnes Pro is an Adobe Fonts
       webfont and cannot be measured server-side, so the real strings were
       rendered in headless Chrome with the live kit (wpv4rvb) loaded:

           "Available homes"  @70px = 491px   <- the heading
           "Sort by beds"     @18px =  95px
           "Sort by price"    @18px =  96px
           "Highest first"    @18px =  91px
           "Fewest first"     @18px =  88px
           "Max price"        @18px =  73px
           "£560,000"         @18px =  78px
           "Available only"   @18px = 103px   (+50 box +8 gap = the node's 161)

       That last line cross-checks the method: it reproduces the node's own
       161px group width exactly. Two earlier ESTIMATES in this file's history
       were wrong — the heading was guessed at ~560px (69px over), which is what
       made the full labels look impossible and briefly got them shortened to
       "Beds"/"Price". With the real numbers they fit comfortably, so the node's
       original wording is back. RE-MEASURE the same way before changing these.

       Widest content decides each width: Beds 95 and Price 96 → 150px each
       (with the 44px padding that leaves ~10px spare); Max price is the
       SHORTEST of the three at 78px, not the longest as first assumed → 130px.
       Total 714px, leaving a ~75px gap to the heading at a 1280 container. */
    width: 150px;
    height: 50px;
    border: 1px solid var(--vl-ink);
    border-radius: var(--vl-radius-field); /* 10px, exact node match */
    background: var(--vl-cream);
    /* 40/20 in the node; trimmed to 32/12 to buy back 16px of TEXT space per
       select at the narrower width. The right side must still clear the 21px
       chevron (::after, inset 9px from the box edge). */
    padding: 0 32px 0 12px;
    color: var(--vl-grey); /* node text fill #90918c — the "placeholder" state */
    font: inherit;
    font-size: var(--vl-text-nav); /* 18px */
    font-weight: var(--vl-weight-regular);
    line-height: 22px;
    cursor: pointer;
}

.vl-plot-filters__select select:focus-visible {
    outline: 2px solid var(--vl-cornflower);
    outline-offset: 2px;
}

/* Chevron: node vector 21×11, positioned ~9px in from the box's right edge
   (metadata: box right 1340, chevron right 1330.75 → 9px), vertically
   centred. Exported asset already reflects the frame's downward orientation
   (rotate-180 baked into the export) — masked the same way as the
   established icon pattern (vl-plot-card__fact::before), fixed to the
   node's ink fill rather than currentColor since this glyph's colour
   never varies with context. */
.vl-plot-filters__select::after {
    content: '';
    position: absolute;
    top: 50%;
    right: 9px;
    width: 21px;
    height: 11px;
    transform: translateY(-50%);
    background-color: var(--vl-ink);
    -webkit-mask: url("../assets/icon-chevron-down.svg") no-repeat center / contain;
    mask: url("../assets/icon-chevron-down.svg") no-repeat center / contain;
    pointer-events: none;
    /* Vendor sets .ts-control { z-index: 1 }, lifting it above this ::after
       chevron once Tom Select mounts (the enquiry buyer-position lesson,
       commit 4695055). */
    z-index: 2;
}

/* ── Tom Select control skin (filter-dropdowns spec 2026-07-31): the
   .ts-control adopts EXACTLY the native select construction above; the
   PANEL comes from the shared css/ts-dropdown.css (vl-ts scope, mounted by
   js/ts-filters.js). The native select is NOT pre-hidden — no-JS keeps the
   working native control (spec §4); TS hides it itself on mount, so the
   brief native flash before footer JS runs is accepted. Selector depth
   (.ts-wrapper ancestor) deliberately matches the vendor's own
   `.ts-wrapper.single.input-active .ts-control` (0,3,0) — ours loads later,
   so ties resolve our way and the click-to-open state can't flash the
   vendor's white ground over the cream. */
/* ⚠️ THIS is the width that actually renders. Tom Select replaces the native
   <select> with .ts-wrapper/.ts-control (CLAUDE.md dropdown rule), so changing
   the native width alone would have changed nothing on screen. Keep these two
   in step with the native select above. */
.vl-plot-filters__select .ts-wrapper {
    width: 150px; /* = the native select's width above */
}

/* Max price is the NARROWEST of the three, not the widest — measured, its
   longest content is "£560,000" at 78px against "Sort by price" at 96px.
   ⚠️ MUST stay inside a min-width query. At ≤1024 the selects go width:100% to
   fill the dropdown panel, and that rule is `.vl-plot-filters__select
   .ts-wrapper` — specificity (0,2,0). A bare :last-child rule here would be
   (0,3,0) and WIN over it, pinning this one select to 150px inside the mobile
   panel while the other two went full width. */
@media (min-width: 1025px) {
    .vl-plot-filters__select:last-child select,
    .vl-plot-filters__select:last-child .ts-wrapper {
        width: 130px;
    }
}

.vl-plot-filters__select .ts-wrapper .ts-control {
    width: 100%;
    height: 50px;
    min-height: 50px;
    align-items: center;
    flex-wrap: nowrap;
    border: 1px solid var(--vl-ink);
    border-radius: var(--vl-radius-field); /* 10px */
    background: var(--vl-cream);
    padding: 0 32px 0 12px; /* trimmed with the native select's, 2026-08-26 —
                               right still clears the 21px chevron, which sits
                               9px from the box edge (so it ends at 30px) */
    font-size: var(--vl-text-nav); /* 18px — NOTE this is the size the labels
                                      have to fit, not the 16px --vl-text-sm */
    font-weight: var(--vl-weight-regular);
    line-height: 22px;
    cursor: pointer;
    box-shadow: none;
}

/* Selected-value text: black with a real value, placeholder-grey on the ""
   option — the native has-value convention, now pure CSS off the TS item's
   own data-value (the filter scripts' class toggling keeps running on the
   hidden native select, harmlessly). */
.vl-plot-filters__select .ts-wrapper .ts-control .item {
    color: var(--vl-black);
}

.vl-plot-filters__select .ts-wrapper .ts-control .item[data-value=""] {
    color: var(--vl-grey); /* node text fill #90918c — the placeholder state */
}

.vl-plot-filters__select .ts-wrapper.focus .ts-control {
    outline: 2px solid var(--vl-cornflower);
    outline-offset: 2px;
    box-shadow: none;
}

.vl-plot-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 30px; /* card x deltas: 703−160−513 / 1246−703−513; row y delta: 1983−(1381+572) — all 30px */
}

/* ── Card ──────────────────────────────────────────────────────────────── */

.vl-plot-card {
    position: relative;
    display: flex;
    flex-direction: column;
    border-radius: 20px; /* node 35:715 */
    overflow: hidden; /* clips media to the card's rounded corners; grid's
                          default align-items:stretch fills shorter
                          (reserved/sold — no price/CTA rows) cards to the
                          row's tallest card, reproducing Figma's fixed
                          572px card height without hardcoding it */
    background: var(--vl-ink); /* node 35:715 fill #32383b */
    /* Renmore lift recipe (Liam, 2026-07-23): a resting shadow that GROWS
       while the card rises reads as one object lifting toward you — not a
       shadow appearing from nowhere. Rise + shadow share one curve. */
    box-shadow: 0 4px 16px rgba(50, 56, 59, 0.10);
    transition: transform 0.25s ease, box-shadow 0.25s ease;
}

/* Hover lift on AVAILABLE cards only — reserved/exchanged/sold are inert
   and must not advertise interactivity (Liam, 2026-07-23). */
.vl-plot-card--available:hover {
    transform: translateY(-4px);
    box-shadow: 0 12px 36px rgba(50, 56, 59, 0.35);
}

/* Gentle photo zoom on card hover — 5%: alive without lurching; the media
   box's overflow:hidden clips it inside the rounded corners (Liam, 2026-07-23). */
.vl-plot-card__media img {
    transition: transform 0.35s ease;
}

.vl-plot-card--available:hover .vl-plot-card__media img {
    transform: scale(1.05);
}

.vl-plot-card__media {
    position: relative;
    /* Same content-minimum guard as .vl-dev-card__media (2026-07-23):
       square-ish source photos must not inflate the aspect-ratio box. */
    min-height: 0;
    overflow: hidden;
    flex-shrink: 0;
    aspect-ratio: 513 / 342; /* node 35:766 image-mask proportions */
}

.vl-plot-card__media img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.vl-plot-card__badge {
    position: absolute;
    top: 15px;   /* node 35:733: 1396−1381 */
    right: 15px; /* node 35:733: (1246+513)−(1644+100) */
    /* Node 35:733 is a fixed 100px pill with the label centred — no padding of
       its own, which is why the three designed labels (Available/Reserved/Sold)
       merely LOOK padded: they're short enough to leave slack. "Coming soon"
       (added 2026-08-05) is longer and filled the pill edge to edge.
       min-width keeps the designed 100px for the short labels — they render
       identically — while a longer label grows and keeps real side space. 14px
       matches .vl-siteplan-card__badge, which has always been padding-based. */
    min-width: 100px;
    padding-inline: 14px;
    box-sizing: border-box;
    height: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--vl-radius-pill);
    box-shadow: 4px 4px 4px rgba(50, 56, 59, 0.15); /* = --vl-ink at 15%, node shadow */
    font-size: var(--vl-text-badge);
    font-weight: var(--vl-weight-semibold);
    line-height: 16px;
    white-space: nowrap;
}

.vl-plot-card__badge--available {
    /* Figma "Color 7" #00b388 — brand green. Not in variables.css: no
       existing token covers it, and design-tokens.md documents no green
       token either. Soft-rule note (CLAUDE.md) explicitly allows brand
       green/pink to appear minimally in the approved designs — this badge
       is exactly that case. Flag to Liam re: adding a --vl-green token if
       green is used again elsewhere. */
    background: #00b388;
    color: var(--vl-ink);
}

.vl-plot-card__badge--reserved {
    background: var(--vl-cornflower); /* node 35:991, exact token match */
    color: var(--vl-ink);
}

.vl-plot-card__badge--sold {
    background: var(--vl-pink); /* Figma "Color 6" — now a token (see variables.css); the About year lozenge is the second use */
    color: var(--vl-white); /* only status badge with WHITE text — read
                                directly off the node, not a derived rule */
}

/* Coming soon (HBP Pending, 0/7) — added 2026-08-05. NO Figma node exists for
   this state: Liam chose the ink dark grey deliberately as the muted "not yet"
   treatment, distinct from the three designed badges. The card photo is
   deliberately NOT greyscaled (Liam): greyscale reads as "gone", and this home
   is still to come. */
.vl-plot-card__badge--coming-soon {
    background: var(--vl-ink);
    color: var(--vl-cream);
    /* Optical centring (Liam, 2026-08-05): flex centres the LINE BOX, not the
       ink, so the baseline is identical for every label. "Coming soon" is the
       only one with a descender (the g), which fills the space below the
       baseline that Available/Reserved/Sold leave empty — so it reads low.
       With the fixed 24px height and border-box already set, 2px of bottom
       padding shrinks the content box to 22px and lifts the line 1px. */
    padding-bottom: 2px;
}

.vl-plot-card__promo {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0; /* flush with the media's bottom edge, node 57:3 */
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    padding-inline: 16px;
    background: var(--vl-cornflower); /* node 57:3 fill #a5bafa, exact token match */
    color: var(--vl-white);
    font-size: var(--vl-text-nav); /* 18px, node 57:4 */
    font-weight: var(--vl-weight-semibold);
    line-height: 22px;
    text-align: center;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.vl-plot-card__body {
    flex: 1 1 auto; /* absorbs leftover row height on shorter (reserved/sold) cards */
    display: flex;
    flex-direction: column;
    /* Liam review 2026-07-23 (replaces the node-derived 24/30/31 values):
       30px padding all round; 12px between text rows — the gap is measured
       between LINE BOXES, whose line-heights already carry leading, so 12px
       here reads like the frame's tight 8/13 rhythm (20px felt too airy
       live). The button's extra margin tops its gap up to 30px. */
    padding: 30px;
    gap: 12px;
}

/* Client rule (via Liam, 2026-07-23): price-less cards keep the SAME height
   as full cards even on rows of their own (the frame shows the empty ink
   area — node 35:837). 230px = a full card's body: 30+30 padding + 32 type
   + 12 gap + 34 price + 12 gap + 22 bottomrow + 18 margin + 40 CTA. Row
   heights are width-independent (single-line rows), so one value holds
   across all desktop/tablet column counts. Mobile keeps auto height until
   the mobile pass rules on it (its body padding differs). */
@media (min-width: 768px) {
    .vl-plot-card__body {
        min-height: 230px;
    }
}

.vl-plot-card__toprow {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 16px;
}

.vl-plot-card__type {
    margin: 0;
    color: var(--vl-white);
    font-size: var(--vl-text-input); /* 30px, node 35:725 */
    font-weight: var(--vl-weight-regular);
    line-height: 1.067; /* node ratio 32/30, unitless so the mobile token size scales it */
}

.vl-plot-card__plotno {
    flex-shrink: 0;
    color: var(--vl-white);
    font-size: var(--vl-text-nav); /* 18px, node 35:724 */
    font-weight: var(--vl-weight-regular);
    line-height: 22px;
    white-space: nowrap;
}

.vl-plot-card__price {
    margin: 0; /* row rhythm comes from the body's 20px gap (Liam, 2026-07-23) */
    color: var(--vl-white);
    font-size: var(--vl-text-input); /* 30px, node 35:751 */
    font-weight: var(--vl-weight-semibold);
    line-height: 1.133; /* node ratio 34/30, unitless so the mobile token size scales it */
}

.vl-plot-card__bottomrow {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px; /* internal building↔facts gap; row rhythm from the body's 12px gap */
}

/* No price row (reserved/sold): the building row sat too close under the
   type row — top up the 12px gap to 20px (Liam review 2026-07-23). */
.vl-plot-card__body:not(:has(.vl-plot-card__price)) .vl-plot-card__bottomrow {
    margin-top: 8px;
}

.vl-plot-card__building {
    margin: 0;
    color: var(--vl-white);
    font-style: italic; /* node 35:752 "Omnes:Italic" */
    font-weight: var(--vl-weight-regular); /* Italic = Regular italic per the kit's shifted numbering */
    font-size: var(--vl-text-nav); /* 18px */
    line-height: 22px;
}

.vl-plot-card__facts {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    gap: 30px; /* node gap: bed group end (1659) to bath group start (1689) */
}

.vl-plot-card__fact {
    display: flex;
    align-items: center;
    gap: 10px; /* node gap: icon end to number start, both bed/bath groups */
    color: var(--vl-white);
    font-size: var(--vl-text-nav); /* 18px */
    font-weight: var(--vl-weight-regular);
    line-height: 22px;
}

.vl-plot-card__fact::before {
    content: '';
    display: inline-block;
    background-color: currentColor;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-size: contain;
    mask-size: contain;
}

.vl-plot-card__fact--beds::before {
    width: 27px;  /* node no_of_bedrooms_icon: 27.289×18.8205 */
    height: 19px;
    -webkit-mask-image: url("../assets/icon-bed.svg");
    mask-image: url("../assets/icon-bed.svg");
}

.vl-plot-card__fact--baths::before {
    width: 20px; /* node no_of_bathrooms_icon: 19.999×18.821 */
    height: 19px;
    -webkit-mask-image: url("../assets/icon-shower.svg");
    mask-image: url("../assets/icon-shower.svg");
}

.vl-plot-card__cta {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    width: 209px; /* node 35:736/35:822 */
    height: 40px;
    margin-top: 18px; /* + the body's 12px gap = 30px above the button (Liam, 2026-07-23) */
    border-radius: var(--vl-radius-pill);
    background: var(--vl-brick); /* node 35:738 fill #ce9761, exact token match */
    color: var(--vl-white); /* read off the exported "View home" glyph fill (white) — the flattened
                                text layer isn't colour-readable directly, so the raw vector asset
                                was inspected to confirm */
    font-size: var(--vl-text-nav); /* 18px — established CTA-pill label size (docs/design-tokens.md) */
    font-weight: var(--vl-weight-semibold);
    text-decoration: none;
    white-space: nowrap;
}

.vl-plot-card__cta::after {
    content: '';
    display: inline-block;
    width: 24px;  /* node svgexport-13 1 */
    height: 12px;
    background-color: currentColor;
    -webkit-mask: url("../assets/icon-arrow-right.svg") no-repeat center / contain;
    mask: url("../assets/icon-arrow-right.svg") no-repeat center / contain;
    transition: transform 0.2s ease;
}

/* Whole card is the link on available plots (Liam review 2026-07-23):
   the CTA anchor stretches over the card — one link, whole card clickable,
   keyboard/screen-reader unchanged. Reserved/sold cards have no CTA and
   stay inert (client rule). */
.vl-plot-card__cta::before {
    content: '';
    position: absolute;
    inset: 0;
}

.vl-plot-card--available:hover .vl-plot-card__cta::after {
    transform: translateX(10px); /* arrow slides right on hover (Liam) */
}

/* ── Non-available treatment: greyed photo, no pointer affordance ──
   (client-confirmed inert rule — no <a>/CTA is emitted server-side; this
   is purely the visual desaturation read off the Sold/Reserved nodes,
   which show the photo in full greyscale while the ink panel, badge, and
   type all keep their normal colours). */
.vl-plot-card--reserved .vl-plot-card__media img,
.vl-plot-card--sold .vl-plot-card__media img {
    filter: grayscale(1);
}

.vl-plot-card--reserved,
.vl-plot-card--sold,
.vl-plot-card--coming-soon {
    cursor: default;
}

/* ── Responsive: 3 columns down to 1280px, 2 columns down to 900px,
   1 column below that (Liam, 2026-07-24: two ~395px cards at 900 is the
   squeeze point — a content-driven breakpoint like 1477/1280, no framework
   standard sits at 900). ── */
@media (max-width: 1280px) {
    .vl-plot-grid {
        grid-template-columns: repeat(2, 1fr);
    }
}

@media (max-width: 899px) {
    .vl-plot-grid {
        grid-template-columns: 1fr;
    }
}

/* Below 900px, hide RESERVED and COMING SOON plots (client request via Liam,
   2026-08-25). 900 is the breakpoint deliberately: it is where the grid above
   drops to ONE column, which is what turns Knowle Grange's 21 cards into the
   long scroll the client complained about (6 available, 8 reserved, 6 coming
   soon, 1 sold). At 900-1280 the grid is two-up and the scroll is half as
   long, so it is left alone.

   SOLD is deliberately NOT hidden — Liam chose this from a side-by-side that
   showed the sold card staying. If it should go too, add
   .vl-plot-card--sold to the list below; nothing else changes.

   ⚠️ The :has() guard is load-bearing, not decoration. Without it, a
   development whose plots are ALL reserved or coming soon would render an
   EMPTY grid on mobile under an "Available homes" heading and a filter bar —
   which reads as a broken page, not as a filter. With it, the rule only fires
   where at least one available plot exists to show; otherwise the full list
   renders as it does today. Knowle Grange has 6 available now, but that
   number only goes down as plots sell.

   Degrades safely: a browser without :has() treats the whole selector as
   invalid and ignores it, which leaves today's behaviour (all cards shown).

   The "Available only" checkbox in the filter bar still works and is
   unaffected — on mobile it now only additionally removes the sold card. */
@media (max-width: 899px) {
    .vl-plot-grid:has(.vl-plot-card--available) .vl-plot-card--reserved,
    .vl-plot-grid:has(.vl-plot-card--available) .vl-plot-card--coming-soon {
        display: none;
    }
}

/* ── Tablet / mobile: filters collapse behind the "Filters" toggle, which
   sits right-aligned on the heading row (Liam, 2026-07-24 — replaced the
   always-visible wrapped rows). Two open modes, one DOM:
   · bar unstuck → form in flow under the toggle row, pushes the grid down
   · bar stuck   → form is an absolute dropdown panel under the pinned bar
   dev-filters.js toggles .is-open/.is-stuck and closes the panel whenever
   the pin state flips (mode switching mid-open would jump the layout). ── */
@media (max-width: 1024px) {
    .vl-plot-filterbar {
        display: flex;
        flex-wrap: wrap; /* row 1 = spacer + toggle; the open form wraps to row 2 */
        position: sticky;
        top: var(--vl-dev-anchor-h); /* pins under the anchor bar, like the desktop form */
        z-index: 20; /* below the anchor bar (30); above section content */
        /* Pull the toggle row up beside the heading (same trick as the
           desktop form): heading height (line-height 1 = the display token)
           + its 40px bottom margin. */
        margin-top: calc(-1 * (var(--vl-text-display) + 40px));
        margin-bottom: 40px;
        pointer-events: none; /* the row overlays the heading — click-through */
        transition: background-color 0.25s ease;
    }

    /* The slide (Liam, 2026-07-24 — same mechanic as the desktop form's
       ::before): this spacer right-aligns the toggle on the heading row;
       flex-grow IS animatable, so collapsing it when the bar pins slides
       the toggle across to the left. */
    .vl-plot-filterbar::before {
        content: '';
        flex-grow: 1;
        transition: flex-grow 0.35s ease;
    }

    .vl-plot-filterbar.is-stuck::before {
        flex-grow: 0;
    }

    .vl-plot-filterbar__toggle {
        display: inline-flex;
        pointer-events: auto;
        height: var(--vl-text-display); /* = heading height, so the toggle
                                           centres on the heading row */
    }

    /* Pinned: full-bleed cream band + chrome shadow (the anchor bar above
       suppresses its own shadow via :has(), dev-anchor.css). Same
       negative-margin/padding full-bleed as the desktop form's stuck state. */
    .vl-plot-filterbar.is-stuck {
        background: var(--vl-cream);
        box-shadow: var(--vl-shadow-chrome);
        margin-inline: calc(50% - 50vw);
        padding-inline: calc(50vw - 50%);
        padding-block: 15px; /* breathing room around the pinned Filters row (Liam, 2026-07-24) */
        pointer-events: auto;
    }

    .vl-plot-filters {
        display: none;
        position: static; /* the WRAPPER is the sticky element at this width */
        width: 100%; /* full-width flex item — wraps below the toggle row */
        margin: 0;
        /* 40px top = the heading row's bottom margin, which the wrapper's
           pull-up margin otherwise swallows (caught live 2026-07-24) —
           inline-open filters start the same 40px below the heading row as
           the grid does when they're closed. No bottom padding (Liam,
           2026-07-24 — was 16px); the wrapper's 40px margin carries the
           gap to the grid. */
        padding: 40px 0 0;
        gap: 16px;
        row-gap: 20px; /* checkbox-row ↔ selects-row (Liam, 2026-07-24) */
        pointer-events: auto;
    }

    .vl-plot-filterbar.is-open .vl-plot-filters {
        display: flex;
    }

    /* Dropdown mode: floats over the grid instead of pushing it. The stuck
       wrapper is full-bleed, so left/right 0 span the viewport; --vl-edge
       restores the content gutters inside the panel. */
    .vl-plot-filterbar.is-stuck .vl-plot-filters {
        position: absolute;
        top: 100%;
        left: 0;
        right: 0;
        background: var(--vl-cream);
        box-shadow: var(--vl-shadow-chrome);
        /* The shadow's blur also bleeds ABOVE the panel, drawing a grey line
           over the bar it hangs from (caught live 2026-07-24) — clip it flush
           at the top edge, keep it on the other three sides. */
        clip-path: inset(0 -30px -30px -30px);
        padding: 20px var(--vl-edge) 24px;
    }

    /* Reset moves to the right-hand end of the checkbox row (Liam,
       2026-07-24). DOM order (reset first) is unchanged — flex order +
       auto margin re-seat it visually: row 1 = checkbox … reset, row 2 =
       the full-width selects. The 767px block below reverts for mobile. */
    .vl-plot-filters__reset {
        order: 1;
        margin-left: auto;
    }

    .vl-plot-filters__selects {
        order: 2;
    }

    /* The three selects share the full row width equally (Liam, 2026-07-24 —
       replaces the desktop's fixed 200px boxes at this width). On mobile the
       767px block below stacks them instead; flex: 1 is inert in a column. */
    .vl-plot-filters__selects {
        width: 100%;
        gap: 10px;
    }

    .vl-plot-filters__select {
        flex: 1;
    }

    .vl-plot-filters__select select {
        width: 100%;
    }

    .vl-plot-filters__select .ts-wrapper {
        width: 100%; /* tracks the native select's own rule above */
    }
}

/* Small mobile: the Filters toggle drops onto its own row below the
   heading, flush LEFT (Liam, 2026-07-24 — right-aligned tried live,
   reverted). No pull-up beside the title, spacer off (so no pin-slide —
   it's already left). Sticky + dropdown behaviour unchanged. */
@media (max-width: 500px) {
    /* Both gaps around the own-row Filters toggle read 30px (Liam,
       2026-07-24 screenshot: heading→filters and filters→grid boxed).
       Safe to shrink the heading margin HERE only — the >500 pull-up
       calc assumes the 40px. */
    .vl-dev-plots__heading {
        margin-bottom: 30px;
    }

    .vl-plot-filterbar {
        margin-top: 0;
        margin-bottom: 30px; /* gap to the grid, 40→30 at small mobile (Liam, 2026-07-24) */
    }

    .vl-plot-filterbar::before {
        display: none;
    }
}

@media (max-width: 767px) {
    .vl-dev-plots {
        padding-block: 60px 60px;
    }

    /* (Reset stays at the right-hand end of the checkbox row on mobile too —
       Liam, 2026-07-24; the ≤1024 re-seat cascades down. An earlier revert
       to Reset-first lived here for one review round.) */

    .vl-plot-filters__select select {
        width: 100%;
    }

    .vl-plot-filters__selects {
        width: 100%;
        flex-direction: column;
        align-items: stretch;
    }

    .vl-plot-card__body {
        padding: 30px 20px; /* Liam, 2026-07-24 (was 20/20/24) */
    }
}

/* Brick-button hover (Liam, 2026-07-23): the CTA deepens when its card is
   hovered — whole card is the link, so the button reacts with it. */
.vl-plot-card__cta {
    transition: background-color 0.2s ease;
}

.vl-plot-card--available:hover .vl-plot-card__cta {
    background: var(--vl-brick-dark);
}
