/* =============================================================================
   Catalogue — grid, card, rail, drawer, compare tray, product detail.

   Loaded ONLY on catalogue views (functions.php), because shipping this to a
   legal page is weight that buys nothing on shared hosting (§8.17).

   Same rules as main.css: tokens only, mobile-first, min-width at
   480/768/1024/1280, cascade layers rather than forced priority.
   ========================================================================== */

/* Short viewports: the drawer goes full-screen and internally scrollable
   rather than sitting in a fixed column (§8.13). */
@media (max-height: 640px) {
  .filter-drawer__panel { inline-size: 100%; }
}

/* The GET filter form remains reachable when scripting is unavailable. */
html:not(.has-js) .filter-drawer {
  display: block;
  position: static;
  inline-size: 100%;
  block-size: auto;
  margin: 0;
}
html:not(.has-js) .filter-drawer__panel {
  inline-size: 100%;
  block-size: auto;
  max-block-size: none;
}
html:not(.has-js) [data-open-drawer="filter-drawer"],
html:not(.has-js) .filter-drawer__close { display: none; }
/* Catalogue product-grid defaults and mobile toolbar fit.
 * Mobile first: one column on phones, three on tablets, four on desktop.
 * Visitor density choices in catalogue.js still override these defaults through
 * .product-grid[data-cols]. Breakpoints follow the canonical 768/1024 switches.
 */
