/* ==========================================================================
   EC Carpets Rugs — GLOBAL SHELL  ·  v9b ("Gallery" → Revised)
   ==========================================================================
   Base reset + typography + global chrome shared by EVERY page: floating-glass
   utility strip, floating-glass main header, mega menu (desktop + mobile),
   Woodland Sage footer, and the recurring shared components / product tile.

   Ported from the v9b mockup css/shell.css, with the mockup's css/theme.css
   override layer FOLDED IN at the foot of this file (per docs/DESIGN-SYNC:
   theme.css is not shipped as a separate patch layer). The folded block is the
   v9b "REVISED" layer — serif h2s, Woodland Sage buttons, the Woodland Sage
   footer slab, the glass-over-hero header, the floating mega sheet and the
   black header ink — and it must stay at the BOTTOM so it wins the cascade,
   exactly as theme.css loads last in the mockup.

   Depends on pb-tokens.css (loaded first). Selectors/class names are kept
   identical to the mockup so a mockup diff maps 1:1 onto a theme diff.

   BASELINE SYNC (2026-07-25): chrome + shared components synced to v9b. The
   per-section block sheets (blocks/pb_ec_{slug}/style.css) still reflect v4b and
   are resynced to v9b in a later per-section pass — a few of the folded rules
   below ([C1b]/[C1c]) name those v4b section selectors and will be revisited then.

   DO NOT write a path glob in a comment here. Spelling the block folder with a
   star followed by a slash puts a comment TERMINATOR inside the prose: the comment
   ends there, the remaining prose is parsed as a selector, and the next real rule's
   braces are consumed as that garbage rule's block — so the rule is dropped with no
   error anywhere. That is exactly what happened to the reset below (see line 28),
   and it cost the whole site its border-box. `dev/lint-css-comments.mjs` now checks
   for it; use `{slug}` in prose.
   ========================================================================== */

/* ==========================================================================
   0. RESET & BASE
   ========================================================================== */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  scroll-behavior: smooth;
}

body {
  font-family: var(--font-body);
  font-weight: var(--w-regular);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  letter-spacing: var(--ls-body);
  color: var(--text);
  background: var(--surface);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  min-height: 100vh;
}

/* v9b (brief R3): the utility strip + header are FIXED floating glass bars OUT
   of the normal flow. Round 1 reserved their footprint with a body padding-top,
   but that pushed the FIRST section (banner/hero imagery included) DOWN below the
   bars — so the chrome read as displacing the page rather than floating over it,
   and heroes started under a white gap instead of bleeding up behind the glass
   (gethommey.com.au behaviour: chrome overlays the hero, content is not moved).
   FIX: body top padding is 0 on EVERY page; each page's FIRST section instead
   carries the clearance INTERNALLY via .page-top (padding-top: --chrome-bottom),
   so a banner section's background/imagery bleeds up under the bars while its
   text content clears them. Content-first pages (breadcrumb, plain section) put
   .page-top on that first element; the HOME hero already pads itself (home.css).
   Nothing here reserves flow space, so no rule displaces the banner. */
body { padding-top: 0; }
/* First-section clearance: the ONE hook every page's first flow element carries
   so its content starts below the floating header's foot. On a bleed banner the
   imagery is absolutely positioned (inset:0), so this pad only moves the OVERLAID
   copy down — the photo still fills up under the glass. --chrome-bottom = inset +
   strip + gap + header; one more --chrome-inset seats the content off the rounded
   foot symmetrically. Home opts out (its hero owns the pad). */
.page-top { padding-top: calc(var(--chrome-bottom) + var(--chrome-inset)); }
/* A bleed banner wants ONLY its overlaid copy cleared, not extra dead height, so
   pages may use .page-top--bleed on a hero to add the clearance to the existing
   top padding rather than on top of a content gap (page CSS composes as needed). */

img, picture, svg, video { display: block; max-width: 100%; }
svg { fill: currentColor; }

h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-display);
  font-weight: var(--w-light);   /* headings lead with light weight, not bold */
  line-height: var(--lh-heading);
  letter-spacing: var(--ls-heading);
  color: var(--text-strong);
  text-wrap: balance;
}

a { color: inherit; text-decoration: none; }
p a { color: var(--text-accent); text-decoration: underline; text-underline-offset: 0.15em; text-decoration-thickness: 1px; }
p a:hover { color: var(--accent-hover); }

button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
ul, ol { list-style: none; padding: 0; }

/* Global focus treatment — visible on every interactive element (plan §7).
   focus-visible keeps it keyboard-only; a strong offset ring reads on both
   light surfaces and imagery.
   DO NOT give this rule a border-radius. :focus-visible is a pseudo-class, so the
   selector is only (0,1,0) — it TIES every single-class rule that rounds an
   element, and block sheets all print BEFORE this file, so the tie goes to the
   radius set here. A 2px radius squared off the pill filter chips, the circular
   rail arrows and the 28px edit cards the instant they took keyboard focus, with
   the ring following the new shape. Keyboard-only, so visual QA never saw it. The
   outline already traces whatever radius the element carries; it needs none. */
:focus { outline: none; }
:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}
/* On dark surfaces, flip the ring to the accent for contrast. (v9b: the folded
   [C2] block relaxes all three of these back to the dark ring, because the chrome
   and the flattened bands are LIGHT now; the accent ring survives only on the
   mega promo tiles, which really are dark photography.) */
.on-inverse :focus-visible,
.u-strip :focus-visible,
.site-footer :focus-visible { outline-color: var(--focus-ring-inverse); }

/* v9b: the selection fill follows the accent to Woodland Sage, but the ink does
   NOT stay slate-deep — slate-deep on sage is 4.0:1, under AA for the body copy
   a user is selecting (it was 4.58:1 on gold, which only just cleared).
   Near-black on sage is 5.76:1, so selected text reads better than it did. */
::selection { background: var(--woodland-sage); color: var(--near-black); }

/* ==========================================================================
   1. UTILITIES & SHARED PRIMITIVES
   ========================================================================== */

/* Screen-reader-only (labels, live-region mirrors). */
.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0);
  white-space: nowrap; border: 0;
}

/* Skip link — first focusable element on every page. */
.skip-link {
  position: absolute;
  top: 0; left: var(--space-sm);
  transform: translateY(-120%);
  z-index: var(--z-toast);
  background: var(--slate-deep);
  color: var(--white);
  padding: var(--space-2xs) var(--space-md);
  border-radius: 0 0 var(--radius-sm) var(--radius-sm);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-caps);
  transition: transform var(--dur-base) var(--ease-out);
}
.skip-link:focus-visible { transform: translateY(0); }

/* Container. */
.container { width: 100%; max-width: var(--container-max); margin-inline: auto; padding-inline: var(--gutter); }
.container--wide { max-width: var(--container-wide); }
.container--measure { max-width: var(--measure); }

/* Eyebrow — the signature tracked micro-label (echoes "EST · 1963"). */
.eyebrow {
  display: inline-block;
  font-size: var(--fs-eyebrow);
  font-weight: var(--w-medium);
  letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase;
  color: var(--text-muted);
  line-height: 1;
}

/* Caps utility — render this element's text in capitals. Applied per-element in
   the markup (the "The Designers Edit" links and headings this round); it carries
   no colour, size or weight of its own, so it composes onto whatever it lands on.

   Tracking: NONE, deliberately (user 2026-08-14). An earlier round set this to
   --ls-caps (0.12em) to match the tracking the set gives its other uppercase runs.
   That value is calibrated to small uppercase LINKS and labels — .btn,
   .link-underline, .header-textlink, .nav-toggle__label, .product-tile__flag,
   .trio__badge, the utility strip — which all set it in their own rules and are
   untouched by this change. On the display-scale targets this utility also lands
   on (.edit__title, .easy-card__title, .edit-hero__text h1) 0.12em is 3-6px of gap
   per letter and the words come apart. Anything that wants tracked capitals should
   use .eyebrow (--ls-eyebrow) or carry its own value.

   `normal`, NOT a deleted declaration — they are different edits. Five of this
   utility's own targets set negative, MIXED-CASE tracking that capitals would
   otherwise inherit; measured in the browser:
     .mega__link           -0.015em  (--ls-heading, this file, later section)
     .mega__promo-label    -0.015em  (--ls-heading, this file, later section)
     .edit__title          -0.010em  (block sheet, loads after this one)
     .easy-card__title     -0.015em  (block sheet, loads after this one)
     .edit-hero__text h1   -0.012em  (block sheet, 0,1,1)
   Negative tracking suits mixed-case display type and closes capitals up, which
   have no ascender/descender rhythm to carry it. `normal` gives every target the
   same untracked capitals instead of five subtly different tight ones.

   Cascade: the class stays DOUBLED (0,2,0) for exactly that reason — it has to
   beat all five, while staying beatable by any 0,2,1 or 0,3,0 rule so a block can
   still tune a specific instance on purpose. Doubling keeps this as ONE rule in
   one file; the alternative (restating it in the folded override layer at the foot
   of this file) would create a second pair to be kept in lockstep. */
.u-caps.u-caps {
  text-transform: uppercase;
  letter-spacing: normal;
}

/* Hairline rule — the boxes-into-space primitive (plan §2.3). */
.hairline { border: none; border-top: var(--rule); width: 100%; }
.hairline--strong { border-top: var(--rule-strong); }

/* --- Buttons: quiet, architectural; hierarchy via fill vs line, not chrome.
   [NOTE N1] Client note (V2 pasteboard): "Button to be inversed when hovered
   over / Button to only have outline". So the primary CTA is OUTLINE by default
   and INVERSES (fills) on hover AND focus-visible — one button, two states (the
   V2 wireframe's stacked duplicate CTAs are those two states, not two buttons).
   To keep the two button roles distinct now that primary is outlined, .btn--ghost
   drops its own outline and reads as an underline-style text action (subordinate,
   never a second boxed button beside the CTA). Both states clear 44px, keep the
   focus-visible ring from the global rule above, and hold AA contrast; the fill
   transition uses --dur-base, which reduced-motion zeroes globally (tokens.css). */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2xs);
  min-height: 50px;               /* v4: a calmer, slightly taller button        */
  padding: 0 var(--space-xl);     /* v4: more horizontal room around the label   */
  font-size: var(--fs-sm);
  font-weight: var(--w-medium);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  border-radius: var(--radius-btn);  /* v4: softly rounded (10px), echoing media  */
  transition: background-color var(--dur-base) var(--ease-standard),
              color var(--dur-base) var(--ease-standard),
              border-color var(--dur-base) var(--ease-standard);
}
/* Primary CTA [NOTE N1] — OUTLINE by default, INVERSES to a solid fill on hover
   AND focus-visible. On light surfaces the resting outline is drawn in the dark
   anchor ink (slate-deep = 8.2:1 on white, AA for both text and the 1px line); a
   sage line at rest would be too faint, so the accent identity lives in the
   INVERSE — the button floods Woodland Sage on interaction (folded [K] below).
   The base rule here keeps the v4 gold fill for parity with shell.css; [K] at the
   foot repoints the hover/focus fill to Woodland Sage (loads last, wins). */
.btn--primary {
  background: transparent;
  color: var(--accent-contrast);
  border: var(--hairline-width) solid var(--accent-contrast);
}
.btn--primary:hover,
.btn--primary:focus-visible {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
}
/* Inverse primary — same outline→fill inversion tuned for the dark anchor.
   [re-ink] Bands render LIGHT (surface-inverse → white in v9b), so the resting
   inverse primary is re-inked to slate-deep line/ink for AA on the light ground
   (a resting gold line failed AA at 2.5:1). The hover FILL is repointed to
   Woodland Sage in the folded [K] block below. */
.on-inverse .btn--primary {
  color: var(--accent-contrast);          /* slate-deep ink (8.6:1 on white)    */
  border-color: var(--accent-contrast);
  background: transparent;
}
.on-inverse .btn--primary:hover,
.on-inverse .btn--primary:focus-visible {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-contrast);
}
/* Secondary/ghost — now the quiet, subordinate action: no boxed outline (that
   role belongs to the primary CTA at rest), just uppercase text with an animated
   underline that fills in on hover/focus. Keeps a clear step down from the CTA
   and never renders as a paired second button beside it. */
