/* =========================================
   Amaryllis — PRODUCT LISTING PAGE
   -----------------------------------------
   Loaded only on the Products template (see the enqueue in
   functions.php). style.css is still the base: tokens, resets, the
   header/footer and the whole .reveal / .stagger motion system come
   from there and are NOT repeated here. This file only adds what is
   unique to this page.

   Every number below was read off the Figma frames. The one figure
   everything else hangs on is the product tile: 444 x 476 with a
   20px corner radius, three to a 1440 row.
========================================= */

.products-page {
    /* --- Product card ---
       Figma: the card is 448 x 788 and the image 444 x 476. Those two
       widths disagree by 4px — the image sits flush to the card's
       left edge and stops 4px short on the right, which is a slip in
       the file, not an inset.
       The ratio here is 448/476 rather than 444/476 because the media
       box fills the card: feeding it 444/476 made the image 480 tall
       instead of 476, pushing the card 4px past the design. Keeping
       the HEIGHT exact matters more than reproducing the 4px gap. */
    /* --tile-ar, --tile-radius and --tile-bg have MOVED to
       .product-card — see the note there. They were scoped to this
       page, and the card is also used by "Customers Also Liked" on the
       single product page, which is not inside .products-page. */

    /* --- Ink ---
       The listing runs darker than the home page: buttons and the
       filter outlines are near-black, not the #333 used on cards. */
    --p-ink: var(--c-ink);
    --p-title: #111111;
    --p-body: #4f4f4f;
    --p-line: var(--c-ink);

    /* --- Hero ---
       Warm brown sampled off the banner artwork so the headline
       sits in the photograph rather than on top of it. */
    --hero-ink: #3f342b;
    --hero-rule: #8d7f6f;

    /* --- Grid metrics -------------------------------------------
     * Figma: three 444 tiles across a 1374 block, so a 21px column
     * gap and a 33px inset on the 1440 frame.
     *
     * The column gap is declared HERE rather than read from the
     * theme-wide --grid-gap. That token was set to 0 when the home
     * page moved to a butted, edge-to-edge product grid, and this
     * page was silently inheriting the zero — which is why the cards
     * were touching.
     * ----------------------------------------------------------- */
    --col-gap: 21px;
    /* --- Why the grid is capped -----------------------------------
     * This was `none`, so the three columns simply divided whatever
     * width the window had. At the 1440 design width that gives a 442px
     * card, which is Figma's 448 — correct. But nothing stopped it
     * above that: measured at 1905px the card came out 595 wide, 33%
     * over the design, and because everything inside the card is sized
     * in cqw (a share of the CARD), the artwork and both buttons grew
     * with it — the buttons rendered 64px tall against a design of 48.
     *
     * 1376 is Figma's own block: three 448 cards with two 16px gaps.
     * Capping here rather than on .product-grid keeps the filter row,
     * which shares this container, aligned with the cards beneath it.
     * Below 1376 nothing changes — the cards still shrink to fit.
     * ----------------------------------------------------------- */
    --content-max: 1376px;
    /* Figma: the filter row is 1374 at x=34 and the card grid 1376 at
       x=32 on a 1440 frame — roughly a 33px inset either side, i.e.
       2.29vw. Held as a ratio with NO ceiling and no max-width on the
       rows: a fixed cap does not bind at 1440 but does above it, which
       is what left hundreds of pixels of white on a large display. */
    --grid-inset: max(14px, 2.29vw);

    /* Rows need far more air than columns because each card carries
       two stacked buttons under the artwork. */
    --row-gap: 52px;
}

/* =========================================
   1. BANNER
   Full-bleed photograph, copy left-aligned and vertically centred.
   The image is a real <img> rather than a background so it keeps
   its alt text and can be given fetchpriority.
========================================= */
.product-hero {
    position: relative;
    width: 100%;
    /* Figma: 1440 x 810, the same tall banner as the homepage. Both are
       now the shared height: the window, less the sticky header. */
    height: var(--hero-h);
    display: flex;
    align-items: center;
    overflow: hidden;
    background-color: #ece5dc;
}

.product-hero-img {
    position: absolute;
    inset: 0;
    z-index: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
}

/* Left-to-right wash. The Figma banner has the copy sitting on a
   plain wall, but that wall is a fraction lighter at the top left
   than at the bottom, so a very soft scrim keeps the headline at a
   consistent contrast without reading as an overlay. */
.product-hero::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
    background: var(--hero-scrim, linear-gradient(
        90deg,
        rgba(255, 255, 255, 0.42) 0%,
        rgba(255, 255, 255, 0.18) 34%,
        rgba(255, 255, 255, 0) 58%
    ));
    pointer-events: none;
}

/* --- Which way round the artwork runs ----------------------------
 * The five category banners are not alike. Four are dark scenes —
 * an olive studio, a navy plinth, a blue sweep, a brown dining room —
 * and carry white type. Fresh Air is a pale daylit concrete room and
 * carries the dark type the rest of the site uses.
 *
 * So the hero's three colours and its scrim are tokens, and the page
 * template puts .product-hero--light or --dark on the section
 * depending on the banner's 'ink' value. Nothing else in the block
 * below needs to know which one is showing.
 *
 * The scrim flips with it: a white wash under white text would erase
 * the text, so the light variant darkens the left of the frame
 * instead, on the same left-to-right falloff.
 * ------------------------------------------------------------- */
.product-hero--dark {
    --hero-title: #413529;
    --hero-sub: #a18a74;
    /* --hero-rule keeps the #8d7f6f set on .products-page */
}

/* Fresh Air is dark type too, but neutral rather than warm: its frame
   is grey daylit concrete, and the design sets both the headline and
   the line under it in the page's own ink. The warm brown and tan of
   --dark belong to the bathroom photograph on /products/ and read as
   washed out against concrete. */
.product-hero--neutral {
    --hero-title: var(--p-ink);
    --hero-sub: var(--p-ink);
    --hero-rule: var(--p-ink);
}

.product-hero--light {
    --hero-title: #ffffff;
    --hero-sub: rgba(255, 255, 255, 0.88);
    --hero-rule: rgba(255, 255, 255, 0.8);
    --hero-scrim: linear-gradient(
        90deg,
        rgba(0, 0, 0, 0.52) 0%,
        rgba(0, 0, 0, 0.26) 36%,
        rgba(0, 0, 0, 0) 62%
    );
}

.product-hero-inner {
    position: relative;
    z-index: 2;
    width: 100%;
    max-width: var(--container);
    margin: 0 auto;
    padding: 0 var(--section-gutter);
}

/* Figma: the copy block is 611 wide at x=89. */
.product-hero-copy {
    max-width: 611px;
}

/* Figma: Source Sans Pro w700 64 / lh 74 / #413529 — a warm dark
   brown, not the page ink. Was capped at 46. */