@layer components {

  /* ==========================================================================
     CATEGORY HERO (§5.6) — THE SAME SHAPE AS THE DESTINATION HERO
     (template-parts/destination-hero.php), deliberately so.

     A full-width photo at a fixed, "normal" height (--hero-media-h) with a
     white cutout card overlapping it — position: absolute AT EVERY WIDTH, so
     the card's content can never inflate the photo's height, but the card's
     own shape adapts per breakpoint: anchored to the photo's block-end and
     stretched full-inline at the base width (a phone hero is naturally
     taller than it is wide, so there's room along the bottom even in
     portrait); moved to the inline-start edge and stretched full-block from
     --bp-md instead (a landscape photo has the width to spare for a side
     column but not the height to spare along the bottom).

     TEXT NEVER SITS ON THE PHOTO PIXELS. The card is fully opaque, so the
     only thing crossing onto the photo is its border — the §8.2 scrim is
     still not needed, and an admin-uploaded photo's unpredictable brightness
     still cannot break legibility.
     ======================================================================= */

  .category-hero {
    position: relative;
  }

  .category-hero__media {
    /* Scopes __placeholder's inset: 0 to this block — the card is
       position: absolute at every width now, so .category-hero's rendered
       height is always exactly this block's. */
    position: relative;
    display: block;
    /* FIXED, not min — the photo dictates this box's height and the
       absolutely-positioned card below can never inflate it. */
    block-size: var(--hero-media-h);
    overflow: hidden;
    background-color: var(--bg-tint);
  }

  .category-hero__img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    object-position: center center;
  }

  .category-hero__img--bottom-center {
    object-position: center bottom;
  }

  /* Empty state: the block stays so the layout does not jump the day a photo
     is uploaded. --bg-tint + a --line hairline, never a grey "missing image"
     glyph that reads as a broken page (§8.15). The mark is decorative. */
  .category-hero__media--empty {
    border-block: 1px solid var(--line);
  }

  .category-hero__placeholder {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--ink-500);
  }

  /* THE CARD, BASE WIDTH: anchored to the photo's block-end, stretched
     across the inline axis minus the page gutter. inset-block-end: 0 (no
     gap) runs the card flush to the photo's bottom edge, the same
     "touch the wall" treatment the --bp-md-up layout gives the
     inline-start edge — content-hugging height (no inset-block-start) keeps
     the card taller than it is wide on a narrow phone viewport, a vertical
     shape to match the portrait photo. max-block-size leaves at least
     --s-9 of the photo visible above the card even at its tallest;
     overflow-y: auto is the fallback for content that still doesn't fit —
     this card can hold the longest content of the three (the "why this,
     here" line plus the term description), so it's the one most likely to
     ever need it. */
  .category-hero__copy {
    position: absolute;
    /* Same reason as .destination-hero__copy (main.css): .category-hero__
       media is also position: relative, so paint order between two
       positioned, auto-z-index siblings follows DOM order — copy-first in
       the markup would otherwise paint BEHIND the media that follows it. */
    z-index: var(--z-raised);
    inset-inline: var(--gutter-x);
    inset-block-end: 0;
    max-block-size: calc(var(--hero-media-h) - var(--s-9));
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: var(--s-5);
    min-inline-size: 0;
    padding: calc(var(--s-5) + 5px);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: 0;
  }

  /* THE OFFSET BORDER, BASE WIDTH: same idea as the --bp-md-up ::before
     below — a second border inset from the card's edges, left open on
     whichever edge is already flush to a wall. Here that's the block-end
     (the card touches the photo's bottom edge at this width, not the
     inline-start), so the offset border insets from the top/left/right and
     runs flush + open along the bottom instead. Inset is --s-4 minus 2px,
     tighter than the card's own (bumped) padding so it doesn't crowd it. */
  .category-hero__copy::before {
    content: "";
    position: absolute;
    inset-inline: calc(var(--s-4) - 2px);
    inset-block-start: calc(var(--s-4) - 2px);
    inset-block-end: 0;
    border: 1px solid var(--brass);
    border-block-end: 0;
    pointer-events: none;
  }

  .category-hero__title {
    max-inline-size: 34ch;
  }

  .category-hero__why,
  .category-hero__description {
    max-inline-size: var(--w-prose);
    /* Paragraph, not a heading — matches .home-hero__intro / .destination-
       hero__lead / .page-hero__description in main.css, all of which moved
       from --fs-h4 to --fs-body once --fs-body dropped to 12px. */
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    color: var(--ink-700);
  }

  .category-hero__description .term-description,
  .category-hero__description .term-description p {
    margin: 0;
  }

  .category-hero__description .term-description p + p {
    margin-block-start: var(--s-4);
  }

  /* ==========================================================================
     THE RAIL (§9.11)
     A scroll container, not a carousel widget. No library, no auto-advance.
     ======================================================================= */

  /* Padding, not margin: the section hairline sits on the border box, and
     margin would leave a gap of empty page above the rule. Top keeps the
     compact rhythm; bottom runs a step tighter — the cards themselves already
     carry visual weight down to their own edge, so the same padding below as
     above read as more empty space than the section needed. */
  .rail {
    padding-block-start: var(--section-y-compact);
    padding-block-end: var(--s-5);
  }

  .rail__head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--s-5);
    margin-block-end: var(--s-5);
  }

  /* Size comes from the section-heading role in main.css (§8.3). */
  .rail__heading { margin: 0; }

  /* Arrows are an ENHANCEMENT at --bp-lg+ only. Below that the gesture is the
     interface and arrows just eat width (§9.11).

     THE PAUSE CONTROL IS NOT AN ARROW AND IS NEVER HIDDEN. If a rail is
     auto-advancing on a phone, WCAG 2.2 SC 2.2.2 requires a way to stop it ON
     THAT PHONE — hiding the control below --bp-lg would put the site in breach
     exactly where most visitors are (§6.10, §8.16). Arrows hide; pause does
     not. */
  .rail__controls {
    display: flex;
    gap: var(--s-3);
  }

  .rail__arrow:not(.rail__pause) { display: none; }

  .rail__pause-on,
  .rail__pause-off { display: grid; place-items: center; }

  /* The button always shows what it WILL DO, never what is happening. */
  .rail__pause-off { display: none; }
  .rail.is-paused .rail__pause-on { display: none; }
  .rail.is-paused .rail__pause-off { display: grid; }

  /* THE TRACK IS CONSTRAINED TO THE PAGE CONTAINER, not full-bleed.
     Its heading already sits in `.container` (rail.php), so a track running
     edge to edge started its first card outside the column every other block
     on the page lines up with — the rail read as a different page element
     rather than as the section under its own heading. These three declarations
     are `.container`'s, so the two cannot drift. */
  .rail__track {
    display: flex;
    gap: var(--grid-gutter);
    margin-inline: auto;
    margin-block: 0;
    max-inline-size: var(--w-max);
    padding-inline: var(--gutter-x);
    padding-block-end: var(--s-3);
    overflow-x: auto;
    scroll-snap-type: inline mandatory;
    /* EXPLICIT, not left to the browser's default (0). Snap stops are
       computed against `scroll-padding`, not against the track's own
       `padding-inline` — the two usually happen to agree, but Safari in
       particular has been inconsistent about inferring one from the other
       (§8.14's "Safari especially… scroll-snap" warning), and when they
       disagree a card can snap a few pixels past the container's own gutter,
       reading as "outside the page's content width" even though the track
       itself never overflows. Stating it explicitly, matching padding-inline
       exactly, is what keeps every snap stop flush with the same column the
       rest of the page lines up to. */
    scroll-padding-inline: var(--gutter-x);
  }

  /* TRANSPARENT AT REST, FADED IN WHILE ACTUALLY SCROLLING — never toggled
     with `display`, which was the bug: a scrollbar that goes from `display:
     none` to a drawn 6px bar changes the track's own rendered height the
     instant scrolling starts, so the section below it visibly jumped. The fix
     is to reserve that 6px permanently (the SECTION'S layout accounts for it
     from the start, rather than the scrollbar popping in and moving things)
     and only ever animate its COLOUR — transparent thumb at rest, `--ink-500`
     while scrolling — which is a property that can transition smoothly,
     unlike `display`. `.is-scrolling` is toggled by catalogue.js on `scroll`,
     cleared after a short idle timeout — there's no CSS-only way to detect
     "currently scrolling," so this is JS-assisted (§8.17): without it the
     thumb simply never colours in, which still never blocks scrolling itself
     (swipe, wheel, drag and the arrow controls all work regardless). */
  .rail__track {
    scrollbar-width: thin;
    scrollbar-color: transparent transparent;
  }
  .rail__track.is-scrolling {
    scrollbar-color: var(--ink-500) transparent;
  }
  .rail__track::-webkit-scrollbar { block-size: 6px; }
  .rail__track::-webkit-scrollbar-track { background: transparent; }
  .rail__track::-webkit-scrollbar-thumb {
    background-color: transparent;
    border-radius: var(--r-full);
    transition: background-color var(--dur-base) var(--ease);
  }
  .rail__track.is-scrolling::-webkit-scrollbar-thumb {
    background-color: var(--ink-500);
  }

  /* Slide width is a per-rail custom property so a caller can change how many
     are in view without a second set of rail styles (§9.11). */
  .rail__track { --rail-item: 78%; }

  /* THE PROJECTS RAIL RUNS SMALL, on the track's own variable — never
     `.rail__item`'s width directly, per the bug note below this block. */
  .rail--projects .rail__track { --rail-item: 45%; }

  /* EVERY BREAKPOINT SETS `--rail-item` ON THE TRACK. NOTHING SETS
     `inline-size` ON THE ITEM EXCEPT THE LINE BELOW.
     This is not a style preference — it is the bug that made the weekly picks
     rail show three cards no matter what the picks override said. The `sm` and
     `md` blocks used to declare `.rail__item { inline-size: 46% }` and
     `{ 34% }` directly. Every min-width query still matches at 1024px, so at
     `lg` the last `inline-size` to be declared was the `md` block's 34%, which
     beat the base rule's `var(--rail-item)` on source order at equal
     specificity. The `lg` block duly computed a picks value — and it was read
     by nothing. A per-rail override that is silently ignored is worse than no
     override, because the value looks right in the stylesheet. */
  .rail__item {
    flex: 0 0 auto;
    /* NEVER full-bleed off both edges without a visible partial card: the last
       card in view is cut deliberately, which is what tells a visitor to
       scroll. A rail whose final card lands flush reads as a finished row
       (§9.11). */
    inline-size: var(--rail-item);
    /* Snap to the card START, not the centre — centred snapping on a 2-up
       phone layout leaves half a card each side and no reading order. */
    scroll-snap-align: start;
  }

  /* The edge fade IS the scroll affordance — without it nobody discovers there
     is more to the right (§9.11, §8.13). Applied only when the rail actually
     scrolls: a fade on a non-scrolling row is a lie.
     `black`/`transparent` here are mask alpha keywords, not palette colours. */
  .rail.is-scrollable .rail__track {
    mask-image: linear-gradient(
      to right,
      transparent 0,
      black var(--s-5),
      black calc(100% - var(--s-11)),
      transparent 100%
    );
  }

  /* THE PICKS RAIL DROPS THE MASK-BASED FADE. Elsewhere the fade IS the
     "there's more" affordance (§9.11) — but the picks rail auto-advances and
     carries its own visible arrows and pause control, so the motion already
     tells a visitor there's more; the two-sided mask was only softening the
     card edges. */
  .rail--picks.is-scrollable .rail__track {
    mask-image: none;
    -webkit-mask-image: none;
  }

  /* A LIGHT WHITE BLEND ON THE RIGHT EDGE ONLY, not the two-sided mask above.
     `.rail__viewport` (rail.php) is a non-scrolling wrapper around the track,
     so this overlay stays fixed at the visible right edge regardless of
     scroll position — positioning it on the track itself would scroll it away
     with the track's own content, since it would be a descendant of the
     scrolling element rather than a sibling of it. `--bg` because the section
     sits directly on the page background (no card, no tint) and the overlay
     has to blend into whatever is actually behind it, not an assumed white. */
  /* MIRRORS THE TRACK'S OWN WIDTH-CAPPING, not just its position. A plain
     block-level div is full-width by default regardless of its child's own
     max-inline-size — without this, `.rail__viewport` would span edge to
     edge of `.rail` (full viewport width), so the fade below, inset from
     *this* element's own edge, would sit out in the page margin on any
     screen wider than --w-max instead of at the track's actual right edge,
     nowhere near the cards it's meant to blend. */
  .rail--picks .rail__viewport {
    position: relative;
    margin-inline: auto;
    max-inline-size: var(--w-max);
  }

  .rail--picks.is-scrollable .rail__viewport::after {
    content: "";
    position: absolute;
    inset-block: 0;
    inset-inline-end: 0;
    inline-size: var(--s-11);
    /* Lighter than a fade to full --bg: the end stop stays partway
       transparent (color-mix, §8.14) so the last card still reads through
       it rather than being blotted out at the very edge. */
    background-image: linear-gradient(
      to right,
      transparent,
      color-mix(in srgb, var(--bg) 65%, transparent)
    );
    pointer-events: none;
  }

  /* BARE MARKS, NO BOX — the same rule the footer socials follow (§9.5).
     A bordered button in the rail header competes with the heading beside it
     and reads as a third piece of chrome; unboxed, the arrow reads as what it
     is, a direction. THE HIT AREA IS UNCHANGED at --field-h: the box is
     removed, not the target, so nothing drops below the §8.4 floor (44px under
     a finger, 32 under a mouse). */
  .rail__arrow {
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    padding: 0;
    background: none;
    border: 0;
    border-radius: var(--r-sm);
    color: var(--ink-700);
    cursor: pointer;
    transition: color var(--dur-fast) var(--ease);
  }
  .rail__arrow:hover { color: var(--ink-900); }

  /* DISABLED, NOT HIDDEN, at each end — so the rail doesn't reflow
     mid-interaction (§9.11, §8.15). --ink-500 is 5.55:1 on --bg, well clear of
     the 3:1 floor a disabled control has to keep: disabled must still be
     legible, not greyed to nothing (§8.15). The `disabled` attribute is what
     carries the state to assistive tech, so this is never colour alone. */
  .rail__arrow[disabled] {
    color: var(--ink-500);
    cursor: not-allowed;
  }

  /* ==========================================================================
     CATEGORY CARDS — the catalogue's front door (§5.6)
     Two-up from the start, like the product grid: a single column of four
     enormous cards throws away the density argument in §8.4 and turns the one
     decision on the page into a scroll.
     ======================================================================= */

  .category-cards { padding-block: var(--section-y-compact) var(--section-y); }

  .category-cards__heading {
    margin-block-end: var(--s-6);
  }

  .category-cards__grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--grid-gutter);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .category-card {
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
    padding: var(--card-pad);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
  }

  .category-card__link {
    display: flex;
    flex-direction: column;
    gap: var(--s-4);
    text-decoration: none;
  }

  .category-card__media {
    display: block;
    /* Same fixed ratio discipline as the product card — reserved before paint
       so a lazy image can't shift the row (§8.13, §8.17). */
    aspect-ratio: 4 / 3;
    overflow: hidden;
    /* Inner radius = outer − padding (§8.5): an 8px card with 12px padding
       holds its child a step down, never matching. */
    border-radius: var(--r-sm);
    background-color: var(--bg-alt);
  }

  .category-card__img {
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }

  .category-card__body {
    display: flex;
    flex-direction: column;
    gap: var(--s-1);
  }

  .category-card__name {
    font-size: var(--fs-h3);
    font-weight: var(--fw-subhead);
    color: var(--ink-900);
  }

  .category-card__count {
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  .category-card__why {
    margin: 0;
    font-size: var(--fs-small);
    line-height: var(--lh-small);
    color: var(--ink-700);
  }

  .category-card__children {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .category-card__child {
    display: inline-block;
    padding: var(--s-2) var(--s-3);
    background-color: var(--bg-tint);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    font-size: var(--fs-small);
    color: var(--ink-700);
    text-decoration: none;
  }

  .category-card__child:hover {
    border-color: var(--ink-500);
    color: var(--ink-900);
  }

  /* ---- The compact variant — "Narrow it down" (§5.6) --------------------- *
     A filter shortcut sitting above a grid the visitor has already reached, not
     a destination: square thumbnail on the left, name and count on the right,
     nothing below. Equal boxes, just smaller than the shop-root cards — two
     columns from the start, three at md, four at lg — so a pair of children
     does not stretch into two half-page rectangles. Padding and the thumb
     stay on --s-2 and --s-8 so the products under this band stay on
     the first screen. */

  .category-cards--compact {
    /* Same --section-y-compact (32/48, auto-scaling at --bp-lg) the
       shop-root variant already uses, and the same hairline-marks-a-new-
       topic idiom as .faqs/.product-detail__body elsewhere (§8.4) — the
       border sits on THIS section's own trailing edge rather than as a
       border-block-start on whatever follows, since what follows varies
       (the toolbar directly, or the projects rail first). The toolbar's own
       margin-block-start (--s-7, catalogue.css) supplies the matching air
       on the other side of the rule, same as it does everywhere else it
       appears — no override needed here. Override the padding, do not add
       margin on top of it. Height is content: another row of children adds
       a grid track. */
    padding-block: var(--section-y-compact);
    margin-block: 0;
    border-block-end: 1px solid var(--line);
  }

  .category-cards--compact .category-cards__heading {
    font-size: var(--fs-h3);
    margin-block: 0 var(--s-3);
  }

  .category-cards--compact .category-cards__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-auto-rows: auto;
    gap: var(--s-2);
  }

  .category-cards--compact .category-card {
    padding: var(--s-2);
    gap: 0;
    border-radius: var(--r-sm);
  }

  .category-cards--compact .category-card__link {
    flex-direction: row;
    align-items: center;
    gap: var(--s-3);
  }

  /* Square, and FIXED — flex would otherwise shrink the thumb to fit a long
     category name, and a row of thumbnails at three different widths is what
     makes a list look assembled rather than designed. Always painted: the
     placeholder keeps the same 32px box as a real photo, so adding a hero
     later cannot shift the row (§8.13). */
  .category-cards--compact .category-card__media {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    inline-size: var(--s-8);   /* 32px */
    block-size: var(--s-8);
    aspect-ratio: 1;
    overflow: hidden;
  }

  .category-cards--compact .category-card__placeholder {
    display: block;
    vertical-align: 0;
    inline-size: var(--s-5);
    block-size: var(--s-5);
    color: var(--ink-500);
  }

  .category-cards--compact .category-card__name { font-size: var(--fs-small); }

  /* ---- Project tiles inside the projects rail (§5.6) --------------------- */

  .project-tile { margin: 0; }

  .project-tile__open {
    display: block;
    inline-size: 100%;
    padding: 0;
    border: 0;
    background: none;
    cursor: zoom-in;
  }

  /* VERTICAL, matching the 4/5 portrait ratio already used for the product
     card and gallery thumbs (§5.7, §5.8) — one crop convention rather than a
     landscape tile here and a portrait one everywhere else the site shows a
     photo (§8.5's "consistency beats correctness"). Shared by the category
     rail and the Delivered Projects grid (§6.5): same photos, same crop. */
  .project-tile__img {
    inline-size: 100%;
    aspect-ratio: 4 / 5;
    object-fit: cover;
    border-radius: var(--r-md);
  }

  .project-tile__caption {
    margin-block-start: var(--s-3);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  /* ==========================================================================
     TOOLBAR (§5.5)
     ======================================================================= */

  /* A HAIRLINE UNDER THE TOOLBAR, and less air above it. The controls and the
     grid they act on are two different things and were separated by nothing but
     16px; the rule is what makes the toolbar read as chrome over the results
     rather than as the first row of them. */
  .catalogue__toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s-4);
    margin-block: var(--s-7) var(--s-5);
    padding-block-end: var(--s-4);
    border-block-end: 1px solid var(--line);
  }

  .catalogue__sort {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    font-size: var(--fs-small);
  }

  /* "Sort by" reads on the dropdown itself below 768px, not as separate text
     beside it — visually hidden here rather than removed, so the <select>'s
     accessible name survives for screen-reader visitors (§8.15). Restored to
     visible text once the toolbar has room for it (below). */
  .catalogue__sort label {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  .catalogue__sort select {
    block-size: var(--field-h);
    padding-inline: var(--s-3);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
  }

  .catalogue__count {
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  .catalogue__filter-count {
    display: inline-grid;
    place-items: center;
    min-inline-size: var(--s-6);
    padding-inline: var(--s-2);
    background-color: var(--accent-tint);
    border-radius: var(--r-sm);
    font-variant-numeric: tabular-nums;
  }

  /* ==========================================================================
     PRODUCT GRID + CARD (§5.7)
     Dense inside, generous outside (§8.4): tight gutters read as SELECTION and
     VALUE; sparse grids read as boutique and thin inventory.
     ======================================================================= */

  .product-grid {
    display: grid;
    /* 2-UP ON PHONES, NEVER 1-UP, or the density argument is lost (§8.13). */
    /* One step above the shared --grid-gutter tokens, scoped to this grid only
       (other grids sharing those tokens — category cards, rails, FAQ — are
       unaffected). Still the 4px scale, still vertical-one-step-above-
       horizontal (§8.4). */
    gap: var(--s-4) var(--s-3);
    margin: 0;
  }

  /* ---- Pagination (WooCommerce list markup) -----------------------------
     woocommerce_pagination() renders <nav class="woocommerce-pagination">
     <ul class="page-numbers">, not core's the_posts_pagination() shape
     (<nav class="navigation pagination"><div class="nav-links">) the shared
     .pagination/.nav-links rule in main.css was written for — that rule is
     blog-only (index.php, §6.6) and never matched here. The individual pill
     — border, --tap-min sizing, current/hover state — is a bare .page-numbers
     selector in main.css and already applies to both; only the WC list
     itself was unstyled, so it fell back to the UA default: block-stacked
     <li>s (bullets already stripped by the reset layer's `ul[class]` rule,
     margin already zeroed by `* { margin: 0 }` — display was the one thing
     missing). */
  .woocommerce-pagination { margin-block-start: var(--s-10); }
  .woocommerce-pagination ul.page-numbers {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
  }
  /* The skipped-range span, not a link — no border box, no hover, no tap
     target to imply. */
  .woocommerce-pagination .page-numbers.dots {
    min-inline-size: auto;
    border-color: transparent;
    color: var(--ink-500);
  }

  /* ---- Density switcher (§5.7) ------------------------------------------ *
     A VISITOR PREFERENCE, not a design setting — §3.11 keeps grid columns out
     of the admin, and this is a different thing: it changes one person's view
     and nothing else.

     THE OVERRIDE IS SCOPED TO [data-cols], so the §8.13 breakpoint defaults
     still govern until a visitor actually chooses. It carries an attribute
     selector on top of the class, which puts it above the plain
     .product-grid rule without needing a later layer or forced priority. */
  .product-grid[data-cols="1"] { grid-template-columns: repeat(1, minmax(0, 1fr)); }
  .product-grid[data-cols="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .product-grid[data-cols="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .product-grid[data-cols="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }

  .catalogue__density {
    display: flex;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    overflow: hidden;
  }

  .catalogue__density-btn {
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    padding: 0;
    background: none;
    border: 0;
    color: var(--ink-500);
    cursor: pointer;
  }

  .catalogue__density-btn + .catalogue__density-btn { border-inline-start: 1px solid var(--line); }
  .catalogue__density-btn:hover { background-color: var(--bg-tint); color: var(--ink-900); }

  /* State is never colour alone — aria-pressed carries it for assistive tech
     (§8.2, §8.15). */
  .catalogue__density-btn[aria-pressed="true"] {
    background-color: var(--bg-alt);
    color: var(--ink-900);
  }

  /* THE OFFERED OPTIONS BRACKET THE BREAKPOINT'S OWN DEFAULT, so a choice can
     always be undone at the width it was made: phones default to 2-up and offer
     1 or 2, tablets default to 3 and offer 2 or 3, and from --bp-lg the default
     is 4 so 2-up drops out — 3 and 4 remain. A 2-column row on a desktop
     catalogue throws away the density argument in §8.4. An option that produces
     an unreadable (or sparse) grid is not a choice, it is a trap.

     1-UP IS A VISITOR PREFERENCE AND NEVER THE DEFAULT. The base grid stays
     2-up (§8.13), so §8.4's density argument still governs what everyone lands
     on; this is one person choosing to spend more of their own screen.

     Buttons outside the current window are removed from flow rather than dimmed,
     since there is nothing the visitor could do to enable them. */
  .catalogue__density-btn--3,
  .catalogue__density-btn--4 { display: none; }

  /* THE CARD IS A FLEX COLUMN, NOT A SUBGRID.
   *
   * §5.7 asked for uniform card heights via `grid-template-rows: subgrid` with
   * `grid-row: span 5`. That only works while the card is a grid item of a grid
   * that HAS those rows — and §8.14 requires this same card to render in the
   * weekly picks rail and the compare tray, both of which are FLEX containers
   * with no rows to inherit. There the subgrid is invalid, the span is ignored,
   * and the card collapses to content width while its image spills out of the
   * border: the rail was rendering slivers with the photo overflowing both
   * sides and no title visible at all.
   *
   * A flex column works identically in every container. Uniform heights still
   * come for free INSIDE the grid, because grid items stretch to their row by
   * default — the card's outer box matches its row regardless of how much of
   * that height its own content fills. What is given up is title/meta/price
   * sharing exact row tracks across a row — and that was never happening
   * anyway, since all three sit inside one `__body` div. */
  .product-card {
    display: flex;
    flex-direction: column;
    /* The card sizes to its CONTAINER, not the viewport (§8.14). */
    container-type: inline-size;
    position: relative;
    /* NO explicit inline-size. In the grid the card is a grid item and
       stretches to its column; in the rail it carries `rail__item` itself and
       takes that width. An `inline-size: 100%` here would override the rail's
       78%/26% and put one card per screen. */
    padding: var(--card-pad);
    background-color: var(--surface);
    border: 1px solid var(--line);
    /* --r-sm, not --r-md. A ~160px tile at 360px (§8.13) is small enough that an
       8px corner reads as a rounded object rather than a squared one with its
       edges taken off, and the grid is a dense scan surface (§8.4). Still a
       scale token, not an invented value (§8.5). The media below tracks it. */
    border-radius: var(--r-sm);
  }

  /* display:contents so the link wraps media+body for hit area and semantics
     without becoming a flex item between them. */
  .product-card__link {
    display: contents;
    text-decoration: none;
    color: inherit;
  }

  .product-card__media {
    position: relative;
    /* FIXED 4:5 PORTRAIT at every breakpoint, so odd supplier image sizes can't
       cause layout shift (§5.7, §8.17). */
    aspect-ratio: 4 / 5;
    /* FULL-BLEED ON THREE SIDES: the card's padding is cancelled at the block
       start and both inline edges, so the image meets the card's border rather
       than floating in a white margin. That margin was doing no work — the
       image is the card's subject and the padding it needs is BELOW it, between
       the photo and the title. It also cost ~24px of the ~160px a card gets at
       360px (§8.13), which is width the 4:5 image can use.
       Cancelling the token rather than a literal keeps the 12px→16px step at
       --bp-lg automatic. */
    margin-block-start: calc(var(--card-pad) * -1);
    margin-inline: calc(var(--card-pad) * -1);
    margin-block-end: var(--s-3);
    /* The nesting rule is "inner radius = outer − padding" (§8.5), and on the
       two flush corners that padding is now zero — so the top corners MATCH the
       card at --r-sm, and the bottom two square off where the media stops being
       an edge. */
    border-radius: var(--r-sm);
    border-end-start-radius: 0;
    border-end-end-radius: 0;
    overflow: hidden;
    /* The letterbox field behind a `contain` image — see below. */
    background-color: var(--bg-alt);
  }

  /* Hit-area for the product page. Absolute so Compare can sit on the image
     as a sibling rather than a nested control inside the link (§5.7). */
  .product-card__media-link {
    position: absolute;
    inset: 0;
    display: block;
    color: inherit;
    text-decoration: none;
  }

  /* `contain`, NOT `cover` — the whole product is always visible.
   *
   * `cover` fills the 4:5 box by cropping whatever doesn't fit, which on a
   * square supplier swatch silently removes about a fifth of the product. For
   * a room photo that is fine; for a plank, a tile or a paint chip the image
   * IS the specification, and a cropped one is a wrong one.
   *
   * The 4:5 box itself does not change, so every structural guarantee it
   * exists for holds: the grid still aligns, and nothing shifts on load
   * (§5.7, §8.17). The cost is letterbox bars on images that aren't 4:5,
   * which is why --bg-alt sits behind them rather than white. */
  .product-card__img {
    position: absolute;
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    /* Match the media frame so WordPress width/height attributes cannot
       shrink the box to the bitmap. object-fit: contain still letterboxes
       inside; Compare and the closeup then sit on the image, not in a gap. */
    aspect-ratio: 4 / 5;
    object-fit: contain;
  }

  /* Hover swap is capability-gated, NOT width-gated: a 1024px touch laptop is
     not a mouse. Without this, touch devices fire the swap on tap and eat the
     click (§8.13, §5.7). */
  .product-card__img--hover { display: none; }

  @media (hover: hover) and (pointer: fine) {
    .product-card__img--hover {
      display: block;
      position: absolute;
      inset: 0;
      opacity: 0;
      transition: opacity var(--dur-base) var(--ease);
    }
    .product-card:hover .product-card__img--hover { opacity: 1; }
  }

  /* ---- The closeup inset (§5.7) ----------------------------------------- */

  .product-card__closeup {
    position: absolute;
    /* Bottom-inline-end corner, inset 8px. Badges keep the top corners, so the
       two never collide (§5.7, §8.2). */
    inset-block-end: var(--s-3);
    inset-inline-end: var(--s-3);
    inline-size: 56px;
    block-size: 56px;
    object-fit: cover;
    /* A child of an 8px-radius image, so the nesting rule puts it a step down,
       not matching (§8.5). */
    border-radius: var(--r-sm);
    /* Separated by a 2px ring, NOT a shadow — a border reads at 56px where
       --shadow-sm disappears (§8.6). No tint, no frosting, no gradient: it is a
       colour sample and the colour must be true (§8.2). */
    border: 2px solid var(--surface);
  }

  /* ---- Badges ----------------------------------------------------------- */

  .product-card__badges {
    position: absolute;
    inset-block-start: var(--s-3);
    inset-inline-start: var(--s-3);
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-1);
    margin: 0;
  }

  /* Compact chips: body leading (1.6) was inflating an 11px label into a
     22px slab. Pin line-height and drop the inline padding a step so the
     tag sits as a mark on the photo rather than a second heading. Type
     and radius are a step below the rest of the UI so a filled red chip
     does not read as a second heading or a pill (§5.7). */
  .badge {
    padding: var(--s-1) var(--s-2);
    /* White on maple red: conversion marks on the photo, not a second CTA.
       Accent-scarcity exception is in CLAUDE.md §8.2 / §5.7. */
    background-color: var(--accent);
    border-radius: var(--r-xs);
    font-size: var(--fs-badge);
    font-weight: var(--fw-ui);
    line-height: 1;
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--surface);
  }

  /* ---- Card body -------------------------------------------------------- */

  .product-card__body { display: contents; }

  /* Steps up from the 12px baseline: the product name is the thing a visitor
     scans a grid for, so it is one of the roles that "varies according to
     need" (§8.3). */
  .product-card__title {
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
    line-height: var(--lh-small);
    /* Truncation is the FALLBACK for uniform rows, not the mechanism. */
    white-space: nowrap;
    text-overflow: ellipsis;
    overflow: hidden;
  }

  .product-card__meta {
    margin-block-start: var(--s-1);   /* 2px title→meta — tightened (§8.4) */
    font-size: var(--fs-micro);
    color: var(--ink-500);
  }

  /* On a card the price steps DOWN to --fs-h4 and gains the weight back
     (§8.3). It is still the decision number, but in a ~160px card at 360px it
     was the largest thing on the tile and outweighed the product name the
     visitor is actually scanning for. Size separated it from the title before;
     weight separates it now — 700 against the title's 600 at the same 14px,
     plus --ink-900 against --ink-500 meta. The DETAIL page keeps --fs-price
     (18), where the price has a column to itself and no grid to stay quiet in. */
  .product-card__price {
    margin-block-start: var(--s-2);
    font-size: var(--fs-h4);
    font-weight: var(--fw-display);
    color: var(--ink-900);
  }

  /* THE CTA BAR (CALL/WHATSAPP) IS REMOVED FROM THE CARD, on direct request.
     Compare stays — it lives on the image, never in this strip (§5.7), and is
     styled separately below. Both channels are still one tap away on the
     detail page (§5.8), the mobile nav (§9.2) and the footer (§9.5). */

  /* COMPARE ON THE IMAGE, not in the CTA bar (§5.7). Bottom-inline-start,
     opposite the closeup inset, as a LABELLED PILL — the mark plus the word
     "Compare", so the control reads at a glance rather than asking a visitor
     to learn an icon on a photo. --surface plus a hairline so it reads on an
     unpredictable photo without a scrim; --field-h keeps the target. */
  .product-card__compare {
    position: absolute;
    z-index: var(--z-raised);
    inset-block-end: var(--s-3);
    inset-inline-start: var(--s-3);
    display: inline-flex;
    align-items: center;
    gap: var(--s-1);
    /* Same scale as the Free install / Sale tags above it (§5.7): --r-xs,
       --fs-badge, badge-weight padding — so the two overlay marks on the
       photo read as one family rather than two chip sizes. */
    padding: var(--s-1) var(--s-2);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-xs);
    font-family: inherit;
    font-size: var(--fs-badge);
    font-weight: var(--fw-ui);
    line-height: 1;
    color: var(--ink-700);
    white-space: nowrap;
    cursor: pointer;
  }
  /* The chip now PAINTS at badge scale, but it is a control, not a mark — its
     tap target must not shrink with it (§8.4). Same idiom as .btn::after
     (§8.9): a transparent inset expands the hit area to --field-h without the
     visual box growing a pixel. */
  .product-card__compare::after {
    content: "";
    position: absolute;
    inset-inline: 0;
    inset-block: calc((var(--field-h) - var(--fs-badge) - (2 * var(--s-1))) / -2);
  }
  .product-card__compare:hover {
    border-color: var(--ink-500);
    color: var(--ink-900);
  }
  /* GREY, NOT THE MAPLE-RED ACCENT TINT: a pressed/selected control reads as
     "chosen" against a neutral fill. --accent stays the one primary action
     per viewport (§8.2) — a reddish fill on a control that isn't Book was
     competing with it. State is never colour alone — aria-pressed carries it
     for assistive tech too. */
  .product-card__compare[aria-pressed="true"] {
    background-color: var(--bg-alt);
    color: var(--ink-900);
  }
  .product-card__compare[disabled] {
    color: var(--ink-500);
    cursor: not-allowed;
  }

  /* On the tightest cards the MARK yields to the word, not the reverse: the
     icon drops so "Compare" keeps reading at a glance and the chip does not
     collide with the closeup inset (§5.7). */
  @container (max-width: 160px) {
    .product-card__compare .pinnacle-icon { display: none; }
  }

  /* ==========================================================================
     FILTER DRAWER (§5.5)
     ======================================================================= */

  .filter-drawer {
    padding: 0;
    border: 0;
    background: transparent;
    max-inline-size: 100%;
    max-block-size: 100%;
    inline-size: 100%;
    block-size: 100%;
  }

  .filter-drawer::backdrop {
    background-color: color-mix(in srgb, var(--ink-900) 55%, transparent);
    transition: background-color var(--dur-base) var(--ease);
  }

  /* THE PANEL SLIDES IN FROM WHERE IT RESTS, not a generic fade — it is a
     side panel at --bp-lg (below), so sliding in from that same inline-end
     edge reads as the panel's own entrance rather than a decoration bolted
     on top of it. @starting-style provides the "before" position for a
     dialog going from not-rendered to open (§8.14); no exit animation,
     same convention as .modal — closing stays instant. */
  .filter-drawer__panel {
    display: flex;
    flex-direction: column;
    /* FULL-SCREEN below --bp-lg (§8.13). This is the variant that most often
       traps a visitor, which is why the close control lives in a sticky header
       and is reachable without scrolling at 320px (§5.5). */
    inline-size: 100%;
    block-size: 100%;
    background-color: var(--surface);
    translate: 0 0;
    transition: translate var(--dur-base) var(--ease);
  }

  @starting-style {
    .filter-drawer[open]::backdrop { background-color: transparent; }
    .filter-drawer[open] .filter-drawer__panel { translate: 100% 0; }
  }

  .filter-drawer__head {
    position: sticky;
    inset-block-start: 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s-4);
    padding: var(--s-5);
    background-color: var(--surface);
    border-block-end: 1px solid var(--line);
  }

  .filter-drawer__title {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    font-family: var(--font-body);
    font-size: var(--fs-h3);
    font-weight: var(--fw-subhead);
  }

  .filter-drawer__active {
    display: inline-grid;
    place-items: center;
    min-inline-size: var(--s-7);
    padding-inline: var(--s-2);
    background-color: var(--accent-tint);
    border-radius: var(--r-sm);
    font-size: var(--fs-micro);
    font-variant-numeric: tabular-nums;
  }

  .filter-drawer__close {
    display: grid;
    place-items: center;
    inline-size: var(--s-10);
    block-size: var(--s-10);
    background: none;
    border: 0;
    color: var(--ink-900);
    cursor: pointer;
    border-radius: var(--r-sm);
  }

  .filter-drawer__body {
    flex: 1 1 auto;
    /* A flex item's default min-block-size is its content's min-content
       size, not 0 — so without this, the fieldsets (now taller since the
       price slider was added) could refuse to shrink to the space actually
       left after the sticky head/foot, pushing this box past the panel's
       own fixed block-size. The dialog itself has no explicit overflow, so
       that overflow silently became the DIALOG's problem instead of this
       box's: the whole panel — head, footer and all — floated and scrolled
       as one unit, with `overflow-y: auto` below never engaging because it
       never actually ran out of room. `.compare-view__body` already sets
       this for the same flex-column-with-a-scrolling-middle shape. */
    min-block-size: 0;
    overflow-y: auto;
    padding: var(--s-5);
    display: grid;
    gap: var(--s-8);
  }

  .filter-drawer__foot {
    position: sticky;
    inset-block-end: 0;
    display: flex;
    gap: var(--s-4);
    padding: var(--s-5);
    background-color: var(--surface);
    border-block-start: 1px solid var(--line);
  }
  .filter-drawer__foot .btn { flex: 1 1 0; }

  .facet { border: 0; padding: 0; margin: 0; }

  .facet__legend {
    padding: 0;
    margin-block-end: var(--s-4);
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
    color: var(--ink-900);
  }

  .facet__options { display: grid; gap: var(--s-2); }

  .facet__option {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    min-block-size: var(--field-h);
    font-size: var(--fs-small);
    cursor: pointer;
  }

  /* Native controls styled without rebuilding them (§8.14, §7.1). */
  .facet__option input { accent-color: var(--accent); inline-size: var(--s-6); block-size: var(--s-6); }

  .facet__option-count { margin-inline-start: auto; color: var(--ink-500); font-variant-numeric: tabular-nums; }

  /* ---- Price slider (§5.5) --------------------------------------------
     TWO REAL RANGE INPUTS, STACKED, each interactive only through its own
     thumb (`pointer-events: none` on the input, `auto` on the thumb
     pseudo-element) — the standard dual-range technique. A click on the
     bare track never has to guess which thumb it meant to move; every drag
     starts on an actual thumb. The track/fill are separate divs because a
     native range can only paint ONE colour behind its own thumb, and this
     needs two: the untouched track and the selected span between the
     thumbs. Purely progressive (§8.17) — the Min/Max number inputs below
     are the real, no-JS-required control; catalogue.js only keeps this in
     sync with them. */
  .price-slider {
    position: relative;
    block-size: var(--s-6);
    margin-block: var(--s-3) var(--s-5);
  }

  .price-slider__track {
    position: absolute;
    inset-inline: 0;
    inset-block-start: 50%;
    block-size: var(--s-2);
    transform: translateY(-50%);
    border-radius: var(--r-sm);
    background-color: var(--line);
  }

  .price-slider__fill {
    position: absolute;
    inset-block: 0;
    border-radius: var(--r-sm);
    background-color: var(--accent);
  }

  .price-slider__input {
    position: absolute;
    inset-inline: 0;
    inset-block-start: 0;
    inline-size: 100%;
    block-size: 100%;
    margin: 0;
    background: none;
    pointer-events: none;
    appearance: none;
    -webkit-appearance: none;
  }

  .price-slider__input::-webkit-slider-runnable-track {
    -webkit-appearance: none;
    background: none;
    block-size: 100%;
  }
  .price-slider__input::-moz-range-track {
    background: none;
    block-size: 100%;
    border: 0;
  }

  .price-slider__input::-webkit-slider-thumb {
    -webkit-appearance: none;
    pointer-events: auto;
    inline-size: var(--s-6);
    block-size: var(--s-6);
    border-radius: var(--r-sm);
    background-color: var(--accent);
    border: 2px solid var(--surface);
    box-shadow: 0 0 0 1px var(--line);
    cursor: pointer;
  }
  .price-slider__input::-moz-range-thumb {
    pointer-events: auto;
    inline-size: var(--s-6);
    block-size: var(--s-6);
    border-radius: var(--r-sm);
    background-color: var(--accent);
    border: 2px solid var(--surface);
    box-shadow: 0 0 0 1px var(--line);
    cursor: pointer;
  }

  .price-slider__input:focus-visible::-webkit-slider-thumb,
  .price-slider__input:focus-visible::-moz-range-thumb {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  .facet__range { display: flex; gap: var(--s-4); margin-block-start: var(--s-3); }
  .facet__range-field { flex: 1 1 0; display: grid; gap: var(--s-2); font-size: var(--fs-small); }
  .facet__range-field input {
    block-size: var(--field-h);
    padding-inline: var(--s-3);
    background-color: var(--bg-tint);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
  }

  .facet__clear {
    margin-block-start: var(--s-3);
    background: none;
    border: 0;
    padding: 0;
    font-size: var(--fs-small);
    color: var(--ink-500);
    text-decoration: underline;
    cursor: pointer;
  }

  /* ==========================================================================
     COMPARE TRAY + IN-PAGE VIEW (§5.9)
     ======================================================================= */

  /* SLIDES UP FROM OFF-SCREEN when catalogue.js clears its `hidden` attribute
     (§5.9), rather than snapping into place — @starting-style gives an
     element going from not-rendered to rendered a "before" value to animate
     FROM (§8.14), same technique as .modal/.filter-drawer. No exit
     animation, same convention as both: closing (catalogue.js re-setting
     `hidden`) stays instant. */

  @starting-style {
  }

  /* Full-viewport dialog, not a sheet that grows from the tray toward the
     header. showModal() puts it in the top layer (§8.8, §8.14). */

  /* A FADE, NOT A SCALE — unlike .modal's small centred box, this panel is
     full-viewport, so scaling it in would read as the whole page zooming
     rather than a dialog arriving. Same @starting-style/no-exit-animation
     convention as .modal and .filter-drawer. */

  @starting-style {
  }

  /* Down from 8rem: this is a column of short spec labels (Brand, Colour,
     Size), not prose, so it was giving up more width than any label in it
     needed and crowding the three product columns beside it. */

  /* Compact icon-only remove, sitting in the corner of the photo — the same
     inset the closeup badge and card badges already use (§5.7) — rather than
     a full-width button under it. It no longer competes with the column for
     width, which is most of what the column was carrying (§5.9). */

  /* One universal CTA in the modal's foot, not a channel bar per column
     (§5.9, compare-tray.php) — Call/Email/WhatsApp are the same three
     destinations regardless of which products are being compared, so they
     render once rather than three times. Sits outside the scrolling body so
     it stays reachable without scrolling the table (§8.13). */

  /* ==========================================================================
     PRODUCT DETAIL (§5.8)
     ======================================================================= */

  /* `minmax(0, 1fr)`, not the grid default: an unspecified single-column
     track sizes to the AUTO/max-content width of its contents, and the
     gallery/specs/appointment widget below all carry content that can't
     shrink past a few hundred px (the calendar's 7 fixed tap-min columns,
     the time-list's fixed-min-width chips) — so on a real phone the whole
     page grew to that max-content width instead of the viewport. */

  /* The description and FAQs below the two columns are a new topic, so they get
     a rule rather than a gap — the same treatment as the FAQ block and the
     toolbar (§8.4). It's also the last thing in the page's normal flow (the
     sticky CTA bar below --bp-lg is position: fixed, out of flow) — .site-main
     no longer supplies a shared page-level bottom padding, so the closing
     gap is this block's own margin-block-end. No matching border/padding at
     the end: a hairline marks a new topic starting, and nothing follows this
     one to separate from. */

  /* …but never two rules stacked. A product with no description puts the FAQ
     block first inside __body, where its own top rule would land 1px under
     this one. */

  .product-gallery__trigger {
    display: block;
    cursor: zoom-in;
    border-radius: var(--r-md);
  }
  .product-gallery__trigger:focus-visible {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  /* 4:3 RATHER THAN THE CARD'S 4:5. The card is portrait so a dense grid lines
     up (§5.7); this is a single image with a whole column to itself, and the
     taller ratio was spending that column's width on empty side margins. 4:3
     over the wider column below makes the actual pixels of the product
     noticeably larger without the page growing. */
  .product-gallery__main {
    inline-size: 100%;
    aspect-ratio: 4 / 3;
    object-fit: cover;
    border-radius: var(--r-md);
  }

  .product-gallery__thumbs {
    display: flex;
    gap: var(--s-3);
    margin-block-start: var(--s-3);
    padding-block-end: var(--s-2);
    overflow-x: auto;
    scroll-snap-type: inline mandatory;
  }

  .product-gallery__thumbs li { flex: 0 0 auto; scroll-snap-align: start; }

  .product-gallery__thumb {
    padding: 0;
    background: none;
    border: 2px solid transparent;
    border-radius: var(--r-sm);
    cursor: pointer;
    line-height: 0;
  }
  .product-gallery__thumb[aria-pressed="true"] { border-color: var(--ink-900); }
  .product-gallery__thumb img { inline-size: var(--s-11); block-size: var(--s-11); object-fit: cover; border-radius: var(--r-sm); }

  /* ---- Feature strip (§5.8) --------------------------------------------- */

  .feature-strip {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
    margin-block: var(--s-5);
  }

  /* A LIGHT BORDER ON --surface, not a filled --bg-alt block. Against the
     neutral white scale (§8.2) a filled chip reads as a button a visitor can
     press — and these are not links (§5.8). A hairline outline says "label",
     which is what they are, and it keeps the row legible on both --bg and
     --bg-alt without needing two treatments. */
  .feature-strip__chip {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    padding: var(--s-2) var(--s-4);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    font-size: var(--fs-small);
    color: var(--ink-700);
  }

  /* Icons are monochrome --ink-700, or --brass where a detail line reads
     better. NEVER --accent: six accent icons a scroll above the Book button is
     exactly how the accent-scarcity rule dies (§5.8, §8.2). */
  .feature-strip__chip svg { color: var(--ink-700); }

  /* ---- Spec boxes (§5.8) ------------------------------------------------ *
     A REAL TABLE SHAPE: fully bordered, zebra-striped, tight. It was a stack of
     rows with a single bottom rule and 16px of vertical padding each, which at
     eight attributes ran taller than the gallery beside it and gave the eye no
     row to track across. Sides and outer frame close it; alternating fills do
     the row-tracking a rule can't; and the padding comes down to 8/12 because a
     spec table is scanned, not read (§8.4).

     Proximity still holds (§8.4): the label→value gap stays visibly smaller
     than the gap between rows, which is what stops it becoming a stripe. */

  .spec-boxes {
    margin-block: var(--s-6);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
    /* The frame is rounded; the striped rows inside are not. */
    overflow: hidden;
  }

  .spec-boxes__row {
    display: grid;
    gap: var(--s-1);                   /* 2px label→value — tightened (§8.4) */
    padding: var(--s-3) var(--s-4);    /* 8px / 12px */
    border-block-end: 1px solid var(--line);
  }

  /* The last row must not draw a rule against the frame's own edge. */
  .spec-boxes__row:last-child { border-block-end: 0; }

  /* ZEBRA FROM --bg-tint, NOT --bg-alt. --bg-alt is the recessed band (§8.2) and
     at 1.08:1 it stripes as two different tables; --bg-tint against
     --surface is 1.07:1 — visible as a row, invisible as a colour. */
  .spec-boxes__row:nth-child(even) { background-color: var(--bg-tint); }

  .spec-boxes__label {
    display: flex;
    align-items: center;
    gap: var(--s-2);
    font-size: var(--fs-small);
    font-weight: var(--fw-subhead);
    color: var(--ink-900);
  }

  /* The mark is decorative and must never pull rank on the value beside it —
     --ink-500, never --accent (§8.2, §5.8). */
  .spec-boxes__label svg {
    flex: none;
    color: var(--ink-500);
  }

  .spec-boxes__value {
    margin: 0;
    font-size: var(--fs-small);
    color: var(--ink-700);
  }

  /* ---- The action strip (§5.8) ------------------------------------------ *
     ONE BOX AROUND THE WHOLE STRIP, not a frame per control — the same rule as
     the card (§5.7). It sits ABOVE the estimator: the estimator ends with the
     booking CTA directly under its result, and nothing may come between those
     two (§8.10 #1). */

  /* Favourite, compare, share and copy — a tool group, framed once. */

  /* Inside the group the buttons drop their own frames; standalone .icon-btn
     elsewhere keeps its border.

     --field-h, NOT --tap-min: §8.4 pins only calendar cells, time slots and
     mobile nav rows to the 44px floor at every pointer type. --field-h keeps
     the 44px target under a finger and drops to 32px under a mouse, in step
     with every other control instead of sitting a size above them. */
  .icon-btn {
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    color: var(--ink-700);
    cursor: pointer;
  }
  .icon-btn:hover { border-color: var(--ink-500); }

  /* ---- FAQs (§6.7) -------------------------------------------------------
     BASE RULES LIVE IN main.css, NOT HERE. `.faqs`/`.faq` render on the /faq/
     page too (via [pinnacle_faq]), which loads main.css but never this file
     (§8.17 — a plain content page doesn't need the grid/rail/drawer/compare
     weight this stylesheet carries). This file keeps only the
     `.product-detail__body > .faqs:first-child` override above, specific to
     the product-detail context. */

  /* ---- Sticky product CTA bar (§5.8, §8.8) ------------------------------ */

  .product-cta-bar {
    position: fixed;
    inset-block-end: 0;
    inset-inline: 0;
    z-index: var(--z-sticky);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s-4);
    padding: var(--s-3) var(--gutter-x);
    background-color: var(--surface);
    border-block-start: 1px solid var(--line);
  }

  .product-cta-bar__price { font-weight: var(--fw-subhead); color: var(--ink-900); }

  /* ---- Lightbox (§5.6, §8.14) ------------------------------------------ */

  /* A top-layer <dialog>, so it renders above every z-index including the
     consent banner — which is exactly why §8.8 puts everything at --z-modal
     and above in the top layer rather than on the scale. */
  .lightbox {
    padding: var(--s-5);
    border: 0;
    max-inline-size: 100%;
    max-block-size: 100%;
    background-color: var(--surface);
    border-radius: var(--r-md);
  }

  .lightbox::backdrop { background-color: color-mix(in srgb, var(--ink-900) 78%, transparent); }

  .lightbox__img {
    max-inline-size: min(90vw, 1100px);
    max-block-size: 78vh;
    object-fit: contain;
    border-radius: var(--r-sm);
  }

  .lightbox__caption {
    margin-block-start: var(--s-4);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  .lightbox__close {
    display: block;
    margin-inline-start: auto;
    margin-block-end: var(--s-3);
    min-block-size: var(--tap-min);
    padding-inline: var(--s-5);
    background-color: var(--bg-alt);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    font-size: var(--fs-small);
    cursor: pointer;
  }

  /* Product lightbox: full-viewport, dark field, zoom + pan. The white card
     above is the project-photo dialog; this one is for inspecting material. */
  .lightbox--product {
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    max-inline-size: none;
    max-block-size: none;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 0;
    background-color: color-mix(in srgb, var(--ink-900) 92%, transparent);
    color: var(--surface);
  }
  .lightbox--product::backdrop {
    background-color: color-mix(in srgb, var(--ink-900) 92%, transparent);
  }

  .lightbox--product .lightbox__toolbar {
    position: absolute;
    inset-block-start: 0;
    inset-inline: 0;
    z-index: var(--z-raised);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s-3);
    padding: var(--s-3) var(--s-4);
    pointer-events: none;
  }
  .lightbox--product .lightbox__toolbar > * { pointer-events: auto; }

  .lightbox--product .lightbox__count {
    margin: 0;
    font-size: var(--fs-small);
    font-variant-numeric: tabular-nums;
    color: var(--surface);
  }

  .lightbox--product .lightbox__tools {
    display: flex;
    gap: var(--s-2);
    margin-inline-start: auto;
  }

  .lightbox--product .lightbox__tool,
  .lightbox--product .lightbox__nav {
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    padding: 0;
    background: none;
    border: 0;
    border-radius: var(--r-sm);
    color: var(--surface);
    cursor: pointer;
  }
  .lightbox--product .lightbox__tool:hover,
  .lightbox--product .lightbox__nav:hover { color: var(--bg); }
  .lightbox--product .lightbox__tool:focus-visible,
  .lightbox--product .lightbox__nav:focus-visible {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }
  .lightbox--product .lightbox__tool[aria-pressed="true"] {
    background-color: color-mix(in srgb, var(--surface) 16%, transparent);
  }
  .lightbox--product .lightbox__nav[disabled] {
    color: color-mix(in srgb, var(--surface) 55%, transparent);
    cursor: not-allowed;
  }

  .lightbox--product .lightbox__mark {
    display: grid;
    place-items: center;
  }

  .lightbox--product .lightbox__nav {
    position: absolute;
    inset-block-start: 50%;
    translate: 0 -50%;
    z-index: var(--z-raised);
  }
  .lightbox--product .lightbox__nav[data-lightbox-prev] { inset-inline-start: var(--s-3); }
  .lightbox--product .lightbox__nav[data-lightbox-next] { inset-inline-end: var(--s-3); }

  .lightbox--product .lightbox__stage {
    display: grid;
    place-items: center;
    inline-size: 100%;
    block-size: 100%;
    overflow: hidden;
    cursor: zoom-in;
    touch-action: none;
  }
  .lightbox--product.is-zoomed .lightbox__stage { cursor: grab; }
  .lightbox--product.is-zoomed .lightbox__stage:active { cursor: grabbing; }

  .lightbox--product .lightbox__img {
    max-inline-size: min(92vw, 1200px);
    max-block-size: 86vh;
    object-fit: contain;
    border-radius: 0;
    transform-origin: center center;
    transition: transform var(--dur-base) var(--ease);
  }
  .lightbox--product.is-zoomed .lightbox__img {
    cursor: inherit;
    transition: none;
  }

  /* ---- Toast (§8.8) ----------------------------------------------------- */

  /* Stays a NORMAL element rather than a dialog, so it can appear over the page
     without stealing focus (§8.8). */
  .toast {
    position: fixed;
    inset-block-end: var(--s-10);
    inset-inline: 0;
    z-index: var(--z-toast);
    inline-size: fit-content;
    margin-inline: auto;
    padding: var(--s-3) var(--s-6);
    background-color: var(--ink-900);
    color: var(--bg);
    border-radius: var(--r-sm);
    font-size: var(--fs-small);
  }

  /* ==========================================================================
     BREAKPOINT SWITCHES — the §8.13 table
     ======================================================================= */

  @media (min-width: 480px) {
    /* Spec-box label→value goes side-by-side. The proximity rule still holds
       (§8.4) — the column gap here is larger than the 2px stacked gap, and the
       row padding is what separates rows. */
    .spec-boxes__row {
      grid-template-columns: minmax(0, 10rem) minmax(0, 1fr);
      gap: var(--s-4);
      align-items: baseline;
    }

    /* THE TRACK'S VARIABLE, NEVER THE ITEM'S WIDTH — see the note at
       `.rail__item`. Setting inline-size directly here is what silently broke
       every per-rail override above this breakpoint. */
    .rail__track { --rail-item: 46%; }
    .rail--projects .rail__track { --rail-item: 30%; }
  }

  @media (min-width: 768px) {
    .product-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }

    /* Restores "Sort by" as visible text once the toolbar has room for it
       (see the mobile clip rule above). */
    .catalogue__sort label {
      position: static;
      inline-size: auto;
      block-size: auto;
      overflow: visible;
      clip-path: none;
      white-space: normal;
    }

    /* Three columns is the default from here, so it has to be reachable, and 1-up
       drops out because the phone widths it exists for are behind us. The divider
       it carried goes with it, or the first visible button draws a rule against
       the group's own frame. */
    .catalogue__density-btn--1 { display: none; }
    .catalogue__density-btn--3 { display: grid; }
    .catalogue__density-btn + .catalogue__density-btn--2 { border-inline-start: 0; }

    /* Gallery + specs, two columns (§8.13).
       THE GALLERY TAKES THE LARGER COLUMN. An even 1fr/1fr split sized the
       product image by the width the spec text happened to need — and on this
       page the photograph is the thing being judged, while the specs beside it
       are 12px rows that read fine narrower. 1.15fr keeps the summary above
       the 45ch where its values start wrapping mid-value. */

    .rail__track { --rail-item: 34%; }
    .rail--projects .rail__track { --rail-item: 20%; }

    .category-cards--compact .category-cards__grid {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }

    /* ---- The category hero's card moves from the photo's block-end to its
       inline-start edge, same mechanics as the destination hero (main.css,
       §6.3/§6.4) --------------------------------------------------------
       The card stays position: absolute (set at the base width already) —
       only WHERE it anchors changes. inset-inline-start: 0 runs the card
       flush to the true viewport edge (not .container's left edge) — a
       landscape photo has the width to spare for a side column, and flush
       reads more deliberate than a floating gutter here. The card is
       auto-sized to its content and centred on the photo's block axis with
       inset-block-start: 50% + transform: translateY(-50%) — NOT the more
       familiar inset-block: 0 + margin-block: auto trick, which stretches
       rather than centres here: this card is display: flex, and an abspos
       flex container with BOTH opposing insets set resolves its auto block
       size to the stretch-fit size (fills the gap first, leaving nothing for
       the auto margins to split), not the shrink-to-fit size a plain block
       gets. Only ONE inset (inset-block-start) being non-auto avoids that
       path, so the card stays content height and the transform does the
       centring instead. inline-size is wider than the base width's
       full-bleed band, so padding-inline goes up to match rather than
       staying at padding-block's --s-5 — a wider card with the same padding
       on every side would read as slack, not generous; padding-block gets
       its own smaller bump (+15px over --s-5) for the same reason, just
       less of one.

       ::before draws a second, offset border just inside the card — inset
       from the top/right/bottom edges but flush with the card's own
       inline-start (0, matching the card) and with no inline-start border
       segment of its own. Left open on the one edge that's already flush to
       the true viewport edge, it reads as a frame that keeps going past the
       wall rather than one that closes around the card. Inset is --s-4
       minus 2px, tighter than the card's own padding so it doesn't crowd
       wider padding-inline/padding-block. --brass, not --accent: a reddish
       note here is decorative, and §8.2's accent-scarcity rule reserves
       --accent for the one primary-action element per viewport — never
       borders/chrome. --brass exists specifically for this (hairline/icon
       detail, never text or CTAs). */
    .category-hero__media--empty {
      border-block: 0;
    }

    .category-hero__copy {
      inset-inline-start: 0;
      inset-inline-end: auto;
      inset-block-start: 50%;
      inset-block-end: auto;
      transform: translateY(-50%);
      inline-size: clamp(360px, 52%, 560px);
      padding-inline: var(--s-9);
      padding-block: calc(var(--s-5) + 15px);
    }

    .category-hero__copy::before {
      content: "";
      position: absolute;
      inset-block: calc(var(--s-4) - 2px);
      inset-inline: 0 calc(var(--s-4) - 2px);
      border: 1px solid var(--brass);
      border-inline-start: 0;
      pointer-events: none;
    }

    .category-hero__title { max-inline-size: 18ch; }
  }

  @media (min-width: 1024px) {
    .product-grid {
      grid-template-columns: repeat(4, minmax(0, 1fr));
      gap: var(--s-5) var(--s-4);
    }

    /* Desktop offers 3 and 4 only. 2-up is the tablet/phone preference; here it
       leaves two enormous cards in a row that was meant to read as a catalogue.
       The divider 2 carried at 768px goes with it, or the first visible button
       draws a rule against the group's own frame. */
    .catalogue__density-btn--2 { display: none; }
    .catalogue__density-btn--4 { display: grid; }
    .catalogue__density-btn + .catalogue__density-btn--3 { border-inline-start: 0; }

    .rail__track { gap: var(--grid-gutter-lg); padding-inline: var(--gutter-x-lg); --rail-item: 26%; }

    /* FOUR AT A TIME, ADVANCING ONE.
       23% puts four cards fully in view and leaves the fifth partly showing,
       which is what tells a visitor there is more to the right — a rail whose
       last card lands flush at the container edge reads as a finished row
       (§9.11). Matching the 4-column grid it draws from means a card is the
       same size in both places, so the rail reads as the same catalogue rather
       than a smaller version of it.

       The arrows move ONE CARD, not one screenful: catalogue.js takes its step
       from the measured width of `.rail__item` plus the track's own computed
       gap, so this percentage and the scroll distance can never disagree. Four
       per click would jump the whole visible set and lose the visitor's place
       in it. */
    .rail--picks .rail__track { --rail-item: 23%; }

    /* SMALLER THAN THE PICKS RAIL, deliberately: this rail is proof, not a
       destination to browse product-by-product, so the tiles stay compact and
       the reel keeps moving (autoplay is on for this rail — archive-product.php)
       rather than asking a visitor to study one photo at a time. */
    .rail--projects .rail__track { --rail-item: 14%; }
    .rail__controls { gap: var(--s-2); }
    .rail__arrow:not(.rail__pause) { display: grid; }

    /* THREE, NOT FOUR, AND THE COUNT IS WHAT SETS IT: the shop root draws the
       five top-level categories, and five cards in a four-column row strand one
       alone on a second row - which reads as a category that failed to load
       rather than as the fifth of five. 3 + 2 reads as an arrangement. */
    .category-cards__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--grid-gutter); }

    .category-cards--compact .category-cards__grid {
      grid-template-columns: repeat(6, minmax(0, 1fr));
    }

    .product-card__closeup { inline-size: 64px; block-size: 64px; }

    /* The sticky CTA bar is hidden at --bp-lg+, where the floating CTA takes
       over — the two never render together (§8.8, §9.8). */
    .product-cta-bar { display: none; }

    /* Side drawer, not full-screen (§8.13). */
    .filter-drawer__panel {
      margin-inline-start: auto;
      inline-size: min(420px, 90%);
    }

    /* Three product columns plus the sticky label, no horizontal scroll. */
  }
  .product-grid {
    grid-template-columns: minmax(0, 1fr);
  }

  /* Archive FAQ is a standalone page section, outside the product-grid
     container. The outer section owns the vertical rhythm and divider; the
     inner .container only aligns content horizontally. Reset the generic FAQ
     topic spacing inside it so the section spacing is applied exactly once. */
  .catalogue-faqs {
    padding-block: var(--section-y-compact);
    border-block-start: 1px solid var(--line);
  }

  .catalogue-faqs .faqs {
    margin-block: 0;
    padding-block-start: 0;
    border-block-start: 0;
  }

  /* On phones the product count was consuming the spare space in the first
     toolbar row, forcing the density selector onto a row by itself. Keep the
     controls together and let the informational count take the second row. */
  .catalogue__density {
    margin-inline-start: auto;
  }

  .catalogue__count {
    order: 2;
    flex-basis: 100%;
    margin-inline-start: 0;
  }

  /* The mobile filter is a full-width dialog, so none of its descendants may
     retain an intrinsic minimum wider than the viewport. Number inputs are the
     main offender: inside a two-column flex row their UA min-content width can
     force the whole drawer sideways. Let every layout layer shrink, size the
     price fields to their tracks, and wrap long facet labels instead of
     creating a horizontal scroll axis. */
  .filter-drawer,
  .filter-drawer__panel,
  .filter-drawer__head,
  .filter-drawer__body,
  .filter-drawer__foot,
  .facet,
  .facet__options,
  .facet__option,
  .facet__range,
  .facet__range-field {
    min-inline-size: 0;
    max-inline-size: 100%;
  }

  .filter-drawer__body {
    overflow-x: hidden;
  }

  .facet__range-field input {
    inline-size: 100%;
    min-inline-size: 0;
    max-inline-size: 100%;
  }

  .facet__option-label {
    min-inline-size: 0;
    overflow-wrap: anywhere;
  }

  .filter-drawer__foot .btn {
    min-inline-size: 0;
  }

  @media (min-width: 768px) {
    .product-grid {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }

    .catalogue__density {
      margin-inline-start: 0;
    }

    .catalogue__count {
      order: 0;
      flex-basis: auto;
      margin-inline-start: auto;
    }
  }

  @media (min-width: 1024px) {
    .product-grid {
      grid-template-columns: repeat(4, minmax(0, 1fr));
    }
  }
}