.btn--ghost {
  padding-left: 0;
  padding-right: 0;
  color: var(--text);
  position: relative;
}
.btn--ghost::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 10px;
  height: var(--hairline-width);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-base) var(--ease-out);
}
.btn--ghost:hover::after,
.btn--ghost:focus-visible::after { transform: scaleX(1); }
/* Inverse ghost — same underline treatment on the dark anchor. */
.on-inverse .btn--ghost { color: var(--text-on-inverse); }
/* Text link with animated underline — for "Explore →" style CTAs. */
.link-underline {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2xs);
  min-height: var(--tap-min);
  font-size: var(--fs-sm);
  font-weight: var(--w-medium);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text);
  position: relative;
}
.link-underline::after {
  content: "";
  position: absolute;
  left: 0; bottom: 8px;
  width: 100%; height: 1px;
  background: currentColor;
  transform: scaleX(1);
  transform-origin: left;
  transition: transform var(--dur-base) var(--ease-out);
}
.link-underline:hover { color: var(--text-accent); }
/* THE `.arrow` HOVER PAIR IS DELETED, NOT PARKED (2026-09-06). v9b dropped the
   chevron from every link on all ten of its pages but left these two rules
   behind in shell.css:308-309, and this theme copied the dead rules AND kept
   emitting the markup they used to match. That combination is what let the
   chevron survive here for months: the CSS looked like evidence the design
   wanted one. Keeping them for parity was the alternative and was rejected, on
   the grounds that a rule matching nothing is what caused the drift, and that
   this theme already made the same call once, in
   blocks/pb_ec_entry_tiles/style.css, when v9b dropped the FAQ tile's arrow.
   The inline-flex and gap above are what would seat a cue if one ever returns;
   dev/qa-inline-arrow.mjs is what stops one returning by accident. */

/* --- Icon buttons (header cluster, menu back/close, info triggers). Always a
   full 44px target with a centred glyph. */
.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-min);
  height: var(--tap-min);
  border-radius: var(--radius-sm);
  color: inherit;
  transition: background-color var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard);
}
.icon-btn:hover { background: rgba(84,96,103,0.08); }
.on-inverse .icon-btn:hover, .mega .icon-btn:hover { background: rgba(231,233,231,0.12); }
.icon-btn svg { width: 22px; height: 22px; }

/* --- PLACEHOLDER FIELD — tasteful stand-in for lifestyle photography.
   A flat/gradient tone + a discreet centred caption naming the intended shot.
   NEVER a grey box with an X (plan brief, planning/05). */
.ph {
  position: relative;
  width: 100%;
  background: var(--ph-tone-1);
  overflow: hidden;
  border-radius: var(--radius-xs);
  display: grid;
  place-items: center;
  isolation: isolate;
}
.ph--tone1 { background: var(--ph-tone-1); }
.ph--tone2 { background: var(--ph-tone-2); }
.ph--tone3 { background: var(--ph-tone-3); }
.ph--dark  { background: var(--ph-tone-dark); }
.ph--dark .ph__cap { color: var(--text-on-inverse-mut); }
/* faint corner tick so it reads as an intentional reserved slot, not an error */
.ph::after {
  content: "";
  position: absolute;
  inset: var(--space-sm);
  border: 1px solid rgba(255,255,255,0.28);
  border-radius: var(--radius-xs);
  pointer-events: none;
}
.ph--dark::after { border-color: rgba(255,255,255,0.12); }
/* Every delivered slot carries an <img>, so suppress the empty-slot tick +
   caption when a real image is present (v4 [F] finish note). */
.ph:has(> img)::after { display: none; }
.ph:has(> img) .ph__cap { display: none; }
.ph__cap {
  position: relative;
  z-index: 1;
  max-width: 80%;
  text-align: center;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--ph-caption);
  opacity: 0.72;
  line-height: var(--lh-snug);
  padding: var(--space-2xs);
}

/* ==========================================================================
   2. UTILITY STRIP  (plan §4.1)
   EXACTLY three centred messages. No icons, phone, locator, or login.
   v9b (brief A11 + A10): the strip is a FLOATING GLASS BAR — fixed, inset from
   the viewport edges, rounded, translucent with a backdrop blur — sitting OVER
   the page/hero. It's a SEPARATE element from the header (a --chrome-gap sits
   between them). (brief A10): it now shows ONE message at a time, rotated by
   nav.js on ALL screen sizes (v4 showed all three inline on desktop). The three
   messages stack in a single cell; the active one slides+fades in.
   ========================================================================== */
.u-strip {
  position: fixed;
  top: var(--chrome-inset);
  left: var(--chrome-inset);
  right: var(--chrome-inset);
  z-index: var(--z-header);
  height: var(--ustrip-h);
  background: color-mix(in srgb, var(--white) 68%, transparent);
  -webkit-backdrop-filter: saturate(1.1) blur(14px);
  backdrop-filter: saturate(1.1) blur(14px);
  border: 1px solid rgba(255,255,255,0.55);
  border-radius: var(--chrome-radius);
  box-shadow: var(--shadow-glass);
  color: #000;                      /* v9b (brief S3a): strip text is black       */
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  overflow: hidden;                 /* clip the rotating messages to the bar     */
  /* v9b (brief R4): the strip hides once the page is scrolled past ~140px — it
     slides UP off the top and fades, and the header animates up to take its place.
     Transition transform+opacity here so both directions are smooth; nav.js toggles
     .chrome-condensed on <html>. transform/opacity only (reduced-motion zeroes the
     duration tokens globally). */
  transform: translateY(0);
  transition: transform var(--dur-slow) var(--ease-standard),
              opacity var(--dur-slow) var(--ease-standard);
  will-change: transform, opacity;
}
/* Condensed: the strip lifts fully off the top edge (its own height + the inset)
   and fades out; it also stops catching pointer events so the hero beneath stays
   interactive. */
.chrome-condensed .u-strip {
  transform: translateY(calc(-1 * (var(--ustrip-h) + var(--chrome-inset) + 4px)));
  opacity: 0;
  pointer-events: none;
}
.u-strip__inner {
  height: 100%;
  display: grid;                    /* stack all messages in ONE cell to rotate  */
  place-items: center;
  text-align: center;
}
/* Each message occupies the same grid cell; only the active one is shown. The
   inactive ones sit translated down + transparent, ready to slide up in. */
.u-strip__msg {
  grid-area: 1 / 1;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(60%);
  /* v9b (brief R1 → S3b): the vertical scroll is SLOWER + smoother still — the
     transition goes from 900ms to 1350ms on the same soft ease, while nav.js keeps
     the ~2.3s dwell (R1), so each message drifts in gently and more messages still
     pass in a short window. Reduced-motion drops the transform (fade/swap only) and
     tokens.css zeroes the duration globally.
     GHOST FIX (defect #2a): every INACTIVE message also goes visibility:hidden so it
     can never paint at a readable opacity beside the active one (opacity:0 alone left
     both messages compositing over the backdrop-filter). visibility rides the SAME
     duration but with 0ms delay when SHOWING and the full duration as delay when
     HIDING (see the active/leaving rules), so it flips to visible instantly on entry
     and only drops to hidden AFTER the fade-out completes — the message never blinks. */
  visibility: hidden;
  transition: opacity 1350ms var(--ease-out),
              transform 1350ms var(--ease-out),
              visibility 0s linear 1350ms;
  pointer-events: none;
}
.u-strip[data-js="on"] .u-strip__msg[data-active="true"] {
  opacity: 1;
  transform: translateY(0);
  visibility: visible;
  transition: opacity 1350ms var(--ease-out),
              transform 1350ms var(--ease-out),
              visibility 0s linear 0s;
  pointer-events: auto;
}
/* An outgoing message slides UP and fades as the next comes in, then is taken fully
   hidden once the fade finishes (visibility delayed by the transition duration). */
.u-strip[data-js="on"] .u-strip__msg[data-leaving="true"] {
  opacity: 0;
  transform: translateY(-60%);
  visibility: hidden;
  transition: opacity 1350ms var(--ease-out),
              transform 1350ms var(--ease-out),
              visibility 0s linear 1350ms;
}
/* JS-off fallback: no rotation — show the first message statically (and make it
   visible, since the base rule now hides every message via visibility). */
.u-strip:not([data-js="on"]) .u-strip__msg:first-child { opacity: 1; transform: none; visibility: visible; }
/* The v4 inline dot separators are gone (one-at-a-time now); suppress them. */
.u-strip__msg + .u-strip__msg::before { content: none; }
@media (max-width: 640px) {
  :root { --ustrip-h: 36px; }       /* shorter strip on mobile (geometry token)   */
}
/* Reduced motion: keep the one-at-a-time swap but drop the slide (opacity only,
   and nav.js lengthens the interval / can hold the first item). */
@media (prefers-reduced-motion: reduce) {
  .u-strip__msg { transform: none !important; }
}

/* ==========================================================================
   3. MAIN HEADER  (plan §4.2)
   Deliberately minimal: hamburger + centred logo lockup + a tiny icon cluster.
   Primary nav collapses behind the hamburger EVEN ON DESKTOP — no inline nav.
   v9b (brief A11): the header is the SECOND floating glass bar — fixed, inset,
   sitting a --chrome-gap below the utility strip, rounded + translucent with a
   backdrop blur. It floats OVER the page/hero (content starts underneath). The
   sticky-in-flow v4 header is gone; body/hero offsets reserve the space. nav.js
   keeps --mega-top tracking the header's live bottom so the mega panel still
   meets its edge.
   ========================================================================== */
.site-header {
  position: fixed;
  top: var(--header-top);
  left: var(--chrome-inset);
  right: var(--chrome-inset);
  z-index: var(--z-header);
  height: var(--header-h);
  background: color-mix(in srgb, var(--white) 72%, transparent);
  -webkit-backdrop-filter: saturate(1.1) blur(16px);
  backdrop-filter: saturate(1.1) blur(16px);
  border: 1px solid rgba(255,255,255,0.55);
  border-radius: var(--chrome-radius);
  box-shadow: var(--shadow-glass);
  /* v9b (brief R4): when the strip hides on scroll, the header glides UP from its
     resting offset (below the strip) to the top inset, taking the strip's place. The
     `top` animates; a smooth curve on both directions.
     v9b (brief U1): internal pages now also swap the header ground on scroll — the
     over-hero glass at the top solidifies to the base 72% once condensed (nav.js
     toggles .site-header--over). Transition background/border here too so that swap
     crossfades smoothly. */
  transition: top var(--dur-slow) var(--ease-standard),
              background-color var(--dur-base) var(--ease-standard),
              border-color var(--dur-base) var(--ease-standard);
}
/* Condensed: header rises to sit at the top inset (where the strip used to be). */
.chrome-condensed .site-header { top: var(--chrome-inset); }
.site-header__inner {
  display: grid;
  grid-template-columns: 1fr auto 1fr;  /* hamburger | logo | icons */
  align-items: center;
  gap: var(--space-sm);
  height: 100%;
  /* v9b (brief S4): no max-width. The markup composes .site-header__inner with
     .container (which caps at --container-max); inside the floating glass bar the
     header cluster should span the bar's full inset width instead, so the logo/
     icons sit at the bar edges. This later single-class rule overrides .container's
     max-width; the .container padding-inline (page gutter) is kept. */
  max-width: none;
}
/* Box-sizing re-assert on the two glass bars — now REDUNDANT, kept as a guard.
   HISTORY, corrected 2026-08-09: this was added because the universal reset at
   line 28 was observably not governing here, and that was attributed to "a later
   WC/core cascade" overriding it. That diagnosis was wrong. Nothing overrode it:
   the glob in the file's opening comment closed that comment early and the parser
   swallowed the reset, so it never reached the CSSOM on ANY page. With the comment
   fixed the reset applies everywhere and this rule changes nothing. It stays only
   as a cheap guard against a plugin resetting box-sizing over the chrome; it is
   NOT a pattern new page areas need to copy. See docs/DESIGN-SYNC Conventions. */
.u-strip, .u-strip *, .u-strip *::before, .u-strip *::after,
.site-header, .site-header *, .site-header *::before, .site-header *::after { box-sizing: border-box; }
.site-header__lead { justify-self: start; display: flex; align-items: center; gap: var(--space-2xs); }
.site-header__logo { justify-self: center; }
.site-header__actions { justify-self: end; display: flex; align-items: center; gap: var(--space-3xs); }