.product-hero-copy h1 {
    font-family: var(--font-display);
    font-size: clamp(26px, 4.44vw, 62px);
    font-weight: 700;
    line-height: 1.16;
    text-transform: uppercase;
    /* Token, so the light/dark modifiers above can flip it. */
    color: var(--hero-title, #413529);
}

/* Short rule between headline and standfirst — Figma has it at
   roughly a third of the headline's cap width. */
/* Figma: Line 21 runs 352px at y=568, with the headline block ending
   at 555 and the standfirst starting at 595. Was 62px. */
.product-hero-rule {
    display: block;
    width: clamp(120px, 24.44vw, 352px);
    height: 2px;
    margin: 13px 0 27px;
    background: var(--hero-rule);
    transform-origin: left center;
}

/* Figma: Open Sans w600 20 / lh 24 / #a18a74 — a tan, not the grey
   this had. Block runs 542 wide.

   No hand-placed <br>s in the copy any more: a break chosen for one
   width misfires at another (Industrial left "across" alone on its
   own line). The browser balances the lines instead, at any width. */
.product-hero-copy p {
    font-family: var(--font-body);
    /* Figma: Open Sans Semibold 16 / lh 24. 16/1440 = 1.11vw, so it
       holds the design's size at the design's width and grows on a
       wider screen. It was w400 at 18 — lighter and larger than
       drawn, which is what made it read as faint. */
    font-size: clamp(14px, 1.11vw, 18px);
    font-weight: 600;
    line-height: 1.5;
    max-width: 560px;
    text-wrap: balance;
    color: var(--hero-sub, #a18a74);
}

/* Same load-in as the home-page hero. amaryllis-rise is declared in
   style.css, so this only needs to schedule it. */
.has-js .product-hero-copy > * {
    opacity: 0;
    animation: amaryllis-rise 0.9s var(--ease) forwards;
}

.has-js .product-hero-copy > *:nth-child(1) { animation-delay: 0.15s; }
.has-js .product-hero-copy > *:nth-child(2) { animation-delay: 0.30s; }
.has-js .product-hero-copy > *:nth-child(3) { animation-delay: 0.45s; }

@media (prefers-reduced-motion: reduce) {
    .has-js .product-hero-copy > * {
        opacity: 1;
        animation: none;
    }
}

/* =========================================
   2. LISTING SHELL
========================================= */
.product-listing {
    padding: 34px var(--grid-inset) 90px;
    background-color: var(--c-white);
    /* Cards drift in sideways on reveal; clip stops that from
       flashing a horizontal scrollbar. `clip`, not `hidden`, so no
       scroll container is created and the sticky header still works. */
    overflow-x: clip;
}

.product-container {
    /* Capped at the Figma block width, NOT the theme-wide
       --container (1852). At 1852 each tile rendered about 603
       wide against the 444 it was designed at — the artwork, the
       type and the buttons all scaled up with it. */
    max-width: var(--content-max);
    margin: 0 auto;
}

/* =========================================
   3. FILTER BAR
   -----------------------------------------
   Six outline pills, each a <details> whose <summary> IS the pill.
   Native disclosure rather than a button plus a class: it opens and
   closes, takes keyboard focus and reports its own expanded state
   without any script, which is what lets the whole filter work with
   JavaScript switched off. js/product-filters.js only adds
   close-on-outside-click, Escape, and closing the other five.
========================================= */
/* Ticking a filter reloads the page onto #filters; clear the sticky header. */
.filter-form {
    scroll-margin-top: 110px;
}

/* Phones only - see the @media (max-width: 768px) block below. */
.filter-toggle {
    display: none;
}

/* The sheet's chrome, likewise. display:contents on the wrapper keeps
   the <details> elements direct flex items of .filter-bar at this
   width, so the desktop row is laid out exactly as it was before the
   wrapper existed. */
.filter-groups {
    display: contents;
}

.filter-sheet-head,
.filter-sheet-foot,
/* The named values under a rail row. Only the phone sheet draws
   these — at every other width the pill is 214px wide and the panel
   is one tap away, so the tally badge says enough. */
.filter-chosen,
/* The removable applied-filter chips, likewise: they exist because a
   phone hides the pills behind a button. */
.filter-applied {
    display: none;
}

/* Figma: the filter row is 1374 wide at x=34, holding six 214 x 48
   pills at x = 34 / 266 / 498 / 730 / 962 / 1194 → an 18px gap. */
.filter-bar {
    /* Anchor for the dropdowns on phones, where each spans the whole bar.
       On wider screens every dropdown hangs under its own pill instead -
       see .filter-group. */
    position: relative;

    /* --- Why this z-index is not optional -------------------------
     * This element also carries .reveal, and .reveal sets
     * `will-change: opacity, transform, filter`. will-change on
     * transform creates a STACKING CONTEXT on its own — it does not
     * wait for a transform to actually be applied. So the panel's
     * z-index below is sealed inside this bar and cannot compete with
     * anything outside it.
     *
     * Every .product-card carries .reveal too, so each card is also a
     * stacking context. With the bar left at `z-index: auto` both sat
     * at level 0, and the cards, coming later in the document, simply
     * painted over the open dropdown.
     *
     * Lifting the BAR is what fixes it — raising the panel cannot,
     * because the panel is not the element in the contest. 20 clears
     * the grid and stays well under the sticky header's 1000.
     * ---------------------------------------------------------- */
    z-index: 20;
    display: flex;
    gap: 18px;
    max-width: none;
    margin: 0 auto 48px;
}

.filter-group {
    /* The flex sizing moved here from .filter-pill: the <details> is
       the flex item now, and the summary fills it. */
    flex: 1 1 0;
    min-width: 0;
    /* The anchor for its own dropdown, which is exactly as wide as the
       pill above it in the September dropdown design. */
    position: relative;
}

.filter-pill {
    width: 100%;
    /* No max-width. Capped at 216 the six pills came to 1356 inside
       a wider block, and because a flex row packs from the start,
       every leftover pixel collected on the RIGHT — the row looked
       shifted left against the grid beneath it. Uncapped, they
       divide the block evenly and both edges line up with the
       cards. (The `flex` that used to be here is on .filter-group.) */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* Figma: 214 x 48, radius 8, 8px between the label and its caret,
       label Open Sans w600 20 / lh 40 / #1f1f1f. */
    gap: 8px;
    min-height: 48px;
    padding: 4px 18px;
    background: var(--c-page);
    border: 1px solid var(--p-line);
    border-radius: 8px;
    font-family: var(--font-body);
    font-size: clamp(13px, 1.39vw, 18px);
    font-weight: 400;
    color: var(--p-ink);
    cursor: pointer;
    transition:
        background-color 0.3s var(--ease-out),
        color 0.3s var(--ease-out);
}

/* Caret drawn from two borders — no icon font needed, and it
   inherits the label colour on hover for free. */
.filter-caret {
    width: 7px;
    height: 7px;
    border-right: 1.6px solid currentColor;
    border-bottom: 1.6px solid currentColor;
    transform: translateY(-2px) rotate(45deg);
    transition: transform 0.3s var(--ease-out);
}

/* A <summary> draws a disclosure triangle by default. The caret above
   is the design's own, so the native one has to go — and it takes two
   rules, because Safari uses the ::-webkit- pseudo-element and
   everything else honours `display` on the summary box itself. */
.filter-pill::-webkit-details-marker { display: none; }
.filter-pill::marker { content: ''; }
.filter-pill { list-style: none; }

.filter-pill:hover,
.filter-pill:focus-visible,
.filter-group[open] > .filter-pill,
.filter-group.has-selection > .filter-pill {
    background: var(--p-ink);
    color: var(--c-white);
    border-color: var(--p-ink);
}

.filter-pill:hover .filter-caret,
.filter-group[open] > .filter-pill .filter-caret {
    transform: translateY(0) rotate(45deg);
}

/* Open flips the caret up, so the control says which way it goes. */
.filter-group[open] > .filter-pill .filter-caret {
    transform: translateY(2px) rotate(-135deg);
}

/* How many values are chosen inside a closed panel. */
.filter-tally {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 1.5em;
    height: 1.5em;
    padding: 0 0.35em;
    border-radius: 999px;
    background: var(--c-brand);
    color: var(--c-page);
    font-size: 0.72em;
    font-weight: 700;
    line-height: 1;
}

/* =========================================
   3b. FILTER PANEL
   -----------------------------------------
   September design (client's reference): a small white card under each
   pill, exactly the pill's width, listing the choices as tick boxes - no
   counts, no colour dots and no Apply row, because a tick applies at once
   (js/product-filters.js). Long lists such as Size scroll inside the card
   rather than pushing the grid down the page.
========================================= */
.filter-panel {
    position: absolute;
    z-index: 30;
    top: calc(100% + 8px);
    left: 0;
    right: 0;
    min-width: 190px;
    padding: 12px 16px;
    background: var(--c-page);
    border: 0;
    border-radius: 8px;
    box-shadow: 0 10px 28px rgba(31, 31, 31, 0.14);
}

.filter-options {
    /* A plain list, not multi-column: with a max-height, columns push the
       overflow sideways into extra columns and a scrollbar. */
    columns: auto;
    max-height: 264px;
    overflow-x: hidden;
    overflow-y: auto;
    list-style: none;
    margin: 0;
    padding: 0;
}

.filter-options li {
    /* Without this a row can be split across a column boundary,
       leaving a tick box at the foot of one column and its label at
       the head of the next. */
    break-inside: avoid;
    margin: 0 0 2px;
}

.filter-option {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 6px 4px;
    border-radius: 6px;
    font-family: var(--font-body);
    font-size: clamp(13px, 1.04vw, 15px);
    font-weight: 600;
    line-height: 1.4;
    color: var(--p-ink);
    cursor: pointer;
    transition: background-color 0.2s var(--ease-out);
}

.filter-option:hover {
    background: rgba(31, 31, 31, 0.05);
}

/* The real checkbox stays in the accessibility tree and keeps its
   keyboard behaviour; .filter-box is what gets drawn. */
.filter-option input {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

.filter-box {
    flex: 0 0 auto;
    position: relative;
    width: 18px;
    height: 18px;
    border: 1.6px solid var(--p-line);
    border-radius: 3px;
    background: var(--c-white);
    transition:
        background-color 0.2s var(--ease-out),
        border-color 0.2s var(--ease-out);
}

/* Tick drawn from two borders, same trick as .filter-caret. */
.filter-box::after {
    content: '';
    position: absolute;
    top: 1.5px;
    left: 5.5px;
    width: 4.5px;
    height: 9px;
    border-right: 2px solid var(--c-page);
    border-bottom: 2px solid var(--c-page);
    transform: rotate(45deg) scale(0);
    transition: transform 0.2s var(--ease-out);
}

.filter-option input:checked + .filter-box {
    background: var(--c-brand);
    border-color: var(--c-brand);
}

.filter-option input:checked + .filter-box::after {
    transform: rotate(45deg) scale(1);
}

.filter-option input:focus-visible + .filter-box {
    outline: 2px solid var(--c-brand);
    outline-offset: 2px;
}

/* --- Category panel: links, not tick boxes -----------------------
 * Category is navigation on this site — each one is a real page with
 * its own banner and URL — so its panel lists every category as a
 * link rather than as a facet of the current page. A dot marks where
 * you are, the way a radio group would, without pretending to be a
 * control you can toggle off.
 * ------------------------------------------------------------- */
a.filter-option {
    text-decoration: none;
}

.filter-dot {
    flex: 0 0 auto;
    position: relative;
    width: 16px;
    height: 16px;
    border: 1.5px solid var(--p-line);
    border-radius: 50%;
    background: var(--c-white);
    transition: border-color 0.2s var(--ease-out);
}

.filter-dot::after {
    content: '';
    position: absolute;
    inset: 3px;
    border-radius: 50%;
    background: var(--c-brand);
    transform: scale(0);
    transition: transform 0.2s var(--ease-out);
}

.filter-option.is-current {
    font-weight: 700;
}

.filter-option.is-current .filter-dot {
    border-color: var(--c-brand);
}

.filter-option.is-current .filter-dot::after {
    transform: scale(1);
}

a.filter-option:hover,
a.filter-option:focus-visible {
    background: rgba(31, 31, 31, 0.05);
}

/* The category list is short — six rows including All Products — so it
   reads better as one or two columns than spread thin across four. */
.filter-options--links {
    columns: auto;
}

/* Colour swatch, same token the product cards use. */
.filter-swatch {
    flex: 0 0 auto;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: var(--swatch, #ffffff);
    box-shadow: inset 0 0 0 1px rgba(31, 31, 31, 0.22);
}

.filter-option-label {
    flex: 1 1 auto;
    min-width: 0;
}

/* How many products carry this value. Quiet, so it reads as a hint
   rather than as part of the label. */
.filter-count {
    flex: 0 0 auto;
    color: var(--p-body);
    font-size: 0.85em;
    font-variant-numeric: tabular-nums;
}

/* Not drawn in the dropdown design - the options only. */
.filter-panel .filter-count,
.filter-panel .filter-swatch {
    display: none;
}

.filter-foot {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 14px;
    margin-top: 16px;
    padding-top: 14px;
    border-top: 1px solid var(--p-line);
}

/* A tick applies straight away when the script runs, so the Clear/Apply
   row is only for visitors without JavaScript. */
.has-js .filter-foot {
    display: none;
}

.filter-reset,
.filter-clear-all {
    font-family: var(--font-body);
    font-size: clamp(12px, 1.11vw, 14px);
    font-weight: 400;
    color: var(--p-body);
    text-decoration: underline;
    text-underline-offset: 3px;
    transition: color 0.2s var(--ease-out);
}

.filter-reset:hover,
.filter-clear-all:hover,
.filter-reset:focus-visible,
.filter-clear-all:focus-visible {
    color: var(--p-ink);
}

.filter-apply {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 38px;
    padding: 6px 24px;
    background: var(--p-ink);
    border: 1px solid var(--p-ink);
    border-radius: 8px;
    font-family: var(--font-body);
    font-size: clamp(12px, 1.11vw, 14px);
    font-weight: 700;
    color: var(--c-page);
    cursor: pointer;
    transition:
        background-color 0.3s var(--ease-out),
        border-color 0.3s var(--ease-out);
}

/* Brand green on hover, the same as every other CTA on the site. */
.filter-apply:hover,
.filter-apply:focus-visible {
    background: var(--c-brand);
    border-color: var(--c-brand);
}

/* "Clear all" only exists when something is selected, so it sits at
   the end of the row rather than taking a pill's worth of space. */
.filter-clear-all {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    align-self: center;
    flex: 0 0 auto;
    white-space: nowrap;
}

/* Count line above the grid. */
.product-count {
    margin: -28px 0 24px;
    font-family: var(--font-body);
    font-size: clamp(12px, 1.11vw, 14px);
    color: var(--p-body);
}

@media (prefers-reduced-motion: reduce) {
    .filter-option,
    .filter-box,
    .filter-box::after,
    .filter-apply,
    .filter-reset,
    .filter-clear-all {
        transition: none;
    }
}

/* =========================================
   4. PRODUCT GRID
========================================= */
/* Figma: three 448-wide cards at x = 32 / 496 / 960 in a 1376 grid →
   16px between columns; rows sit on an 835 pitch with an 788-tall
   card → 47px between rows.

   THREE is the base, and the desktop override below takes it to four
   — see the note there for why. Three remains what tablets get. */
.product-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 47px 16px;

    /* The last row holds two cards in Figma and they stay LEFT
       aligned under columns 1 and 2 — grid does this by default, so
       nothing here should centre them. */
}

/* --- Four across on desktop --------------------------------------
 * A deliberate departure from the design file, asked for twice.
 *
 * Figma draws three 448-wide cards, and that is what was rendering:
 * at 1366 a card measured 419 with a 419x445 photograph, so one row
 * stood 764px tall — taller than the screen it was being read on. A
 * 26-product catalogue was arriving three at a time.
 *
 * Four columns in the same 1376 block gives (1376 - 3x16) / 4 = 332
 * per card, and the row drops to roughly 570.
 *
 * Everything inside the card is measured in cqw — a share of the CARD
 * — so the whole tile scales down with it, type included. That was
 * the explicit choice here: the name lands near 17.8px, the button
 * labels near 11.9px and the description sits on its 9px floor. It is
 * small, and it is what was asked for; the sizes to raise if it reads
 * too fine are the clamp ceilings on .product-name, .product-desc and
 * .btn-cart / .btn-view further down this file.
 *
 * 1280px is the switch point, and it was measured rather than picked.
 * The grid is the viewport less about 81px of page gutter, so a
 * four-column card is (V - 129) / 4:
 *
 *     switch at 1200  ->  271px card, 9.7px button label
 *     switch at 1280  ->  289px
 *     at 1366         ->  310px
 *     at 1440         ->  328px
 *     at 1505 and up  ->  332px (the 1376 block caps it)
 *
 * 1200 was tried first and is too low: it drops a 366px card straight
 * to 271 and takes the type under its floors. 1280 is the narrowest
 * screen still called a desktop, and it keeps the card near 290.
 * Anything below stays on the three columns the design specifies.
 *
 * "Customers Also Liked" takes the same four, on request — the cards
 * there were rendering 411 wide against 328 here and were asked to
 * match. It now asks for FOUR related products rather than three, so
 * the fourth column is filled rather than leaving a hole with the row
 * hanging left; see single-amaryllis_product.php. Where a category
 * holds fewer than five products the row simply comes up short, which
 * is what it did before at three.
 * ------------------------------------------------------------- */
@media (min-width: 1280px) {
    .products-page .product-grid,
    .pdp-related .product-grid {
        grid-template-columns: repeat(4, minmax(0, 1fr));
    }
}

/* --- Container-relative sizing --------------------------------
 * Everything inside the card is expressed as a percentage of the
 * CARD's width (cqw), not the viewport's. Figma's card is 448 wide,
 * so each design pixel converts as value/448:
 *     24px title  → 5.36cqw      16px copy    → 3.57cqw
 *     32px gap    → 7.14cqw      20px gap     → 4.46cqw
 *     48px button → 10.71cqw     12px gap     → 2.68cqw
 * Sized off the viewport instead, the clamp() floors stopped
 * shrinking once the card did, so on a narrow window the copy and
 * buttons sat proportionally larger than the design. This makes the
 * card an exact scale model of Figma at any width — same approach as
 * the homepage series cards.
 * ------------------------------------------------------------- */
.product-card {
    /* --- The card carries its OWN tokens ------------------------
     * These were on .products-page, which exists only on the listing
     * template. The same card is also rendered by "Customers Also
     * Liked" on the single product page, inside .product-single —
     * where every one of these var() calls resolved to nothing:
     * aspect-ratio was dropped so the media box had no shape,
     * border-radius fell to 0, the tile background disappeared, and
     * `background: var(--p-ink)` on Add To Cart made the button
     * invisible rather than dark.
     *
     * Defining them on the component instead of its page means the
     * card renders correctly anywhere it is dropped.
     *
     * Figma: the card is 448 x 788 and the image 444 x 476. Those two
     * widths disagree by 4px — the image sits flush to the card's left
     * edge and stops 4px short on the right, which is a slip in the
     * file, not an inset. The ratio is 448/476 rather than 444/476
     * because the media box fills the card: 444/476 made the image 480
     * tall instead of 476, pushing the card 4px past the design.
     * ----------------------------------------------------------- */
    --tile-ar: 448 / 476;
    --tile-radius: 20px;
    --tile-bg: #f1efec;
    --p-ink: var(--c-ink);

    container-type: inline-size;
    display: flex;
    flex-direction: column;
    text-align: center;
}

/* --- Artwork tile --- */
.product-media {
    position: relative;
    aspect-ratio: var(--tile-ar);
    border-radius: var(--tile-radius);
    background-color: var(--tile-bg);
    overflow: hidden;
}

.product-media img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
    transition: transform 0.7s var(--ease-out);
}

.product-card:hover .product-media img {
    transform: scale(1.035);
}

/* --- Wishlist heart ---
   Outline by default, fills in once saved. inc/shop.php sets .is-saved
   (and aria-pressed) server-side, and js/shop.js keeps both in step
   after an async toggle.

   Black at rest, red only once saved — the client's request. One rule
   covers every heart on the site: the product cards, the single product
   page and the wishlist page all render the same .wishlist-btn from
   amaryllis_wishlist_form(). */
.wishlist-btn {
    position: absolute;
    top: 16px;
    right: 16px;
    z-index: 2;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* Figma: the wishlist control is 48 x 48 with an 8px radius,
       sitting 16px in from the tile's top-right. Was 34. */
    width: 48px;
    height: 48px;
    border-radius: 8px;
    padding: 0;
    background: none;
    border: none;
    cursor: pointer;
    color: var(--c-ink);
    transition: transform 0.3s var(--ease-out);
}

.wishlist-btn svg {
    /* Figma: the heart vector is 32 x 26 inside the 48 x 48 control. */
    width: 32px;
    height: 26px;
    display: block;
    transition: fill 0.3s var(--ease-out);
}

.wishlist-btn:hover {
    transform: scale(1.12);
}

.wishlist-btn.is-saved {
    color: var(--c-wish);
}

.wishlist-btn.is-saved svg {
    fill: var(--c-wish);
}

/* --- Colour swatches ---
   Decorative until the backend supplies real variants, hence
   aria-hidden on the wrapper in the template.

   Every figure from here down is measured against the 444 tile and
   carried as a vw that resolves to it at the 1440 frame, so the
   whole card body scales as one block. */
.product-swatches {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 3.57cqw;                 /* Figma: 16 of 448 */
    min-height: 5.36cqw;          /* Figma: 24 of 448 */
    /* Figma: the image ends at 1530 and the swatch row starts at 1562
       (32px); the row ends at 1586 and the title block starts at 1606
       (20px). */
    margin: 7.14cqw 0 4.46cqw;
}

/* Figma: 24 x 24 circles, 16px apart, each with its own
   DROP_SHADOW y2 blur2 #000000 at 25% — not an inset ring. */
.swatch {
    width: 5.36cqw;   /* Figma: 24 of 448 */
    height: 5.36cqw;
    border-radius: 50%;
    background: var(--swatch, #ffffff);
    box-shadow: 0 2px 2px rgba(0, 0, 0, 0.25);
}

/* --- The interactive version -------------------------------------
 * Only rendered as an <a> where the product actually has photographs
 * for each colour; otherwise the swatch stays a plain <span> and none
 * of this applies. See amaryllis_product_swatches() in inc/shop.php.
 *
 * The selected ring is drawn OUTSIDE the circle with an outline rather
 * than a border, because a border would eat into the 24px the design
 * specifies and make the chosen swatch visibly smaller than its
 * neighbours.
 * ------------------------------------------------------------- */
a.swatch {
    display: block;
    flex: 0 0 auto;
    position: relative;
    cursor: pointer;
    outline: 2px solid transparent;
    outline-offset: 2px;
    transition:
        outline-color 0.2s var(--ease-out),
        transform 0.2s var(--ease-out);
}

/* --- Tap target ---------------------------------------------------
 * The circle is 24px at the design width and smaller than that on a
 * phone, which was fine while it was decoration and is not fine now
 * that it is a control — WCAG 2.2 asks for 24x24 as a minimum.
 *
 * Grown with a pseudo-element rather than with padding, so the DRAWN
 * circle stays exactly the size Figma specifies at every breakpoint
 * and only the area that responds to a finger changes.
 *
 * 26px is chosen against the tightest pitch on the site — 17px circles
 * with an 11px gap around 1024, so 28px centre to centre. A larger
 * target would start overlapping its neighbour and the wrong colour
 * would answer the tap.
 * ------------------------------------------------------------- */
a.swatch::before {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: max(100%, 26px);
    height: max(100%, 26px);
    transform: translate(-50%, -50%);
}

a.swatch:hover {
    transform: scale(1.12);
}

a.swatch.is-active {
    outline-color: var(--p-ink);
}

/* Keyboard focus has to outrank the selected ring, so it is stated
   after it and uses the brand colour to stay distinguishable. */
a.swatch:focus-visible {
    outline-color: var(--c-brand);
    outline-width: 3px;
}

/* Tapping a colour swaps the card's photograph. Fading it through the
   change stops the swap reading as a glitch, and it is scoped to
   cards whose swatches are live so nothing else on the page picks up
   a transition it never had. */
.product-card .product-media img {
    transition: opacity 0.25s var(--ease-out);
}

.product-card.is-swapping .product-media img {
    opacity: 0.35;
}

@media (prefers-reduced-motion: reduce) {
    a.swatch,
    .product-card .product-media img {
        transition: none;
    }

    a.swatch:hover { transform: none; }
}

/* --- Copy --- */
/* Figma: Open Sans w600 24 / lh 40 / #1f1f1f — the body font, and
   12px above the description. */
/* Figma: the title and the description share ONE 337-wide column
   centred in the 448 card — 75%. The title had no max-width, so it
   ran the full card while the copy under it was held to 63%; the two
   never lined up. */
.product-name {
    font-family: var(--font-body);
    font-size: clamp(11px, 5.36cqw, 22px);   /* Figma: 24 of 448 */
    font-weight: 400;
    /* 34/24 = 1.417, not the 40/24 = 1.67 the file's lineHeightPx
       reports. Figma's TEXT node says 40 but its box measures 34, and
       34 is what the 788 card height is built from — using 1.67 made
       the card 4px too tall. */
    line-height: 1.417;
    color: var(--c-ink);
    max-width: 75%;
    margin: 0 auto 2.68cqw;                  /* Figma: 12 of 448 */
}

/* Figma: Open Sans w600 16 / lh 24 / #1f1f1f. Was w400 at #4f4f4f. */
.product-desc {
    font-family: var(--font-body);
    font-size: clamp(9px, 3.57cqw, 14px);    /* Figma: 16 of 448 */
    font-weight: 400;
    /* Figma's two-line copy block measures 50, i.e. 25 per line on
       16px type. lh 1.5 gave 48. */
    line-height: 1.5625;
    color: var(--c-ink);
    /* Figma: 337 of the 448 card = 75%, centred — the same column the
       title sits in. Was 63%, which pinched the copy narrower than
       the design and forced an early line break. */
    max-width: 75%;
    margin-left: auto;
    margin-right: auto;
    /* Cards in a row must line their buttons up even when one blurb
       runs to three lines, so the copy block is a fixed two lines and
       anything longer is trimmed. margin-bottom:auto then pins the
       buttons to the bottom of the tallest card in the row. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
    /* auto on the sides centres the narrower measure; auto on the
       bottom still pins the buttons to the foot of the card. */
    margin: 0 auto auto;
}

/* --- Actions --- */
/* Figma: the copy ends at 1702 and Add To Cart starts at 1726 (24px);
   Add To Cart ends at 1774 and View More starts at 1794 (20px). */
.product-actions {
    display: flex;
    flex-direction: column;
    gap: 4.46cqw;                 /* Figma: 20 of 448 */
    margin-top: 5.36cqw;          /* Figma: 24 of 448 */
}

.btn-cart,
.btn-view {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* Figma: both CTAs are 448 x 48 with an 8px radius, labelled
       Open Sans w700 16 / lh 40. */
    width: 100%;
    padding: 4px 18px;
    font-family: var(--font-body);
    font-size: clamp(9px, 3.57cqw, 14px);   /* Figma: 16 of 448 */
    font-weight: 700;
    border-radius: 8px;
    border: 1px solid var(--p-ink);
    cursor: pointer;
    transition:
        background-color 0.35s var(--ease-out),
        border-color 0.35s var(--ease-out),
        color 0.35s var(--ease-out);
}

/* The solid button sits a few pixels taller than the outlined one in
   Figma — 57 against 53 on a 444 tile. */
/* Both are 48 of 448 in Figma — 10.71cqw — not 57 and 53. */
.btn-cart { min-height: 10.71cqw; }
/* No margin here — the 20px gap comes from .product-actions, and
   having both would double it. */
.btn-view { min-height: 10.71cqw; }

.btn-cart {
    background: var(--p-ink);
    color: var(--c-page);
}

.btn-view {
    background: transparent;
    color: var(--p-ink);
}

/* --- Enquiry variant ---------------------------------------------
 * .btn-enquire has MOVED to css/shop.css.
 *
 * It belongs there because amaryllis_add_to_cart_form() renders it
 * anywhere an unpriced product appears — including the wishlist drawer,
 * which is printed on EVERY page — while this stylesheet is enqueued
 * only on product templates. The result was the same Contact Us button
 * rendering 28px tall inside the drawer on a product page and 34px on
 * every other page, because the line-height below never reached it.
 * ------------------------------------------------------------- */

/* --- Hover ------------------------------------------------------
 * Figma component set "Add To Cart" (448 x 48) carries a hover variant
 * for every label, and they all resolve to the SAME thing:
 *
 *   Default            fill #1f1f1f            label #fdfdfd
 *   Default_hover      fill #81af4a  no stroke label #fdfdfd
 *   Connect With Us    fill #1f1f1f            label #fdfdfd
 *   Connect With Us_h  fill #81af4a  no stroke label #fdfdfd
 *   View More          outlined, 1px #1f1f1f   label #1f1f1f
 *   View More_hover    fill #81af4a  no stroke label #fdfdfd
 *
 * So each button turns brand green INDEPENDENTLY. What was here
 * before did something different: the pair swapped as a unit, so
 * pointing at View More also restyled Add To Cart next to it, and
 * neither one ever went green — Add To Cart went to flat black
 * (#000000, a colour that appears nowhere in the file) and View More
 * took the dark fill that belongs to its resting state.
 *
 * .btn-enquire is the "Connect With Us" variant and shares .btn-cart,
 * so it is covered by the same rule.
 *
 * The replacement lives in css/shop.css, for the reason spelled out
 * above .btn-enquire: amaryllis_add_to_cart_form() prints .btn-cart
 * into the WISHLIST DRAWER, which is on every page of the site, while
 * this stylesheet is only enqueued on product templates. Left here,
 * the same button would have turned green in the drawer on /products/
 * and stayed black in the drawer everywhere else.
 * ------------------------------------------------------------- */

/* --- Empty category ---------------------------------------------
 * Four of the five categories have no products yet. An empty grid
 * would read as a broken page, so they get a short line instead.
 * ------------------------------------------------------------- */
.product-empty {
    max-width: 620px;
    margin: clamp(30px, 5vw, 70px) auto clamp(40px, 6vw, 90px);
    text-align: center;
    font-size: clamp(14px, 1.18vw, 15px);
    line-height: 1.7;
    color: var(--p-body);
}

.product-empty a {
    color: var(--p-ink);
    font-weight: 400;
    box-shadow: inset 0 -1px 0 currentColor;
}

/* =========================================
   5. REVEAL — 3-COLUMN OVERRIDE
   -----------------------------------------
   style.css staggers by nth-child 1-7 for the home page's
   full/half/half rhythm, which is wrong here. These rules carry one
   extra class so they win on specificity regardless of load order:
   cards arrive left-to-right across each row of three, drifting in
   from their own side of the grid.
========================================= */
.has-js .product-grid.stagger > *:nth-child(3n + 1) {
    --reveal-delay: 0ms;
    --reveal-x: -26px;
}

.has-js .product-grid.stagger > *:nth-child(3n + 2) {
    --reveal-delay: 80ms;
    --reveal-x: 0px;
}

.has-js .product-grid.stagger > *:nth-child(3n + 3) {
    --reveal-delay: 160ms;
    --reveal-x: 26px;
}

/* --- ...and FOUR above 1200 --------------------------------------
 * The grid switches to four columns there, which leaves the 3n rules
 * above describing the wrong shape: the fourth card of a row would be
 * treated as the first of the next, arrive from the left with no
 * delay, and the wave would walk diagonally across the grid instead
 * of running along each row.
 *
 * Written after those rules rather than before. Specificity is
 * identical — a media query adds none — so source order is the only
 * thing that decides, and these have to come last to win.
 * ------------------------------------------------------------- */
@media (min-width: 1280px) {
    .has-js .products-page .product-grid.stagger > *:nth-child(4n + 1) {
        --reveal-delay: 0ms;
        --reveal-x: -26px;
    }

    .has-js .products-page .product-grid.stagger > *:nth-child(4n + 2) {
        --reveal-delay: 70ms;
        --reveal-x: -9px;
    }

    .has-js .products-page .product-grid.stagger > *:nth-child(4n + 3) {
        --reveal-delay: 140ms;
        --reveal-x: 9px;
    }

    .has-js .products-page .product-grid.stagger > *:nth-child(4n + 4) {
        --reveal-delay: 210ms;
        --reveal-x: 26px;
    }
}

/* =========================================
   6. RESPONSIVE
   Figma supplied a desktop frame only; these are disciplined
   reductions of the same layout.
========================================= */

/* Filters start to crowd before the grid does */
@media (max-width: 1100px) {
    .filter-pill {
        max-width: none;
        font-size: 14px;
        padding: 9px 12px;
        gap: 8px;
    }
}

@media (max-width: 992px) {
    .products-page { --row-gap: 44px; }

    .product-grid {
        grid-template-columns: repeat(2, 1fr);
    }

    /* Two columns now, so re-stagger in pairs — otherwise the 3n
       rules above would send the second card of a row in from the
       middle and the third in from the right. */
    .has-js .product-grid.stagger > *:nth-child(3n + 1),
    .has-js .product-grid.stagger > *:nth-child(3n + 2),
    .has-js .product-grid.stagger > *:nth-child(3n + 3) {
        --reveal-delay: 0ms;
        --reveal-x: 0px;
    }

    .has-js .product-grid.stagger > *:nth-child(2n + 1) { --reveal-x: -22px; }
    .has-js .product-grid.stagger > *:nth-child(2n + 2) { --reveal-delay: 90ms; --reveal-x: 22px; }

    .product-hero-copy { max-width: 460px; }
}

/* The filter row used to become a swipeable strip here — `overflow-x:
   auto` on .filter-bar. That cannot stay now the pills open panels:
   setting overflow on one axis makes the other `auto` too, so the bar
   became a scroll container and CLIPPED the dropdown that hangs below
   it. The panel was there and invisible.

   Wrapping instead. Four to six 38px pills fall into two or three
   short rows, the bar stops being a scroller, and the panel can hang
   out of it as designed — which matters more on a phone, where the
   filters are the only practical way through a 26-product grid. */
@media (max-width: 768px) {
    /* One Filters button instead of the row of pills (client request).
       .has-js because the script is what opens it: without JavaScript
       the button stays hidden and the bar stays open. */
    .has-js .filter-toggle {
        display: inline-flex;
        align-items: center;
        gap: 8px;
        min-height: 40px;
        margin-bottom: 32px;
        padding: 8px 16px;
        background: var(--c-page);
        border: 1px solid var(--p-line);
        border-radius: 8px;
        font-family: var(--font-body);
        font-size: 14px;
        font-weight: 600;
        color: var(--p-ink);
        cursor: pointer;
        transition:
            background-color 0.3s var(--ease-out),
            color 0.3s var(--ease-out);
    }

    .filter-toggle-icon {
        flex: 0 0 auto;
        width: 18px;
        height: 18px;
    }

    /* --- Applied filters, on the page, each one removable ---------
     * The pills are behind the Filters button at this width, so
     * without these the only sign anything was filtered was a number
     * on that button — and dropping one value meant opening the sheet.
     *
     * Each chip is an ordinary link to the same page minus that value,
     * so it needs no script.
     * ----------------------------------------------------------- */
    .filter-applied {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 8px;
        /* -20 closes the gap the Filters button leaves under itself;
           12 is the gap down to the product count, which the chips
           belong with — they are what the count is counting. */
        margin: -20px 0 12px;
    }

    /* .product-count carries margin-top: -28px so it tucks under the
       filter row when nothing sits between them. The chips DO sit
       between them, so that pull has to go or the count is dragged
       four pixels up into the chip row and the two overlap.

       The form and the count are siblings, and the chips only exist
       inside the form when something is applied — so :has() states
       exactly that condition rather than guessing at a number. */
    .filter-form:has(.filter-applied) + .product-count {
        margin-top: 0;
    }

    .filter-chip {
        display: inline-flex;
        align-items: center;
        gap: 7px;
        min-height: 30px;
        padding: 4px 10px;
        background: var(--c-brand);
        border-radius: 999px;
        font-family: var(--font-body);
        font-size: 13px;
        font-weight: 600;
        line-height: 1.2;
        color: var(--c-white);
    }

    .filter-chip-x {
        font-size: 16px;
        line-height: 1;
        opacity: 0.85;
    }

    /* Clear all reads as the exception to the row, not another value. */
    .filter-chip--clear {
        background: transparent;
        border: 1px solid var(--p-line);
        color: var(--p-ink);
    }

    .has-js .filter-form.is-open .filter-toggle {
        margin-bottom: 12px;
        background: var(--p-ink);
        border-color: var(--p-ink);
        color: var(--c-white);
    }

    .has-js .filter-form:not(.is-open) .filter-bar {
        display: none;
    }

    /* Hidden while the page scrolls past it, the bar's scroll reveal
       would replay half-finished the moment it is opened - so it is
       shown at full strength instead. */
    .has-js .filter-form .filter-bar.reveal {
        opacity: 1;
        transform: none;
        filter: none;
        animation: none;
    }

    /* =====================================================
       PHONE FILTER SHEET
       -----------------------------------------------------
       Client request, drawn from the reference they sent: the
       filters come up from the bottom of the screen as a sheet
       rather than dropping panels over the product grid.

         head    Filter                          x
         ------------------------------------------
         rail  | pane
         Size  | [ ] 4 Inch
         Color | [x] 6 Inch      <- the open filter's options
         Price | [ ] 8 Inch
         ------------------------------------------
         foot    Clear Filters          [ Apply ]

       Their reference is in another brand's colours; this is
       the same layout in ours — the dark ink the rest of the
       shop uses for a selected pill, and the brand green on
       Apply.

       The rail is the run of <summary> pills; the pane is
       whichever <details> is open. Only one can be — the same
       one-panel-at-a-time rule as on desktop
       (js/product-filters.js), which is what makes this read as
       two panes rather than an accordion.
       ===================================================== */
    .has-js .filter-form.is-open .filter-bar {
        position: fixed;
        z-index: 1200;
        left: 0;
        right: 0;
        bottom: 0;
        top: auto;
        display: flex;
        flex-direction: column;
        gap: 0;
        height: min(80dvh, 620px);
        margin: 0;
        padding: 0;
        background: var(--c-page);
        border-radius: 18px 18px 0 0;
        box-shadow: 0 -12px 40px rgba(31, 31, 31, 0.22);
        overflow: hidden;
    }

    /* The page behind it. A sibling would need a new element; the
       toggle button is already on the page and already knows when the
       sheet is open, so the scrim hangs off that. */
    .has-js .filter-form.is-open .filter-toggle::after {
        content: '';
        position: fixed;
        z-index: 1100;
        inset: 0;
        background: rgba(31, 31, 31, 0.45);
    }

    .has-js .filter-form.is-open .filter-sheet-head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        flex: none;
        padding: 16px 18px 14px;
        border-bottom: 1px solid #e6e6e6;
    }

    .filter-sheet-title {
        margin: 0;
        font-family: var(--font-display);
        font-size: 19px;
        font-weight: 700;
        line-height: 1.2;
        color: var(--p-ink);
    }

    .filter-sheet-close {
        display: inline-flex;
        padding: 4px;
        background: none;
        border: 0;
        color: var(--p-ink);
        cursor: pointer;
    }

    .filter-sheet-close svg {
        width: 20px;
        height: 20px;
    }

    /* --- The two panes -------------------------------------------
     * The rail is ordinary flow: each <details> is a 38%-wide block,
     * so they stack down the left. The open one's panel is lifted out
     * of that flow and pinned to the right 62%, which is what puts the
     * options BESIDE the list rather than under the pill.
     * ----------------------------------------------------------- */
    .has-js .filter-form.is-open .filter-groups {
        position: relative;
        display: block;
        flex: 1 1 auto;
        min-height: 0;
        overflow: hidden;
        background: #f5f6f7;
    }

    .has-js .filter-form.is-open .filter-group {
        display: block;
        width: 38%;
        position: static;
    }

    /* The rail rows are not pills here, so the filled-pill treatment
       (dark ground, white text) is undone for all four of the states
       that set it — including :hover, which on a rail row left white
       text on the rail's own light grey. */
    .has-js .filter-form.is-open .filter-pill,
    .has-js .filter-form.is-open .filter-pill:hover,
    .has-js .filter-form.is-open .filter-pill:focus-visible,
    .has-js .filter-form.is-open .filter-group.has-selection > .filter-pill {
        justify-content: flex-start;
        min-height: 52px;
        padding: 10px 14px;
        background: transparent;
        border: 0;
        border-radius: 0;
        font-size: 14px;
        color: var(--p-body, #5a5a5a);
        text-align: left;
    }

    /* A filter with values chosen keeps its tally and goes to full ink,
       so the rail still says where the selections are. */
    .has-js .filter-form.is-open .filter-group.has-selection > .filter-pill {
        color: var(--p-ink);
        font-weight: 700;
    }

    /* --- What is ticked, named under the filter -------------------
     * Client request: the rail said a filter was on, and how many
     * values, but not WHICH. With the panel closed that is the only
     * thing on screen, so the count alone meant opening each filter
     * in turn to find out what you had chosen.
     *
     * The row becomes two lines: the filter's name, then its values
     * in small brand-green type underneath.
     * ----------------------------------------------------------- */
    .has-js .filter-form.is-open .filter-pill {
        flex-direction: column;
        align-items: flex-start;
        gap: 2px;
        padding-top: 8px;
        padding-bottom: 8px;
    }

    .has-js .filter-form.is-open .filter-chosen {
        display: block;
        max-width: 100%;
        font-size: 11px;
        font-weight: 600;
        line-height: 1.3;
        color: var(--c-brand);
        /* Three values in a 38% rail would otherwise run past its edge. */
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    /* The tally is redundant once the values themselves are listed. */
    .has-js .filter-form.is-open .filter-pill .filter-tally {
        display: none;
    }

    /* The open filter is the one the pane belongs to, so it is the one
       marked — a white tab reaching into the pane beside it, with the
       brand stripe down its leading edge. */
    .has-js .filter-form.is-open .filter-group[open] > .filter-pill {
        background: var(--c-page);
        border-left: 3px solid var(--c-brand);
        padding-left: 11px;
        color: var(--p-ink);
        font-weight: 700;
    }

    /* The caret belongs to a dropdown, and this is not one. */
    .has-js .filter-form.is-open .filter-caret {
        display: none;
    }

    .has-js .filter-form.is-open .filter-panel {
        position: absolute;
        z-index: 1;
        top: 0;
        right: 0;
        bottom: 0;
        left: 38%;
        width: auto;
        min-width: 0;
        padding: 14px 16px;
        background: var(--c-page);
        border-radius: 0;
        box-shadow: none;
        overflow-y: auto;
    }

    /* The list has the whole pane to fill, so its own cap goes. */
    .has-js .filter-form.is-open .filter-options {
        columns: auto;
        max-height: none;
    }

    .has-js .filter-form.is-open .filter-option {
        padding: 9px 0;
        font-size: 14px;
    }

    /* Per-panel Clear/Apply and the old Clear all: the sheet's own foot
       does both jobs for every filter at once. */
    .has-js .filter-form.is-open .filter-foot,
    .has-js .filter-form.is-open .filter-clear-all {
        display: none;
    }

    .has-js .filter-form.is-open .filter-sheet-foot {
        display: flex;
        align-items: center;
        gap: 14px;
        flex: none;
        padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
        border-top: 1px solid #e6e6e6;
        background: var(--c-page);
    }

    .filter-sheet-clear {
        flex: 0 0 auto;
        padding: 10px 4px;
        font-family: var(--font-body);
        font-size: 15px;
        font-weight: 700;
        color: var(--p-ink);
    }

    .filter-sheet-apply {
        flex: 1 1 auto;
        min-height: 44px;
        padding: 10px 18px;
        background: var(--c-brand);
        border: 0;
        border-radius: 8px;
        font-family: var(--font-body);
        font-size: 15px;
        font-weight: 700;
        color: var(--c-white);
        cursor: pointer;
    }

    /* --- No JavaScript ------------------------------------------
     * Nothing above applies: .is-open is only ever set by the script.
     * The bar stays in the page as a wrapping row of pills, which is
     * what it was before the sheet existed.
     * ----------------------------------------------------------- */
    .filter-bar {
        flex-wrap: wrap;
        gap: 10px;
        margin-bottom: 32px;
    }

    .filter-group {
        /* Two per row, minus the gap. */
        flex: 1 1 calc(50% - 5px);
        /* Unpositioned again, so the dropdown spans the whole bar - a pill
           is too narrow to hold it on a phone. */
        position: static;
    }

    .filter-pill {
        min-height: 38px;
    }

    .filter-panel {
        padding: 16px;
    }

    .filter-options {
        columns: auto;
        max-height: 46vh;
    }

    .filter-clear-all {
        flex: 1 1 100%;
        justify-content: center;
    }
}

@media (max-width: 576px) {
    .products-page {
        --row-gap: 34px;
    }

    /* On the card, not the page — a value set directly on .product-card
       beats one inherited from an ancestor, so this override has to sit
       at the same level as the definition or it would never apply. And
       it needs to reach the cards on the single product page too. */
    .product-card {
        --tile-radius: 14px;
    }

    /* The <br> breaks in the banner copy are desktop-only */
    .product-hero-copy h1 br,
    .product-hero-copy p br {
        display: none;
    }

    .product-hero-rule {
        margin: 18px 0 16px;
    }

    /* =====================================================
       PHONE BANNER: UNCROPPED PICTURE, HEADLINE ON IT
       -----------------------------------------------------
       Two client requests that sound opposed and are not.

       First: stop cropping. Every category photograph is 16:9
       and the banner was holding them in a frame roughly
       375 x 430, so only about 40% of the width was in view —
       the product was either cut out of the picture
       (Multipurpose, Fresh Air) or buried under the copy
       (Inline). The fix is to let the banner take the
       picture's own shape, so nothing is cropped at all.

       Second: put the headline back ON the banner rather than
       under it, and show the headline ALONE — no standfirst,
       no rule. That is what makes the first fix work: a 375
       wide 16:9 frame is only 211px tall, which is nowhere
       near enough for a headline AND three lines of
       standfirst, but is comfortable for a headline by
       itself.

       So the image flows at its natural ratio and the copy is
       lifted back over it, vertically centred, with everything
       except the h1 hidden. The ink variants and the scrim are
       left exactly as the wider layout sets them, because the
       type is on the photograph again.
       ===================================================== */
    .product-hero {
        display: block;
        height: auto;
    }

    .product-hero-img {
        position: static;
        width: 100%;
        height: auto;
        /* The frame is now the picture's own shape, so cover crops
           nothing. object-position is overridden because the template
           sets it inline for the cropped frame this no longer is —
           and an inline style needs !important to beat. */
        aspect-ratio: 16 / 9;
        object-fit: cover;
        object-position: 50% 50% !important;
    }

    /* Back over the picture. .product-hero is position:relative in the
       base rule, so this pins to the image above it. */
    .product-hero-inner {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        padding: 0 var(--section-gutter);
    }

    /* Headline only. */
    .product-hero-rule,
    .product-hero-copy p {
        display: none;
    }

    /* --- Keep the headline off the products ----------------------
     * It was running the full width of the frame and straight across
     * them. Measured off the five banner files, the products all sit
     * in the same place: roughly 50% to 90% across, where the detail
     * is (45 to 65), while the left ~40% is dark, near-empty ground
     * (detail 9 to 27).
     *
     * So the headline is capped to that empty left zone and set a
     * little smaller, which wraps it to three or four short lines
     * down the clear side. The product is then fully visible beside
     * it rather than behind it.
     * ----------------------------------------------------------- */
    .product-hero-copy {
        max-width: 58%;
    }

    .product-hero-copy h1 {
        font-size: 21px;
        line-height: 1.18;
    }

    .product-listing {
        padding: 26px var(--grid-inset) 55px;
    }

    /* The fallback is load-bearing. --row-gap is only defined on
       .products-page, but this grid also renders on the single product
       page ("Customers Also Liked"). There the variable does not
       exist, a var() with no fallback makes the whole
       declaration invalid, and `gap` drops to its initial value -
       `normal`, which is ZERO in a grid. The cards sat touching, side
       to side and row to row. 34px is the value .products-page sets at
       this width. */
    .product-grid {
        gap: var(--row-gap, 34px) 14px;
    }

    /* Gap raised from 7px so the 26px tap targets above sit clear of
       one another. 14px circles on a 14px gap is a 28px pitch against
       a 26px target, which leaves 2px of dead space between them: a
       tap that lands exactly on the seam does nothing, rather than
       selecting the colour next to the one that was aimed at. */
    .product-swatches {
        margin: 16px 0 12px;
        gap: 14px;
    }

    /* Was 12px. These are pressable now, and while the tap target is
       widened invisibly above, a 12px dot is also hard to AIM at —
       14px keeps the row compact and the colours readable. */
    .swatch {
        width: 14px;
        height: 14px;
    }

    .wishlist-btn {
        top: 10px;
        right: 10px;
        width: 28px;
        height: 28px;
    }

    .wishlist-btn svg {
        width: 19px;
        height: 19px;
    }

    .btn-cart,
    .btn-view {
        min-height: 40px;
        padding: 10px 12px;
        font-size: 13px;
    }

    .product-actions {
        gap: 8px;
        margin-top: 14px;
    }
}

@media (prefers-reduced-motion: reduce) {
    .product-media img,
    .wishlist-btn,
    .filter-pill,
    .filter-caret,
    .btn-cart,
    .btn-view {
        transition: none;
    }

    .product-card:hover .product-media img {
        transform: none;
    }
}