/* Quiet "Carpets" site-switch text link, rightmost in the header cluster.
   Inherits header colour so it reads on both the bone header and the
   over-hero (inverse) header; kept a full 44px target for touch. */
.header-textlink {
  display: inline-flex; align-items: center;
  min-height: var(--tap-min);
  padding-inline: var(--space-2xs);
  color: inherit;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  opacity: 0.72;
  transition: opacity var(--dur-fast) var(--ease-standard);
}
.header-textlink:hover, .header-textlink:focus-visible { opacity: 1; }
@media (max-width: 560px) { .header-textlink { display: none; } }

/* Hamburger — icon + optional "Menu" caps label on wider screens. */
.nav-toggle { display: inline-flex; align-items: center; gap: var(--space-2xs); min-height: var(--tap-min); padding-inline: var(--space-2xs); border-radius: var(--radius-sm); }
.nav-toggle:hover { background: rgba(84,96,103,0.08); }
.nav-toggle__label { font-size: var(--fs-xs); letter-spacing: var(--ls-caps); text-transform: uppercase; }
@media (max-width: 560px) { .nav-toggle__label { display: none; } }
.nav-toggle__bars { display: block; width: 22px; height: 12px; position: relative; }
.nav-toggle__bars span { position: absolute; left: 0; width: 100%; height: 1.5px; background: currentColor; transition: transform var(--dur-base) var(--ease-standard), opacity var(--dur-fast); }
.nav-toggle__bars span:nth-child(1) { top: 0; }
.nav-toggle__bars span:nth-child(2) { top: 5px; }
.nav-toggle__bars span:nth-child(3) { top: 10px; }

/* Logo lockup — uses the mono brand mark; height-constrained, width auto. */
.brand-logo { display: inline-flex; align-items: center; }
.brand-logo img { height: 30px; width: auto; }
@media (max-width: 560px) { .brand-logo img { height: 24px; } }

/* --- v9b HEADER BRANDING SWAP ON SCROLL (brief A12) ----------------------
   Default (top of page): the LOGO ONLY, a bit bigger, WITHOUT the old
   "| elevate comfort" lockup. On scroll (nav.js adds .site-header--scrolled),
   the logo crossfades/slides OUT and the words "elevate comfort" crossfade IN.
   Scrolling back to the top restores the logo. No layout jump: the logo marks
   and the wordmark share ONE grid cell, so the header centre never reflows —
   only opacity/transform animate.
   The markup ships the logo as an <img> (the HOME header carries two: a dark
   .brand-logo__mark + a reversed .brand-logo__mark--rev that home.css toggles;
   the SUBPAGE headers carry a single un-classed <img>), plus a
   .brand-logo__tagline span with "elevate comfort". So target the logo <img>
   GENERICALLY (works for both markups) and the tagline span. The old
   .brand-logo__sep rule is dropped (no "| elevate comfort" lockup now). */
.site-header__logo {
  display: grid;
  place-items: center;
  align-items: center;
}
/* The logo image(s) and the wordmark stack in the SAME cell so the swap never
   reflows the header centre. Generic img selector catches both markups.
   GHOST FIX (defect #2b): the wordmark also toggles visibility so the roundel never
   ghosts behind "elevate comfort" (and vice-versa). At the TOP the wordmark is
   hidden; only the logo is visible. When scrolled the two swap in lockstep with the
   opacity fade — visibility flips to visible instantly on the incoming layer and to
   hidden only after the outgoing layer has finished fading (0s delay in / duration
   delay out), so exactly one layer is ever readable. The logo <img> keeps painting
   at the top (it IS the resting brand), so only the tagline carries the base hidden
   state; the scrolled rules below hand visibility across. */
.site-header__logo img,
.site-header__logo .brand-logo__tagline {
  grid-area: 1 / 1;
  transition: opacity var(--dur-base) var(--ease-out),
              transform var(--dur-base) var(--ease-out),
              visibility 0s linear var(--dur-base);
}
/* At rest the logo is visible; the wordmark is fully hidden (not just transparent). */
.site-header__logo img { visibility: visible; transition-delay: 0s; }
/* Logo bigger than v4's 30px (brief A12: "a bit bigger"). Applies to whichever
   logo <img> is showing. */
.site-header__logo img { height: 38px; }
@media (max-width: 560px) { .site-header__logo img { height: 30px; } }
/* Kill the old vertical rule entirely — the top state is logo-only now. */
.site-header__logo .brand-logo__sep { display: none; }
/* The wordmark: "elevate comfort", revealed on scroll (hidden + faded down at the
   top). v9b (brief R2): it must NOT be IvyPresto italic. Match the v4b HEADER
   TAGLINE treatment exactly — the plain Gordita lockup: body face, Light weight,
   the same header ink (full currentColor) as MENU / CARPETS, hairline tracking. */
.site-header__logo .brand-logo__tagline {
  font-family: var(--font-body);
  font-weight: var(--w-light);
  font-style: normal;
  font-size: 1.5rem;         /* round 4: was 1.0625rem/17px (v4b parity) */
  line-height: 1;
  letter-spacing: 0.005em;
  color: currentColor;       /* same ink as MENU / CARPETS, at full strength */
  white-space: nowrap;
  opacity: 0;
  transform: translateY(40%);
  visibility: hidden;        /* hidden at rest so it can't ghost behind the logo */
  pointer-events: none;
}
@media (max-width: 560px) {
  .site-header__logo .brand-logo__tagline { font-size: 0.95rem; }
}
/* SCROLLED STATE — swap logo → wordmark. Generic img selector so it fades out
   whichever logo <img> is showing (home's classed marks + subpages' plain img).
   The outgoing logo drops to visibility:hidden AFTER its fade (delayed), the
   incoming wordmark becomes visible immediately — exactly one layer is readable. */
.site-header--scrolled .site-header__logo img {
  opacity: 0;
  transform: translateY(-40%);
  visibility: hidden;
  transition-delay: 0s, 0s, var(--dur-base);
  pointer-events: none;
}
.site-header--scrolled .brand-logo__tagline {
  opacity: 1;
  transform: translateY(0);
  visibility: visible;
  transition: opacity var(--dur-base) var(--ease-out),
              transform var(--dur-base) var(--ease-out),
              visibility 0s linear 0s;
  pointer-events: auto;
}
/* Reduced motion: crossfade only, no slide (the swap still reads). */
@media (prefers-reduced-motion: reduce) {
  .site-header__logo img,
  .site-header__logo .brand-logo__tagline { transform: none !important; }
}

/* --- v9b LOGO MARK SELECTION (ported verbatim from the mockup's home.css) -----
   Defect #1 root cause: the HOME header ships BOTH marks — the dark
   .brand-logo__mark and the reversed white .brand-logo__mark--rev — stacked in the
   same grid cell. Only home.css decided WHICH shows; that toggle was never ported,
   so the pale reversed mark painted on top at full opacity (washed-out roundel).
   The mockup keeps the DARK mono mark over the hero and in the resting/over states,
   and only ever swaps to the reversed mark for a dark-banner variant it never ships.
   Port those exact rules so the home dual-mark always resolves to the DARK mark;
   subpages now render a single dark <img> (header.php) so this only governs home. */
.home .site-header__logo .brand-logo__mark { display: block; }
.home .site-header__logo .brand-logo__mark--rev { display: none; }
.home .site-header--over .brand-logo__mark { display: block; }
.home .site-header--over .brand-logo__mark--rev { display: none; }

/* Cart icon count bubble. The ink here is a fallback only — the folded [S6] block
   pins every header numeral to white. */
.cart-count {
  position: absolute;
  top: 4px; right: 2px;
  min-width: 16px; height: 16px;
  padding: 0 4px;
  display: grid; place-items: center;
  font-size: 10px; font-weight: var(--w-medium);
  line-height: 1;
  /* Round 55: Dark Slate ground, white numerals, 9.04:1. Was sage with
     near-black ink, which disappeared whenever the floating header sat over
     pale or beige imagery: the badge was a light chip on a light photograph and
     the count stopped registering. A dark chip reads against anything.
     The [S6] restatement at the foot of this file had to move with it, or the
     change is defeated — it previously forced #000 numerals. */
  color: var(--white);
  background: var(--slate-deep);
  border-radius: var(--radius-pill);
}
.icon-btn { position: relative; }

/* ==========================================================================
   4. MEGA MENU  (V2 artboards: pg2 desktop · pg5 mobile)
   A FULL-WIDTH panel that OPENS DOWN from under the header when the hamburger
   is tapped — not a side drawer. Light surface with dark ink. Desktop: link
   columns on the left, two tall image-led promo tiles on the right. Mobile:
   full-screen, promo tiles below the link groups, every group expanded. ONE
   flat markup tree. nav.js drives open/close, focus trap, ESC, scrim, close,
   inert, scroll-lock and reduced-motion; it sets --mega-top to the header's
   live bottom so the panel always meets the header edge.
   ========================================================================== */

/* Scrim behind the panel (covers the page below the header). */
.mega-scrim {
  position: fixed; inset: 0;
  z-index: var(--z-scrim);
  background: var(--scrim);
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--dur-base) var(--ease-standard), visibility var(--dur-base);
}
.mega.is-open ~ .mega-scrim,
.mega-scrim.is-open { opacity: 1; visibility: visible; }

/* The panel — anchored to the header's bottom edge, spanning the full width,
   dropping DOWN over the page. (Floating-sheet geometry folded from theme.css
   [N] at the foot of this file: it floats inset + rounded, 10px clear of the
   header foot.) */
.mega {
  position: fixed;
  top: var(--mega-top, 108px);
  left: 0; right: 0;
  z-index: var(--z-menu);
  max-height: calc(100dvh - var(--mega-top, 108px));
  background: var(--surface);
  color: var(--text);
  border-bottom: var(--rule);
  box-shadow: var(--shadow-panel);
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Reveal: fade + a short drop from just under the header. */
  transform: translateY(-10px);
  opacity: 0;
  visibility: hidden;
  transition: transform var(--dur-base) var(--ease-out),
              opacity var(--dur-base) var(--ease-out),
              visibility var(--dur-base);
}
.mega.is-open { transform: translateY(0); opacity: 1; visibility: visible; }

.mega__inner {
  position: relative;
  padding-block: var(--space-xl) var(--space-2xl);  /* deeper top+foot */
}

/* No close control in the panel: the menu closes via the toggle re-click, the
   scrim, or Esc. (The theme's mega-menu.php still ships a .mega__close button as
   a belt-and-braces affordance; nav.js wires it if present.) Focus lands on the
   first mega link on open (nav.js) and returns to the toggle on close. */

/* --- Desktop layout: two shopping-link columns + two promo tiles. --- */
.mega__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  grid-template-areas: "shop promos";
  column-gap: var(--space-2xl);
  align-items: start;
}
.mega__nav {
  grid-area: shop;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: var(--space-2xl);
}
.mega__col { display: flex; flex-direction: column; gap: var(--space-2xl); }

/* --- Two-level hierarchy: a quiet tracked label leads a set of confident links. */
.mega__group-label {
  font-size: var(--fs-eyebrow);
  font-weight: var(--w-medium);
  letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase;
  color: var(--text-muted, var(--slate));
  margin-bottom: var(--space-sm);
}
.mega__links { display: flex; flex-direction: column; }
.mega__link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-sm);
  min-height: var(--tap-min);
  padding-block: var(--space-2xs);
  font-family: var(--font-display);
  font-weight: var(--w-light);
  font-size: var(--fs-h4);
  line-height: var(--lh-snug);
  color: var(--text);
  letter-spacing: var(--ls-heading);
  white-space: nowrap;
  transition: color var(--dur-fast) var(--ease-standard);
}
.mega__link:hover { color: var(--text-accent); }  /* deep bronze — AA on the panel */
.mega__links--lead { margin-bottom: var(--space-lg); }

/* --- Promo tiles (How to Choose a Rug · Designers Edit). Image-led, text
   overlaid — NOT a bordered card. Two tall equal tiles. --- */
.mega__promos {
  grid-area: promos;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-md);
}
.mega__promo {
  position: relative;
  display: flex;
  align-items: flex-end;
  min-height: 440px;
  padding: var(--space-lg);
  border-radius: var(--radius-media);  /* v4: soft rounded promo frame (18px) */
  overflow: hidden;
  isolation: isolate;
}
.mega__promo .ph { position: absolute; inset: 0; z-index: -2; border-radius: 0; }
.mega__promo .ph::after { inset: 0; border: 0; }
/* Real-photography drop-in for the promo tiles. The theme ships NO global
   `.ph > img` rule (section isolation: pb_ec_hero, pb_ec_cta_band and the other
   block sheets each carry their own copy), and the mega menu is the one
   img-in-a-.ph site that lives in GLOBAL CHROME rather than in a block, so it
   had no owner and so no rule at all. Measured before: object-fit computed
   `fill`, position `static`, the 2000x450 banner laid out 280x63 inside a
   280x440 tile, filling 14% of its height and leaving the rest as bare tone.
   The v9b mockup gets this from the unscoped `.ph > img` preamble at the head of
   every page sheet, which reaches into its shell.css precisely because it is
   unscoped; this theme's convention is the opposite, so it is scoped to the
   tiles here. .ph keeps its own z-index of -2 so the photo stays under the
   legibility scrim, and the per-tile object-position stays inline on the img. */
.mega__promo .ph > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.mega__promo::before {   /* legibility scrim over the image */
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: linear-gradient(to top, rgba(35,41,44,0.7), rgba(35,41,44,0.05));
}
.mega__promo-label {
  font-family: var(--font-display);
  font-weight: var(--w-medium);
  font-size: 1.4375rem;            /* 23px — two lines max on the 1440 2-up tile */
  color: var(--white);
  letter-spacing: var(--ls-heading);
  line-height: var(--lh-snug);
  text-wrap: balance;
}
/* Cramped desktop band (901–1365px): the promo rail drops to a single column so
   "How to Choose a Rug" stays on two lines. */
@media (max-width: 1365px) and (min-width: 901px) {
  .mega__promos { grid-template-columns: 1fr; }
  .mega__promo { min-height: 200px; }
}

/* ---------- MOBILE / NARROW (<=900px) ----------
   Full-screen menu: the two-column link grid leads, promos moved to the BOTTOM.
   CSS-only reorder — the markup keeps desktop source order. */
@media (max-width: 900px) {
  .mega { max-height: calc(100dvh - var(--mega-top, 96px)); }
  .mega__grid {
    grid-template-columns: 1fr;
    grid-template-areas:
      "shop"
      "promos";
    row-gap: var(--space-2xl);
  }
  .mega__promos { grid-template-columns: 1fr; gap: var(--space-lg); }
  .mega__promo { min-height: 168px; padding: var(--space-lg); }

  .mega__nav {
    grid-template-columns: 1fr 1fr;
    column-gap: var(--space-lg);
    row-gap: var(--space-2xl);
  }
  .mega__col { gap: var(--space-2xl); }
}
@media (max-width: 480px) {
  .mega__inner { padding-block: var(--space-lg) var(--space-xl); }
  .mega__nav { grid-template-columns: 1fr; row-gap: var(--space-xl); }
}

/* ==========================================================================
   5. FOOTER  (plan §4.5)
   Newsletter + three link columns (SHOP / GUIDES / COMPANY) + legal bar.
   Base geometry here; the v9b Woodland Sage ground + re-inks are folded from
   theme.css [L] at the foot of this file (they load last and win).
   ========================================================================== */
.site-footer {
  background: var(--surface-inverse);
  color: var(--text-on-inverse);
  border-top: var(--rule);
  margin-top: var(--section-gap);
  padding-top: var(--section-gap-s);
}
.site-footer a { color: var(--text-on-inverse-mut); transition: color var(--dur-fast); min-height: var(--tap-min); display: inline-flex; align-items: center; }
.site-footer a:hover { color: var(--text-accent); }

/* Upper footer: brand/newsletter block on the left, three link columns right. */
.footer-top {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
  gap: var(--space-xl) var(--space-3xl);
  align-items: start;
  padding-bottom: var(--section-gap-s);
  border-bottom: var(--rule-inverse);
}

/* Left block: logo → social → newsletter, stacked. */
.footer-brand { display: flex; flex-direction: column; gap: var(--space-md); align-items: start; }
.footer-brand__logo { display: inline-flex; }
.footer-brand__logo img { display: block; height: 44px; width: auto; }
.footer-social { display: flex; gap: var(--space-2xs); }
.footer-social a { width: var(--tap-min); height: var(--tap-min); justify-content: center; border: var(--rule-inverse); border-radius: var(--radius-circle); }
.footer-social a:hover { border-color: var(--woodland-sage-deep); }
.footer-social svg { width: 18px; height: 18px; }

/* Newsletter opt-in — sits DIRECTLY on the footer's main ground (no panel). */
.footer-news {
  display: flex; flex-direction: column; gap: var(--space-sm);
  max-width: 42ch;
}
.footer-news__copy { color: var(--text-on-inverse); font-size: var(--fs-body); }
.news-field {
  display: flex;
  align-items: end;
  gap: var(--space-sm);
  flex-wrap: wrap;
  max-width: 100%;
}
.news-field input {
  flex: 1 1 auto; min-width: 0; min-height: var(--tap-min);
  background: none; color: var(--white);
  border: none; border-bottom: 1px solid var(--text-on-inverse-mut);
  font: inherit; font-size: var(--fs-body);
  padding: var(--space-2xs) 0;
}
.news-field input::placeholder { color: var(--text-on-inverse-mut); }
.news-field input:focus-visible { outline: none; }
.news-field input:focus { border-bottom-color: var(--woodland-sage-deep); }
.news-field .btn { flex: 0 0 auto; align-self: end; }

/* Link columns. */
.footer-cols {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-lg) var(--space-xl);
}
.footer-col h3 {
  font-family: var(--font-body);
  font-size: var(--fs-eyebrow);
  font-weight: var(--w-medium);
  letter-spacing: var(--ls-eyebrow);
  text-transform: uppercase;
  color: var(--text-on-inverse-mut);
  margin-bottom: var(--space-sm);
}
.footer-col ul { display: flex; flex-direction: column; }
.footer-col a { font-size: var(--fs-body); }

/* Legal bar — single row; Carpets is pushed to the far right by the spacer. */
.footer-legal {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm) var(--space-md);
  padding-block: var(--space-md);
  border-top: var(--rule-inverse);
  font-size: var(--fs-sm);
  color: var(--text-on-inverse-mut);
}
.footer-legal a { font-size: var(--fs-sm); }
.footer-legal .spacer { flex: 1 1 auto; }

@media (max-width: 860px) {
  .footer-top { grid-template-columns: minmax(0, 1fr); gap: var(--space-xl); }
  .footer-cols { gap: var(--space-lg); }
}
@media (max-width: 480px) {
  .footer-cols { grid-template-columns: 1fr 1fr; }
  .footer-legal { flex-direction: column; align-items: flex-start; }
  .footer-legal .spacer { display: none; }
}

/* ==========================================================================
   6. RECURRING BANDS & PRODUCT TILE  (plan §4.6)
   Shared building blocks composed by many pages.
   ========================================================================== */

/* --- Section scaffolding: eyebrow + heading + optional CTA on one line. --- */
.section { padding-block: var(--section-gap); }
.section--tight { padding-block: var(--section-gap-s); }
.section--inverse { background: var(--surface-inverse); color: var(--text-on-inverse); }
.section--recessed { background: var(--surface-recessed); }
.section--inverse h1, .section--inverse h2, .section--inverse h3 { color: var(--white); }

.section-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-md);
  margin-bottom: var(--space-xl);
}
.section-head__text { display: flex; flex-direction: column; gap: var(--space-xs); }
.section-head__text h2 { font-size: var(--fs-h2); }
.section-head__text p { color: var(--text-muted); max-width: 52ch; }
.section--inverse .section-head__text p { color: var(--text-on-inverse-mut); }
@media (max-width: 640px) {
  .section-head { flex-direction: column; align-items: flex-start; margin-bottom: var(--space-lg); }
}

/* --- PRODUCT TILE — the atomic browse unit (plan §4.6). --- */
.product-tile { display: flex; flex-direction: column; gap: var(--space-sm); }
.product-tile__media {
  position: relative;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: var(--surface-recessed);
}
.product-tile__media .ph { position: absolute; inset: 0; border-radius: 0; }
.product-tile__media img { width: 100%; height: 100%; object-fit: cover; }
.product-tile__flag {
  position: absolute;
  top: var(--space-2xs); left: var(--space-2xs);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--slate-deep);
  background: color-mix(in srgb, var(--bone) 86%, transparent);
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  padding: 2px var(--space-2xs);
  border-radius: var(--radius-xs);
}
.product-tile__body { display: flex; flex-direction: column; gap: 2px; }
.product-tile__name {
  font-family: var(--font-display);
  font-weight: var(--w-regular);
  font-size: var(--fs-h4);
  color: var(--text-strong);
  line-height: var(--lh-snug);
  letter-spacing: var(--ls-heading);
}
.product-tile__name a::after { content: ""; position: absolute; inset: 0; }
.product-tile { position: relative; }
.product-tile__price { font-size: var(--fs-sm); color: var(--text-muted); }
.product-tile__price .from { text-transform: uppercase; letter-spacing: var(--ls-caps); font-size: var(--fs-xs); margin-right: 4px; }
.product-tile__price .was { text-decoration: line-through; opacity: 0.6; margin-left: var(--space-2xs); }
.product-tile__price .now { color: var(--brick); }

/* ==========================================================================
   RANGE CARD: the shop grid's tile, and the curated grid's tile, and there is
   only one of it.
   ==========================================================================
   Rendered by block-parts/pb_ec_range_card.php, which both pb_ec_range_grid and
   pb_ec_featured_grid call and neither of which renders card markup of its own
   (registry scope 13.1). The CSS is HERE, beside .product-tile, for the same
   reason: two block sheets cannot each own half a component, and the first CSS
   change is exactly where a copied card diverges. This is a shared browse
   primitive like .product-tile and .product-grid above it, not a section, so
   section isolation (docs/DESIGN-SYNC rule 3) does not reach it.

   Ported from v9b css/shop.css sections 5 and 6. Four deliberate departures,
   each recorded in block-parts/pb_ec_range_card_sync.md:

     1. THE TWO ANCHORS ARE BOTH SERVER-RENDERED, and only that is a departure.
        The card carries v9b's shape: the photograph is a link
        (.range-card__medialink) and "View Rug" is a link. v9b BUILDS its media
        anchor in js/shop.js because its href holds a colourway parsed out of an
        image filename; this one is authored in the markup with a real permalink,
        so it works with no script at all, and the grid script only re-points it
        when a dot is pinned.
        CORRECTED 2026-09-05. This sheet previously made the WHOLE CARD one link
        and deleted .range-card__medialink to do it, citing a one-destination
        ruling. That ruling is SHOP-1, "no route where clicking a range tile
        takes the customer to a single product page": one DESTINATION, not one
        link. Sam has never had the whole card as a link, and these grids need a
        row of buttons (view, add, order samples) that an inset overlay would
        swallow. See block-parts/pb_ec_range_card.php.
     2. THE SWATCH DOT IS AN ANCHOR that a script later swaps for a button, so
        the reset covers both element types. It needs no stacking context of its
        own: with the whole-card overlay gone, nothing is painted over the dots.
        See the note at section 6.
     3. THE DOT'S GROUND IS THE PHOTOGRAPHED SWATCH, with the hex behind it,
        because wp_ec_colourway.swatch_hex is empty on every row in this build.
     4. --popular has no rule. v9b authors two flag treatments by hand; the
        badge is an authored select over more than two states (registry 15.2)
        and none of it is built, so a second modifier nothing can emit would be
        a dead rule.

   The card is width-driven, not viewport-driven: it is narrowest at the bottom
   of a 2-up band rather than at the smallest screen, so a media query would
   break at the wrong moment.
   ========================================================================== */
/* NO `position: relative`, AND ITS ABSENCE IS THE FIX OF 2026-09-05 rather than
   an oversight. The card carried one solely so a whole-card `::after { inset: 0 }`
   could resolve against it; that overlay is gone (the note standing where the rule
   was is just above .btn--primary.range-card__view). Nothing else positions
   against the card: every
   absolutely positioned box in this component sits inside .range-card__media,
   which carries its own. `container-type: inline-size` applies layout
   containment, so the card is STILL a containing block for anything that later
   needs one, and re-adding `position: relative` would buy nothing. */
.range-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  container-type: inline-size;
  container-name: range-card;
}
/* A square well: every source photograph here is square, and a 4/5 crop would
   clip the flat-lays. Matches the Home rail's --peek tiles rather than the
   shell's own 4/5 .product-tile__media. */
.range-card__media {
  position: relative;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: var(--surface-recessed);
}
.range-card__media .ph { position: absolute; inset: 0; border-radius: 0; }
/* The theme ships NO global `.ph > img` cover rule by design; every owner
   carries its own scoped copy (pb_ec_entry_tiles, pb_ec_category_grid, the UGC
   rail in pb_ec_product_rail, and thirteen others). Without one the shell's
   `.ph { display: grid; place-items: center }` centres the photograph at its
   intrinsic size and clips it instead of covering the well. Scoped to the child
   selector on purpose: .range-card__preview is a SIBLING of the .ph, not a
   descendant, and it positions itself below. */
.range-card__media .ph > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* Ranges still awaiting photography keep an EMPTY .ph, so the shell's reserved
   slot treatment (tone gradient, corner tick, caption) does the work it was
   designed for. Nothing is borrowed from another range's photograph. */
.range-card__media .ph__cap { max-width: 60%; }

/* --- THE CARD'S ONE OVERLAY LAYER. A second image stacked over the resting
   photograph rather than a src swap on it: the resting photograph keeps its own
   alt (a src swap would leave the alt describing something no longer there),
   reverting is lossless, and two layers is what makes a crossfade possible at
   all. It serves BOTH swaps, and one layer is the point rather than a shortcut
   since a single element cannot show two photographs at once, so precedence is
   settled by construction instead of by z-index luck.
   The well is a fixed 1/1 box, so nothing here can move layout. --dur-base is
   0ms under prefers-reduced-motion (pb-tokens.css), which turns the crossfade
   into a plain swap on its own; no media query is needed. --- */
.range-card__preview {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-base) var(--ease-standard);
}
.range-card__preview.is-shown { opacity: 1; }

/* --- THE SWATCH VARIANT, ported from v9b css/order-samples.css §4 and §4a.
   One extra class on the <article>, on the Order Samples page only, and it turns
   the square photograph in the well into a RUG LYING ON A FLOOR. The failure it
   fixes: a square crop centred in a square well reads as a framed picture, and
   the page is asking the visitor to imagine wool underfoot.

   IT LIVES HERE, not in the new page's block sheet, for the reason the partial's
   own header gives: .range-card is a shared render-time helper and two block
   sheets cannot each own half a component. The cost is 33 declarations loading on
   every page behind a class that appears on one, which is the accepted trade and
   is already what .range-card__flag--new and --popular do above.

   THE MECHANISM IS THE BOX, NOT A TRANSFORM, and that is a resampling argument.
   The panel is laid out at its final size and the <img> is object-fit: cover'd
   into it once by .range-card__media .ph > img, so a 900px source resamples
   straight to its rendered width in ONE pass. transform: scale() on the image
   rasters at the well's size and then squeezes that, which is two passes and a
   soft result. Animating the panel's width or inset instead would animate LAYOUT,
   on every card in the grid. The only transform left is the 8% flourish below,
   and it ends underneath an opaque photograph. --- */
.range-card--swatch .range-card__media {
  /* THE FLOOR. --sand is the palette's designated recessed-well surface and it is
     warm, so it reads as floor beside wool rather than as a grey UI panel. The
     base rule above paints --surface-recessed, which resolves to --bone-deep
     #EDEBE6, and that is also what .section--recessed paints; the recessed How It
     Works band sits directly above this grid, so a whole grid of wells in that
     same tone would soften a join the page wants crisp. */
  background: var(--sand);
  /* Inset rather than a border, so the well keeps its exact 1/1 box and the
     grid's rhythm is untouched. */
  box-shadow: inset 0 0 0 var(--hairline-width) var(--hairline);

  /* Declared on the WELL, not the panel, so the rug and its edge read as one set
     of numbers. --rug-edge-w is in cqi, 1% of .range-card's inline size, which is
     already an inline-size container above, so the band holds a constant
     proportion of the rug at every breakpoint instead of looking heavy in a
     narrow card and thin in a wide one. The clamp guards widths this grid does
     not currently produce and is not a working range. */
  --rug-w: 70%;
  --rug-edge-w: clamp(6px, 2.2cqi, 11px);
}

/* THE RUG PANEL. Out of the base rule's inset: 0 stretch and into an object.
   absolute + inset: 0 + a definite width + margin: auto is two-axis centring that
   costs no transform, which is what leaves transform free for the flourish below.
   29/20 is not a taste: it is this site's own default rug, the 2.0m x 2.9m the
   configurator opens on. Square corners for the same reason (the configurator's
   default corner style is square), softened by --radius-xs alone, which is also
   what restores the radius the base .range-card__media .ph zeroes. */
.range-card--swatch .range-card__media .ph {
  inset: 0;
  margin: auto;
  width: var(--rug-w);
  height: auto;
  aspect-ratio: 29 / 20;
  border-radius: var(--radius-xs);
  /* A rug has pile depth and does not sit flush with the floor. This does more of
     the "an object lying there" work than any line could, and it is what separates
     the panel from the ground on every colourway at once, which is why there is no
     outer hairline on the panel to go with the inner one on the well. */
  box-shadow: var(--shadow-pop);
  transition: transform var(--dur-base) var(--ease-standard);
}

/* THE EDGING. A band drawn INSIDE the panel's own box, over the outer margin of
   the pile, never added outside it. That is what makes it an edge OF the rug
   rather than a frame AROUND a photograph, and it is why the 29:20 box is the
   same box with the band as without it, so nothing here can move the grid. It
   cannot read as a stray border on the card either: there is floor outside it on
   all four sides, and a border with ground outside it is an edge.

   A WASH, NOT AN EDGE COLOUR. The range page fills its band with an opaque thread
   because there the visitor has chosen one rug and one edge for it. Here one
   value would have to sit under a whole grid of photographs, and the swatch chips
   are a designed ramp while the photography is not: pick a thread against the
   chips and it is tuned to something not on screen, pick it against today's files
   and it is tuned to STAND-IN photography. A wash is the pile darkened rather
   than a colour laid over it, so its step is a property of the wash and not of the
   file, and it holds on photography nobody has taken yet. rgba(35,41,44,...) is
   --near-black; an alpha wash is a treatment, not a colour outside the palette.

   THE SEAM carries the dark end, where the wash alone is thinnest: a hairline of
   light at the band's inner boundary, which is also what an overlocked edge looks
   like in life. An inset shadow paints on the padding box, i.e. precisely on the
   band/pile boundary, so one declaration does it. No stitch texture, deliberately.

   `display: block` IS LOAD-BEARING, DO NOT TIDY IT AWAY. Line 382 above carries
   `.ph:has(> img)::after { display: none }`, which exists to stop the placeholder
   corner tick bleeding over real photography and is right to. Reviving this
   pseudo-element for a different job therefore has to turn the box back on. It is
   NOT a specificity problem: this selector is (0,3,1) against that one's (0,1,2)
   and wins anyway. The rule simply never mentioned `display`, so the `none` stood
   and the band computed a perfect band on a box that was never generated, with
   nothing about it visible in the sheet.

   ON A RANGE STILL AWAITING PHOTOGRAPHY the .ph is empty, so this replaces the
   shell's corner tick rather than joining it. That is the right answer: an empty
   rug-shaped panel with an edge still reads as a rug. */
.range-card--swatch .range-card__media .ph::after {
  content: "";
  display: block;
  position: absolute;
  inset: 0;
  border: var(--rug-edge-w) solid rgba(35, 41, 44, 0.34);
  border-radius: inherit;
  box-shadow: inset 0 0 0 var(--hairline-width) rgba(255, 255, 255, 0.34);
  pointer-events: none;
  transition: opacity var(--dur-base) var(--ease-standard);
}

/* WHAT SWITCHES THE TWO STATES: the preview layer being raised, not a dot being
   hovered. Both would work almost all of the time and only one of them cannot
   drift. blocks/pb_ec_range_grid/script.js resolves hover, focus and the pin down
   to one URL and hands it to the function that adds .is-shown, which is the only
   thing in that file that does. Keying the panel off that class means it steps
   aside exactly when something is painted over it and returns exactly when
   nothing is, instead of re-deriving the answer from :hover and [aria-pressed]
   and hoping the two agree.

   KEYBOARD PARITY COMES WITH IT, and is why this is not a :hover rule: focusin on
   a dot sets the same state hover does, so tabbing to a colour zooms exactly as
   pointing at it does. A :hover selector would have needed a :focus twin kept in
   step by hand.

   NOTHING CAN BE STRANDED ZOOMED IN, because zoomed in and preview shown are now
   one condition and the script cannot produce a stranded preview: no handler
   paints, each flips a boolean and re-derives from scratch.

   A PINNED COLOURWAY STAYS ZOOMED IN. That is what the pin means, it is the only
   way to hold the fibre view without holding a pointer still, and the alternative
   is incoherent: the preview is full bleed and stays raised while pinned, so a
   card that un-zoomed would be painting a rug panel nobody can see.

   THE FLOURISH IS 8% AND NO MORE. It settles underneath an opaque photograph, so
   its resampling never reaches the screen at rest; anything larger would be a real
   magnification of an already rastered layer, i.e. the blur the panel geometry
   went out of its way to avoid. REDUCED MOTION NEEDS NO BRANCH: pb-tokens.css
   zeroes --dur-base under the query and both transitions are written in that
   token, so the two states become a plain swap. */
.range-card--swatch:has(.range-card__preview.is-shown) .range-card__media .ph {
  transform: scale(1.08);
}
.range-card--swatch:has(.range-card__preview.is-shown) .range-card__media .ph::after {
  opacity: 0;
}

/* --- The corner flag. Geometry, type and placement are .product-tile__flag's,
   which is what the other grids in this set use; restated rather than reused so
   a .product-tile__ name inside .range-card does not misreport which component
   owns the box.
   The one departure is the fill: the tile's flag is 86% bone behind a blur,
   this one is OPAQUE brand colour, because a translucent fill over photography
   makes the text's measured contrast depend on whichever pixels happen to sit
   under it, and this one has to read on every crop and every colourway swap.
   Near-black ink, not white: white measures 2.55:1 on Woodland Sage where
   near-black measures 5.77:1.
   z-index 2 keeps it above the overlay layer, so it stays legible and in place
   when a swatch swaps the photograph beneath it. pointer-events: none because it
   is a label, not a control, and .range-card__medialink is painted over it: a
   flag that took the click would leave a dead corner in the photograph's link.
   --- */
.range-card__flag {
  position: absolute;
  top: var(--space-2xs); left: var(--space-2xs);
  z-index: 2;
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  padding: 2px var(--space-2xs);
  border-radius: var(--radius-xs);
  pointer-events: none;
}
.range-card__flag--new { background: var(--woodland-sage); color: var(--near-black); }
/* The second treatment, which v9b/css/shop.css:941 already authored by hand and
   registry item 12 gave something able to emit: --clay is an official brand
   secondary that pb-tokens.css reserved for exactly this flag. Near-black ink
   again, for the same measured reason: white on clay is 2.39:1, near-black is
   6.17:1. Both modifiers are mapped in block-parts/pb_ec_range_card.php and a
   badge state with no rule here renders no flag at all, so this file and that
   one cannot fall out of step without the flag disappearing rather than losing
   its fill. */
.range-card__flag--popular { background: var(--clay); color: var(--near-black); }

/* --- THE PHOTOGRAPH IS A LINK. Ported from v9b css/shop.css §5a1, which is where
   the reasoning was worked out; the markup is block-parts/pb_ec_range_card.php
   and the ruling behind it is in that file's header.
   AN OVERLAY INSIDE THE WELL, NOT AN <a> WRAPPED AROUND THE <img>, for two
   reasons that are both about what a screen reader hears:
     · wrapping the photograph makes its long descriptive alt the link's
       accessible name, so a links list would read sentences about rattan trays
       and linen where it wants range names. The overlay leaves the <img> outside
       the link, alt intact, announced as an image, and takes its own name from an
       sr-only span;
     · the hit area is scoped to the MEDIA rather than the card, which is what
       keeps it off the swatch dots and off the action row. That is the whole
       reason this component cannot use .product-tile__name a::after: an inset
       overlay on the card would swallow every control in it.
   z-index 3 completes the well's stack, above the preview layer (1) and the
   corner flag (2), so the photograph stays clickable whatever is painted over it.
   The flag is pointer-events: none and only a label, so nothing is taken from it.
   THE RING GOES INSIDE THE WELL. The global :focus-visible offset is +2px and the
   well is overflow: hidden, which would clip it away to nothing; -4px draws it
   inside the frame instead. The radius is restated rather than inherited because
   the offset moves the ring off the border box. --- */
.range-card__medialink {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: block;
  border-radius: inherit;
}
.range-card__medialink:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -4px;
  border-radius: var(--radius-media);
}

/* --- Meta row: from-price, fibre, colour count. No middot separators: the
   parts are held apart by a real column gap, not a run of spaces, so they stay
   independent boxes and the row wraps to a clean second line on a narrow card
   instead of breaking mid-phrase. The row SPREADS the card's width so it reads
   as a caption spanning the photograph above it. 10px of air above and below,
   ON TOP of the card's own flex gap. --- */
.range-card__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  column-gap: var(--space-sm);
  row-gap: var(--space-3xs);
  margin-block: 10px;
  font-size: var(--fs-sm);
  line-height: var(--lh-snug);
  color: var(--text-muted);
}
.range-card__meta > span { white-space: nowrap; }
/* Below ~250px of card the three parts cannot hold one line; drop the spread
   and let them pack and wrap from the left, because space-between across two
   lines would strand a lone part against the right edge. */
@container range-card (max-width: 250px) {
  .range-card__meta { justify-content: flex-start; }
}
.range-card__price { color: var(--text); }
.range-card__price .from {
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
  font-size: var(--fs-xs);
  color: var(--text-muted);
  margin-right: 3px;
}

/* --- Name. The same face and scale as .product-tile__name, so a range reads
   identically here and in the Home rail. PLAIN TEXT, and now on its own merits:
   the card's two anchors are the photograph and the button, both named for the
   range, and a third anchor on the heading would be a third route to one
   destination inside one card for a links list to read out. (It was previously
   plain because the whole-card overlay covered it, which is no longer true of
   anything on this card.) --- */
.range-card__name {
  font-family: var(--font-display);
  font-weight: var(--w-regular);
  font-size: var(--fs-h4);
  color: var(--text-strong);
  line-height: var(--lh-snug);
  letter-spacing: var(--ls-heading);
}

/* --- The action row. v9b writes `1fr 1fr` for a fixed pair; this is
   `grid-auto-flow: column` over `grid-auto-columns: 1fr` so ONE action fills the
   row and TWO share it evenly. It now ships TWO — View Rug and Order Sample —
   and this rule did not have to move to take the second, which is what it was
   written this way for. A range with no swatch dots still renders one, because
   Order Sample is a question about a pinned colourway and that card has none. --- */
.range-card__actions {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: var(--gap);
  margin-top: 15px;
}
.range-card__actions .btn {
  min-height: var(--tap-min);
  padding-inline: var(--space-sm);
  font-size: var(--fs-xs);
  white-space: nowrap;
}
/* Below ~250px of card two labels cannot both hold their line; stack them full
   width rather than shrink the type further. Harmless with one action. */
@container range-card (max-width: 250px) {
  .range-card__actions { grid-auto-flow: row; }
}

/* --- NO OVERLAY HANGS OFF THIS LINK, and the empty space where one used to is
   deliberate. Until 2026-09-05 this rule read
   `.range-card__view::after { content: ""; position: absolute; inset: 0; }`,
   which made the whole card one hit area, and v9b's own image anchor was deleted
   from the sheet and the markup so that it could.
   WHY THAT WAS WRONG. It cited Sam's one-destination ruling. That ruling is
   SHOP-1, "there must be no route where clicking a range tile takes the customer
   to a single product page", which fixes where a tile may GO and says nothing
   about how many links may go there. Sam has never had the whole card as a link:
   the photograph is a link and a button to view the range is a link, and every
   variant of these grids needs a row of buttons (view, add, order samples) that
   an inset overlay would swallow. v9b could not use the whole-card pattern for
   exactly that reason and says so; that reason never stopped applying here.
   WHAT REPLACED IT. .range-card__medialink above, scoped to the media well,
   which is where v9b puts the big hit target. Two consequences went with the
   overlay and are recorded where they were: .range-card no longer needs
   `position: relative`, and .range-card__swatches no longer needs a stacking
   context to keep the dots clickable.
   DO NOT REINTRODUCE IT. A card that can only hold one destination was never the
   requirement, and this paragraph exists so the next reader does not re-derive
   the same misreading from the same ruling. --- */
/* --- The pair, as a FIRST and a SECOND choice.
   THE FILL FOLLOWS WHICHEVER ACTION LEADS, and it is keyed on .btn--primary
   INSIDE this row rather than on .range-card__view, which is a deliberate
   departure from v9b and the one place this component does not copy it.
   WHY. v9b's client round asked for the leading action to be solid so that it
   "plainly outranks" the other, and wrote that as `.btn--primary.range-card__view`
   in css/shop.css. On order-samples.html, where Order Sample leads, that selector
   cannot match: css/order-samples.css §5 restates only the SECONDARY treatment
   under a second class name and its own comment records the pair as "kept in step
   by hand". So the mockup's leading action is solid on one page and an outline on
   the other, from two sheets, which is a consequence of per-page CSS rather than
   a decision anyone took. Sam's ruling is the decision: on the Order Samples page
   Order Sample IS the primary action. The partial puts .btn--primary on whichever
   action leads and this paints that one, so the rule reads the same on both.
   The card is the only place .range-card__action exists, so nothing else on the
   site is reachable from here.
   Same recipe range.css gives .btn--primary.sticky-atc__btn rather than a second
   filled-primary treatment: if the house tone moves, the two move together. The
   whole sage ramp, so the button still has somewhere to go now that rest is
   already the old hover colour.
   White on --woodland-sage is 2.56:1, under the 4.5:1 AA asks of small text.
   That is the ACCEPTED SITE-WIDE CONDITION for every filled primary in this
   design: pb-shell's own [K] layer paints this pair on hover for every
   .btn--primary on the site, and the range page paints it at rest on both buy
   buttons. Recorded, not worked around. Matching the house decision matters more
   than this one grid quietly running a darker fill than every other CTA.
   Specificity: rest is (0,2,0), which beats .btn--primary (0,1,0) and meets no
   other rule; the interactive states are (0,3,0), which beats pb-shell's own
   .btn--primary:hover (0,2,0) whatever the sheet order, and :active is written
   after :hover so the two equal-weight rules resolve in the intended direction. */
.range-card__actions .btn--primary {
  background: var(--woodland-sage);
  border-color: var(--woodland-sage);
  color: var(--white);
}
.range-card__actions .btn--primary:hover,
.range-card__actions .btn--primary:focus-visible {
  background: var(--woodland-sage-deep);
  border-color: var(--woodland-sage-deep);
  color: var(--white);
}
.range-card__actions .btn--primary:active {
  background: var(--woodland-sage-ink);
  border-color: var(--woodland-sage-ink);
  color: var(--white);
}

/* The SECOND choice: the same box, a lighter line and warmer ink, ported from
   v9b css/shop.css §5c's .range-card__sample. ONE copy, matched by "the action
   that is not the primary one", because the two orders are a setting here and
   v9b's two page-scoped copies are exactly what its own comment warns about.
   --woodland-sage-deep as the boundary clears 3:1 on white (1.4.11); --text-muted
   as the label clears 7:1 (1.4.3). On hover it takes the chip's sage wash so the
   whole card shares one interaction colour.
   IT STILL HAS TO READ AS AN ACTION, not as a disabled button, now that a solid
   sage primary sits beside it. Nothing about it is greyed: it is drawn in the
   same sage family as the primary's fill rather than in a neutral, so the pair
   reads as two live actions with a weight step between them.
   A bare .btn in this theme carries no box at all (no background, no border), so
   without this rule the second action would be an unboxed uppercase label beside
   a solid one, which is not a weight step. */
.range-card__action:not(.btn--primary) {
  background: transparent;
  color: var(--text-muted);
  border: var(--hairline-width) solid var(--woodland-sage-deep);
}
.range-card__action:not(.btn--primary):hover,
.range-card__action:not(.btn--primary):focus-visible {
  background: color-mix(in srgb, var(--woodland-sage) 16%, transparent);
  color: var(--text-strong);
  border-color: var(--woodland-sage-deep);
}

/* --- "Please choose your desired colour."
   Ported from v9b css/shop.css §10a, and the SENTENCE is v9b's too: Order Sample
   pressed with nothing pinned cannot add anything, because the card offers up to
   seven colourways and a sample is ONE of them, so the card says which step is
   missing rather than silently doing nothing or firing a browser alert().
   BUILT BY blocks/pb_ec_range_grid/script.js, never by the partial, and inserted
   ABOVE the swatch row for a reading reason: the script moves focus to the first
   colour dot, so the message wants to be the thing read just before it and not
   the thing stranded after it. Below the row it would also sit between the
   colours and the buttons, which is the one place on this card nothing belongs.
   THE SAME ELEMENT ALSO CARRIES A FAILED ADD, which is why the script's hook is
   [data-card-alert] and not v9b's [data-colour-prompt] — the card has one thing
   to say at a time and two live regions would announce one event twice. The
   CLASS keeps v9b's name because the treatment is v9b's.
   Treatment follows the page's governing rule (space + hairlines, never boxes):
   no fill, no radius, no icon — a 2px brick rule and the strong ink. --brick is
   the palette's alert colour. The ink is --text-strong on white (12.6:1), not
   brick on white (4.66:1), because the rule is doing the "this is an alert" work
   and the sentence only has to be easy to read. --- */
.range-card__prompt {
  margin: 0;                          /* the card body's flex gap owns the rhythm */
  padding-left: var(--space-2xs);
  border-left: 2px solid var(--brick);
  font-size: var(--fs-sm);
  line-height: var(--lh-snug);
  color: var(--text-strong);
}

/* ==========================================================================
   RANGE CARD, section 6: the colour swatches.
   ==========================================================================
   Always visible, in the card body between the name and the actions: a colour
   you can only reach by hovering is a colour most people never see, and on
   touch there is no hover to reach it with. At 34px the dot stops being a legend
   beside the count and starts being the offer itself, which is only fair once
   tapping one does something.

   NOTHING IS PAINTED OVER THE DOTS, so they need no lift. Until 2026-09-05 this
   rule carried `position: relative; z-index: 5` because the card's whole-card
   `::after { inset: 0 }` overlay covered every control in it and each one had to
   be raised back by hand. That overlay is gone (the note standing where it was
   is just above .btn--primary.range-card__view), and the card's one absolutely
   positioned anchor, .range-card__medialink, is scoped INSIDE
   .range-card__media, which is a SIBLING of this row and clips its own contents.
   So the lift went with the thing it defended against, rather than staying
   behind as a rule with no reason. The dot hit test in dev/qa-range-card.mjs
   still runs: the property worth guarding is "a click on a dot reaches the dot",
   not the z-index that used to deliver it. Measured both ways on 2026-09-05,
   putting the overlay back with no lift: the dot check goes red and reports the
   button under the click, so it is not passing on the absence of its subject.
   ========================================================================== */
.range-card__swatches {
  --swatch-size: 34px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2xs);
  margin: var(--space-2xs) 0 0;
  padding: 0;
  list-style: none;
}
/* Tune to the card, not the viewport, for the same reason as the action row.
   260px rather than 300px because a 34px dot only starts costing a second row
   well below where a 24px one did. */
@container range-card (max-width: 260px) {
  .range-card__swatches { --swatch-size: 30px; gap: var(--space-3xs) 6px; }
}
.range-card__swatches li { display: inline-flex; align-items: center; }
.range-card__swatches li[hidden] { display: none; }

/* --- The dot. It renders as an ANCHOR carrying the colourway's own URL and a
   script swaps it for a BUTTON at boot, so the reset has to cover both: an <a>
   arrives with the shell's link colour and underline, a <button> with a UA
   border, padding and font. Reset both and the swap changes nothing visible.

   TWO GROUNDS, IN ORDER. --sw-img is the photographed swatch, which every live
   colourway has; --sw is the hex behind it, which is wp_ec_colourway.swatch_hex
   and is empty on every row in this build (a DDL column, an admin input and a
   CSV column reaching nothing). So the final fallback is a recessed tone rather
   than one house colour, because a wall of identical sage dots would look like
   a working feature and is worse than an obviously unfilled one.

   The inset ring carries the pale end of the palette: cotton on white is 1.09:1
   on its own, so as a plain disc it would simply not be there. --- */
.swatch-dot {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  flex: 0 0 auto;
  width: var(--swatch-size);
  height: var(--swatch-size);
  padding: 0;
  border: 0;
  border-radius: var(--radius-circle);
  background-color: var(--sw, var(--surface-recessed));
  background-image: var(--sw-img, none);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  text-decoration: none;
  cursor: pointer;
  box-shadow: inset 0 0 0 1px rgba(35, 41, 44, 0.22);
  transition: box-shadow var(--dur-fast) var(--ease-standard),
              transform var(--dur-fast) var(--ease-standard);
}
/* Hover and keyboard focus do the same thing to the image, so they get the same
   cue: the disc lifts a fraction and takes a sage collar held off the fill by a
   white gap, which reads on a pale dot and a dark one alike. The ring is drawn
   with box-shadow rather than outline so it follows the circle. */
.swatch-dot:hover,
.swatch-dot:focus-visible {
  transform: scale(1.06);
  box-shadow: inset 0 0 0 1px rgba(35, 41, 44, 0.22),
              0 0 0 2px var(--surface),
              0 0 0 3px var(--woodland-sage-deep);
}
/* Pinned. Firmer and darker than the hover collar, because this one persists
   after the pointer has gone and has to be legible at rest. Set by the script
   that makes the dot a button; an anchor never carries it. */
.swatch-dot[aria-pressed="true"] {
  box-shadow: inset 0 0 0 1px rgba(35, 41, 44, 0.22),
              0 0 0 2px var(--surface),
              0 0 0 4px var(--text-strong);
}
.swatch-dot[aria-pressed="true"]:hover,
.swatch-dot[aria-pressed="true"]:focus-visible { transform: none; }
/* The overflow affordance. Reads as one more item in the row (same height, same
   pill family) but obviously not a colour: no fill, hairline only, muted micro
   type. Its visible glyph is aria-hidden; the sr-only sibling names the colours
   it stands for, so nothing is lost to a screen reader. */
.swatch-more {
  display: inline-grid;
  place-items: center;
  min-width: var(--swatch-size);
  height: var(--swatch-size);
  padding-inline: 5px;
  border-radius: var(--radius-pill);
  font-size: var(--fs-xs);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
  box-shadow: inset 0 0 0 1px var(--hairline);
}

/* Everything BELOW the card's image is inset 10px from the card's left and
   right edges, so the text column sits in from the photograph instead of running
   flush with it. .range-card__media is deliberately NOT inset: the imagery still
   bleeds to the card edge.
   THIS BLOCK MUST STAY AFTER the four component rules above. __swatches and
   __actions both set a `margin` shorthand with 0 sides, which at equal
   specificity would zero an inline margin declared earlier; source order is
   doing the work. Vertical margins stay where the rules above put them. */
.range-card__meta,
.range-card__name,
.range-card__swatches,
.range-card__actions,
.range-card__prompt {
  margin-inline: 10px;
}

/* --- Product grid (PLP + Recently Viewed) and carousel rail. --- */
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: var(--space-lg) var(--space-md);
}
.product-rail {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(240px, 1fr);
  gap: var(--space-md);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding-bottom: var(--space-2xs);
  scrollbar-width: thin;
}
.product-rail > * { scroll-snap-align: start; }
@media (min-width: 1024px) {
  .product-rail { grid-auto-columns: minmax(0, 1fr); overflow: visible; }
  .product-rail--4 { grid-template-columns: repeat(4, 1fr); grid-auto-flow: row; }
}

/* --- Guided 3-up decision block. --- */
.trio { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-lg); }
@media (max-width: 780px) { .trio { grid-template-columns: 1fr; gap: var(--space-md); } }
.trio__item { position: relative; display: flex; flex-direction: column; gap: var(--space-sm); }
.trio__media { position: relative; aspect-ratio: 3/4; overflow: hidden; border-radius: var(--radius-media); }
.trio__media .ph { position: absolute; inset: 0; border-radius: 0; }
.trio__badge {
  position: absolute; top: var(--space-2xs); left: var(--space-2xs);
  font-size: var(--fs-xs); letter-spacing: var(--ls-caps); text-transform: uppercase;
  /* v9b: accent fill follows gold → Woodland Sage. The ink goes NEAR-BLACK, not
     slate-deep: slate-deep on sage is 4.0:1 and this is fs-xs uppercase, so it
     would drop under AA (it was 4.58:1 on gold). Near-black on sage = 5.76:1.
     NOTE the Designers-Edit block overrides this same badge to WHITE on sage
     (brief X3) which measures 2.56:1 — flagged, not changed here, because that
     ink was an explicit instruction rather than a gold usage. */
  color: var(--near-black); background: var(--woodland-sage);
  padding: 2px var(--space-2xs); border-radius: var(--radius-xs);
}
.trio__title { font-size: var(--fs-h3); font-weight: var(--w-light); }
.trio__desc { color: var(--text-muted); font-size: var(--fs-sm); }
.section--inverse .trio__desc { color: var(--text-on-inverse-mut); }

/* --- FAQ band — recurring conversion/reassurance anchor near page feet. --- */
.faq-band { position: relative; padding-block: var(--section-gap); overflow: hidden; }
.faq-band__inner { display: grid; grid-template-columns: 1fr auto; gap: var(--space-lg); align-items: center; }
.faq-band h2 { font-size: var(--fs-h2); max-width: 20ch; }
.faq-band p { color: var(--text-muted); margin-top: var(--space-2xs); max-width: 46ch; }
@media (max-width: 640px) { .faq-band__inner { grid-template-columns: 1fr; } }

/* --- Two-up content + image (section kit primitive). --- */
.two-up { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2xl); align-items: center; }
.two-up--flip .two-up__media { order: -1; }
.two-up__media { position: relative; aspect-ratio: 4/3; border-radius: var(--radius-media-lg); overflow: hidden; }
.two-up__media .ph { position: absolute; inset: 0; border-radius: 0; }
.two-up__text { display: flex; flex-direction: column; gap: var(--space-sm); max-width: 52ch; }
.two-up__text h2 { font-size: var(--fs-h2); }
.two-up__text p { color: var(--text); }
.section--inverse .two-up__text p { color: var(--text-on-inverse); }
@media (max-width: 820px) {
  .two-up { grid-template-columns: 1fr; gap: var(--space-lg); }
  .two-up--flip .two-up__media { order: 0; }
}

/* --- Image band with overlaid text — the hero/section-break primitive. --- */
.image-band {
  position: relative;
  min-height: clamp(360px, 60vh, 640px);
  display: grid;
  align-items: end;
  overflow: hidden;
  isolation: isolate;
  width: min(100% - (2 * var(--gutter)), var(--container-wide));
  margin-inline: auto;
  border-radius: var(--radius-media-lg);
}
.image-band .ph { position: absolute; inset: 0; z-index: -2; border-radius: 0; }
.image-band .ph::after { inset: 0; border: 0; }
.image-band::before { content: ""; position: absolute; inset: 0; z-index: -1; background: linear-gradient(to top, rgba(35,41,44,0.6), rgba(35,41,44,0) 55%); }
.image-band__text { color: var(--white); padding-block: var(--space-2xl); max-width: 44ch; }
.image-band__text.container { padding-inline: clamp(var(--space-lg), 4vw, var(--space-2xl)); }
.image-band__text h2 { color: var(--white); font-size: var(--fs-h1); }
.image-band__text p { color: rgba(255,255,255,0.88); margin-top: var(--space-sm); }


/* ==========================================================================
   ▓▓▓ FOLDED FROM v9b theme.css — the "REVISED" override layer ▓▓▓
   ==========================================================================
   Kept at the FOOT of this file (loads last) so these rules win the cascade
   without editing the base sections above — exactly as css/theme.css loads
   after shell.css + the page sheets in the mockup. Class names verbatim. Only
   the mockup's asset-path swap-hacks are omitted (see [E]/[L] notes: v9b [L]
   restores the reversed footer logo in markup, so no background-image hack is
   needed here — footer.php ships EC_Mono_Rev.png directly).
   Grouped by the mockup's brief items.
   ========================================================================== */

/* --------------------------------------------------------------------------
   [C1b/C1c] Flattened-band re-inks that touch SECTION selectors.
   NOTE: these name v4b section-block selectors (.step__title, .step__num, etc.)
   that still live in blocks/pb_ec_{slug}/ pending the per-section v9b resync. They
   are carried over verbatim so the flattened bands keep AA on the light ground
   until those blocks are resynced; revisit in the per-section pass.
   -------------------------------------------------------------------------- */
.section--inverse h1,
.section--inverse h2,
.section--inverse h3 { color: var(--text-strong); }
.step__title { color: var(--text-strong); }
.edit-close__text h2 { color: var(--text-strong); }
.section--inverse .eyebrow,
.step__num,
.section--inverse .edit-step__index { color: var(--slate-deep); }
.section--inverse .eyebrow[style*="--woodland-sage"] { color: var(--slate-deep) !important; }
.swatch[aria-checked="false"] .swatch__name { color: var(--white); }
.section--inverse .section-head__text p,
.section--inverse .trio__desc,
.step__desc { color: var(--text-on-inverse-mut); }

/* [C2] Relax the on-inverse ACCENT focus ring back to the dark ring on the now-
   LIGHT chrome/bands (higher contrast on white). The accent ring survives only
   over dark imagery (mega promo tiles, home over-hero header). */
.on-inverse :focus-visible,
.u-strip :focus-visible,
.site-footer :focus-visible { outline-color: var(--focus-ring); }
.mega__promo :focus-visible { outline-color: var(--focus-ring-inverse); }

/* [J] IVYPRESTO DISPLAY — section h2s + banner headings (brief A3).
   The Typekit sheet is enqueued in functions.php; --font-serif resolves to
   "ivypresto-display". Scope: the h2 tier + the two banner headings ONLY
   (h1/h3/h4 stay Gordita). Body/nav/labels stay Gordita throughout. */
h2,
.section-head__text h2,
.image-band__text h2 {
  font-family: var(--font-serif);
  font-weight: 400;
  font-style: normal;
  letter-spacing: -0.01em;
  line-height: 1.12;
}
.section-head__text h2 {
  font-size: clamp(1.95rem, 1.4rem + 2.2vw, 2.9rem);
}
.hero__title,
.cta-band__title {
  font-family: var(--font-serif);
  font-weight: 400;
  font-style: normal;
  letter-spacing: -0.012em;
  line-height: 1.08;
}

/* [K] BUTTONS → WOODLAND SAGE (brief A7).
   Keep the exact two-state N1 behaviour but swap the accent from gold to
   Woodland Sage: the resting outline stays slate-deep; the INVERSE fill floods
   Woodland Sage #A3A393 → #8B8B7D on hover, with a WHITE label.

   SUPERSEDED (client review): A7 originally preserved non-button gold — eyebrows
   on dark imagery, the CTA hairline, focus rings over photos. Gold is now retired
   OUTRIGHT, so those live in pb-tokens.css on the Woodland Sage ramp and this
   block is no longer the only place sage appears. Nothing below changes; the note
   here about "non-button gold is untouched" would now be false, so it is gone. */
.btn--primary:hover,
.btn--primary:focus-visible,
.on-inverse .btn--primary:hover,
.on-inverse .btn--primary:focus-visible {
  background: var(--woodland-sage);
  border-color: var(--woodland-sage);
  color: var(--white);
}
.btn--primary:active,
.on-inverse .btn--primary:active {
  background: var(--woodland-sage-deep);
  border-color: var(--woodland-sage-deep);
  color: var(--white);
}
/* Home HERO CTA (a banner CTA, brief A7): home.css gives it a SOLID chip at
   rest; restate the rest chip to Woodland Sage with a white label. */
.hero .hero__cta.btn--primary {
  background: var(--woodland-sage);
  border-color: var(--woodland-sage);
  color: var(--white);
}
/* Round 1 scoped `--accent: var(--woodland-sage)` to .btn so that non-button gold
   (focus rings, eyebrows, dots) could keep the real gold --accent. Gold is retired
   now, so --accent IS Woodland Sage at :root (pb-tokens.css) and the scoped
   override was a no-op resolving to the identical value. Removed rather than left
   in place: a custom property that shadows its own inherited value is a trap for
   whoever next tries to re-theme the accent from one place. */

/* [L] FOOTER → WOODLAND SAGE GROUND (brief A8 + A9).
   The footer becomes a Woodland Sage #A3A393 slab with near-white ink — the one
   coloured band that closes the page. Supersedes the base footer inks above
   (loads later in the same sheet). The reversed WHITE mono logo is correct again
   on the sage ground (footer.php ships EC_Mono_Rev.png). */
.site-footer {
  background: var(--woodland-sage);
  color: var(--white);
}
.site-footer a { color: rgba(255,255,255,0.92); }
.site-footer a:hover { color: var(--slate-deep); }
.footer-col h3,
.footer-legal,
.footer-news__copy { color: rgba(255,255,255,0.86); }
.news-field input { color: var(--white); caret-color: var(--white); }
.news-field input { border-bottom-color: rgba(255,255,255,0.55); }
.news-field input::placeholder { color: rgba(255,255,255,0.7); }
.news-field input:focus { border-bottom-color: var(--white); }
.footer-top { border-bottom-color: rgba(255,255,255,0.28); }
.footer-legal { border-top-color: rgba(255,255,255,0.28); }
/* Footer focus rings: on the sage ground the dark slate-deep ring reads best
   ([C2] above relaxed footer rings to the dark ring already); keep it.
   slate-deep on sage measures 3.54:1 — clear of the 3:1 a focus indicator owes,
   and the reason a SAGE ring must never be allowed to reach this footer. */
.site-footer :focus-visible { outline-color: var(--slate-deep); }
/* ...including the newsletter input, which the base rule above suppresses the
   ring on (`outline: none`) because it used a GOLD UNDERLINE as its focus
   indicator instead. With gold retired that underline is white-on-sage: 2.0:1
   against the ground and only a 1.47:1 step up from its own resting 55%-white
   line, i.e. no longer a usable indicator. Restore the real ring so the field
   matches every other footer control, and keep the underline as the supporting
   cue. */
.site-footer .news-field input:focus-visible {
  outline: 2px solid var(--slate-deep);
  outline-offset: 2px;
}

/* Footer social icons (brief A9): DROP the circle chrome, make the glyphs big
   (28px), seven marks total (added inline in footer.php). 44px touch target but
   no border/background; the icon is the whole affordance. Near-white on sage,
   darkening to slate-deep on hover. */
.footer-social { gap: var(--space-md); flex-wrap: wrap; }
.footer-social a {
  width: auto; height: var(--tap-min);
  min-width: var(--tap-min);
  border: none; border-radius: 0;
  color: rgba(255,255,255,0.92);
}
.footer-social a:hover { border-color: transparent; color: var(--slate-deep); }
.footer-social svg { width: 28px; height: 28px; fill: none; }

/* Footer subscribe button (brief R7): on the sage ground the dark resting
   outline reads muddy, so at REST the footer Subscribe is a WHITE outline +
   WHITE label; on HOVER/FOCUS it inverts to a solid WHITE fill with slate-deep
   ink. Scoped to .news-field so no other primary CTA changes; beats [K]'s hover. */
.news-field .btn--primary {
  background: transparent;
  color: var(--white);
  border-color: var(--white);
}
.news-field .btn--primary:hover,
.news-field .btn--primary:focus-visible {
  background: var(--white);
  color: var(--slate-deep);
  border-color: var(--white);
}

/* [M] GLASS CHROME over the HERO / TOP OF PAGE (brief A11; generalised U1).
   The over-hero header keeps its glass background/blur/border/radius (the
   backdrop-blur over the photo IS the Apple-glass effect); only the ink stays
   dark/black for legibility. nav.js applies .site-header--over at the top of
   EVERY page and drops it once condensed, so internal headers read like home. */
.site-header--over {
  background: color-mix(in srgb, var(--white) 36%, transparent);
  -webkit-backdrop-filter: saturate(1.1) blur(16px);
  backdrop-filter: saturate(1.1) blur(16px);
  border: 1px solid rgba(255,255,255,0.55);
  border-radius: var(--chrome-radius);
  box-shadow: var(--shadow-glass);
}
.home .site-header--over {
  background: color-mix(in srgb, var(--white) 36%, transparent);
  -webkit-backdrop-filter: saturate(1.1) blur(16px);
  backdrop-filter: saturate(1.1) blur(16px);
  border: 1px solid rgba(255,255,255,0.55);
  border-radius: var(--chrome-radius);
  box-shadow: var(--shadow-glass);
}
.home .site-header--over::before { display: none; }

/* [N] MEGA PANEL as a FLOATING SHEET (round 4).
   Floats in the glass-bar language: 135px from the top (10px clear of the
   header foot), inset 10px each side, rounded four corners. */
.mega {
  top: 135px;
  left: 10px;
  max-width: calc(100% - 20px);
  max-height: calc(100dvh - 135px - var(--chrome-inset));
  border-radius: var(--chrome-radius);
}

/* [S6] HEADER INK → BLACK, EVERY STATE (brief S6).
   With the over-hero glass nearly clear, take ALL header ink to true black and
   hold it across every state (home over-hero, scrolled/condensed, plain
   subpage) so the menu word, icon cluster, "Carpets" link and "elevate comfort"
   wordmark all render black. Loads last, superseding home.css + inherited ink. */
.site-header,
.home .site-header--over {
  color: #000;
}
.home .site-header--over .nav-toggle,
.home .site-header--over .nav-toggle__label,
.home .site-header--over .icon-btn,
.home .site-header--over .header-textlink,
.home .site-header--over .brand-logo__tagline { color: #000; }
.site-header .brand-logo__tagline { color: #000; }
.site-header .header-textlink { color: #000; opacity: 1; }
.site-header__actions .icon-btn { color: #000; }
/* Round 55: the cart numeral goes WHITE, following the badge to its Dark Slate
   ground in the base section above. This rule previously forced #000 and loads
   after that section, so leaving it would have kept black numerals on the new
   dark chip. */
.cart-count,
.home .site-header--over .cart-count { color: var(--white); }

/* [I] HEADER ICON SET — OUTLINE RESTORE + SEARCH GLYPH.
   shell's global `svg { fill: currentColor }` overrides each header icon SVG's
   own fill="none", painting the glyphs solid. Restore the intended outline set
   for the header buttons; thicken only the search HANDLE so the magnifier reads. */
.site-header__actions .icon-btn svg { fill: none; }
.icon-btn[aria-label="Search"] svg circle { stroke-width: 1.4; }
.icon-btn[aria-label="Search"] svg line {
  stroke-width: 2.2;
  stroke-linecap: round;
}

/* ==========================================================================
   [P] TEXT LINKS → WOODLAND SAGE, HOVER → BODY INK
   Client: "across all the pages we have text that's links, including sort of top
   right for some sections but also in content, that has a few different colours
   across the pages. Let's make them all be Sage by default and then on hover go
   to the normal text colour."

   The complaint is INCONSISTENCY. Before this block the set painted its text
   links four different ways, all on the same white ground:
     · .link-underline (the "top right" section-head CTAs)  --text     #546067
       hovering to --text-accent                                       #515134
     · p a / .faq-item__a a (in-content prose)              --text-accent #515134
       hovering to --accent-hover                                      #8B8B7D
     · .info__help-link (configurator)                      --text-accent #515134
       hovering to --woodland-sage-deep                                #8B8B7D
   Now: ONE rest colour (--woodland-sage) and ONE hover/focus colour (--text)
   for every one of them.

   ── CONTRAST, stated plainly ──────────────────────────────────────────────
   --woodland-sage #A3A393 on white is 2.56:1. That is under the 4.5:1 WCAG 2.2
   AA asks of body text, and a link arguably owes MORE separation than body text,
   not less. --woodland-sage-ink #515134 is 8.15:1 and would pass; it is NOT used
   here because the client asked for Sage specifically, having accepted sage on
   light elsewhere on this project. Swap --woodland-sage for --woodland-sage-ink
   on these declarations if that call is ever revisited.
   Mitigation that is genuinely load-bearing: every link in this block keeps a
   PERMANENT underline (the .link-underline ::after rule, and text-decoration on
   the prose links), so WCAG 1.4.1 Use of Colour still holds — colour is not the
   only cue. It is 1.4.3 Contrast (Minimum) that fails, knowingly.
   Hover/focus --text #546067 is 6.47:1 (passes AA), and the rest→hover step is
   itself 2.53:1 with a pale-green→dark-slate value shift, so the state change is
   unmistakable rather than one failing value swapped for another.
   Other grounds these links can land on: bone #F5F4F1 2.32:1, bone-deep #EDEBE6
   2.15:1, sand #EFE7DA 2.08:1 (hover 5.88 / 5.43 / 5.27:1 respectively).

   ── SCOPE ─────────────────────────────────────────────────────────────────
   DELIBERATELY NOT SWEPT, each for a reason:
     · the footer — it sits ON Woodland Sage, so sage links would be 1:1 there;
     · header / mega nav / utility strip / skip link / breadcrumbs — chrome;
     · every .btn, .icon-btn, chip, room pill and cart control — buttons;
     · .on-inverse / .section--inverse / photographic grounds — the white-on-image
       links (hero scroll cue, shop-by-room tiles, FAQ image tiles, CTA bands);
     · whole-card links (.product-tile__name a, .de-slide__link, .edit-option__link,
       .shape__link, .range-card__medialink) — those are card anatomy, not text
       links; the product names in particular are headings that happen to be
       clickable, and sage-ing them would restyle every product grid in the set.
   The guard rules at the foot of this block make the first four explicit rather
   than relying on none of them happening to match.

   The mockup's fourth pair (body .sg-toc a) is NOT ported: .sg-toc is the
   styleguide page's contents list, which has no counterpart in this theme, and
   its `body` prefix exists only to outrank an inline <style> in that page's head.

   This layer loads after every block sheet on every page, so equal-specificity
   restatements of the block rules (the FAQ blocks' .faq-item__a a, pb-range.css's
   .info__help-link) win on order. ========================================== */

/* Section-level CTAs: the "top right" links on section heads, and the same face
   reused inside content (blog feature, Bahamas product-detail panels). */
.link-underline { color: var(--woodland-sage); }
.link-underline:hover,
.link-underline:focus-visible { color: var(--text); }

/* In-content prose links (the `p a` rule near the head of this file is the base
   rule these override). */
p a { color: var(--woodland-sage); }
p a:hover,
p a:focus-visible { color: var(--text); }

/* Block-sheet in-content links that each carried their own accent ink. */
.faq-item__a a,
.faq-item__more a,
.info__help-link { color: var(--woodland-sage); }
.faq-item__a a:hover,
.faq-item__a a:focus-visible,
.faq-item__more a:hover,
.faq-item__more a:focus-visible,
.info__help-link:hover,
.info__help-link:focus-visible { color: var(--text); }

/* GUARDS — the excluded grounds, stated so they cannot be swept by the rules
   above if markup moves. Sage on the sage footer is invisible; sage on a dark or
   photographic ground is not the treatment those links were designed with. These
   fall back to inheriting their container's ink, which is what they did before. */
.site-footer p a,
.site-footer .link-underline,
.on-inverse p a,
.on-inverse .link-underline,
.section--inverse p a,
.section--inverse .link-underline { color: inherit; }
.site-footer p a:hover,
.site-footer .link-underline:hover,
.on-inverse p a:hover,
.on-inverse .link-underline:hover,
.section--inverse p a:hover,
.section--inverse .link-underline:hover { color: inherit; }
