/* ============================================================================
   Luvera Jewellery - shared stylesheet
   Consumed by index.html AND component.html. Never copy a rule into a page.
   Every value here resolves to a token declared in ../../DESIGN.md.
   ============================================================================ */

/* --- 1. Tokens -------------------------------------------------------------
   Names match CLIENT-BRIEF.md 5.5 so they map 1:1 to settings_schema.json.   */

:root {
  color-scheme: light;

  /* Core brand */
  --color-primary:        #C08A7D;   /* Rose Wood. Surface only, never text.  */
  --color-accent:         #9C3A16;   /* Rust. The one accent.                 */
  --color-accent-deep:    #83300F;   /* Rust, hover only.                     */
  --color-foreground:     #1A1A1A;   /* Ink                                   */
  --color-secondary:      #6E6560;   /* Warm Grey                             */
  --color-background:     #FAF6F2;   /* Atelier Cream                         */

  /* Secondary palette */
  --color-surface-1:      #EEDFC0;   /* Soft Ivory                            */
  --color-surface-2:      #9B8271;   /* Taupe Brown. 24px+ type only.         */
  /* A control lifted off the cream ground. There are no shadows on this page,
     so the lift is this 1.5% luminance step plus a hairline. Never a section
     ground, never a page ground, only a control.                            */
  --color-surface-raised: #FFFFFF;

  /* Featured deck grounds. Four muted darks, one per piece, picked so the metal
     in that piece reads: umber behind skin and white gold, plum behind pearl,
     olive behind yellow gold, slate behind white gold and diamond.

     These are display grounds, not brand colours. The brand palette has no dark
     and inventing one for the whole page would break the theme handoff, so they
     live only on this band. Named by hue rather than by role because the hue is
     the entire reason each one was chosen. Measured against the near black
     display bust: every one stays light enough that the bust still reads as a
     silhouette instead of dissolving into the ground.                        */
  /* The stone band's ground: Rose Wood deepened, not a brown borrowed from
     somewhere else. Rose Wood is hsl(11.6, 35%, 62%); this is hsl(4.5, 32%,
     32%). Lightness roughly halved, saturation held, and the hue pulled about
     7 degrees off orange toward red. That last part is the one that matters.
     Darkening 11.6 on its own lands on chocolate, because brown IS dark
     orange: a first pass at 24% lightness on the original hue read as a cocoa
     panel with no rose left in it. Moving toward red keeps it reading as a
     deep rose at a lightness where it otherwise would not.

     Rose Wood at 62% carried only 2.72:1 against cream, which is why that band
     used to set Ink on it. Deepened it carries 8.42:1, so the band can take
     the same cream the four grounds below take.

     The diamond is the reason any of this happened. It renders at opacity 0.78
     on a transparent canvas, so the section's ground is literally 22% of every
     pixel of the stone. On Rose Wood that filled the pavilion with mid-tone
     clay and the stone read as a pink lozenge; on a dark ground the interior
     goes dark and the reflective facets become the only bright thing in the
     frame, which is how a brilliant actually reads. */
  --ground-rosewood: #6C3B37;
  --ground-umber: #342A23;
  --ground-plum:  #352731;
  --ground-olive: #323726;
  --ground-slate: #2A313B;
  --on-ground:       #FAF6F2;                  /* 13.4:1 or better on all four */
  --on-ground-mute:  rgb(250 246 242 / 0.72);
  --on-ground-rule:  rgb(250 246 242 / 0.20);
  --on-ground-dot:   rgb(250 246 242 / 0.45);
  --color-border:         #E5D29A;   /* Champagne Beige. Decorative only.     */
  --color-border-hairline:#E4DAD2;   /* Structural hairline.                  */
  --color-on-accent:      #FFFFFF;
  --color-on-primary:     #1A1A1A;

  /* Type on the Rust band. Derived from on-accent, declared so nothing is loose. */
  --color-on-accent-soft: rgb(255 255 255 / 0.86);
  --color-on-accent-mute: rgb(255 255 255 / 0.70);
  --color-on-accent-rule: rgb(255 255 255 / 0.45);
  --color-success-on-accent: #D9F2E1;
  --color-error-on-accent:   #FFE2D6;

  /* Overlays. The only two non opaque fills on the project. */
  --color-scrim:          rgb(26 26 26 / 0.32);
  /* Type over photography, on a scrim that shades. Since the hero went to a
     cream silk frame this is down to one user, the "Made where it is sold"
     band over the bench photograph, which is dark and stays dark. The hero
     carries Ink over a cream wash instead. Never on a bare image, and
     measured on the built page, not assumed. */
  --color-on-media:       #FFFFFF;
  --color-rule-on-primary: rgb(26 26 26 / 0.22);   /* hairlines on the Rose Wood band */
  /* Concentrated in the bottom left corner where the copy actually sits, so
     the subject on the right stays open. Measured on the built page against
     the real pixels under the type, not assumed from the gradient stops. */
  /* The hero scrim lightens rather than shades. The hero photograph is cream
     silk, so a pool of shade would be a smudge on a pale image and would put
     the one dark mass on the page directly above the cream ground the rest of
     the page is built on. Flooding Atelier Cream out of the bottom left instead
     lifts the silk's own shadow folds up to the page ground, so the copy sits
     on Atelier Cream, the photograph emerges from it, and the section has no
     hard bottom edge. Ink measures 5.22:1 at its worst pixel across the whole
     copy block, which is every pixel passing AA body rather than a percentile
     clearing it. Stops tuned against the rendered composite, not guessed. */
  --scrim-media:          radial-gradient(125% 95% at 0% 100%, rgb(250 246 242 / 0.94) 0%, rgb(250 246 242 / 0.82) 30%, rgb(250 246 242 / 0.60) 50%, rgb(250 246 242 / 0.18) 72%, rgb(250 246 242 / 0) 86%);
  --scrim-media-top:      linear-gradient(180deg, rgb(250 246 242 / 0.93) 0%, rgb(250 246 242 / 0.62) 42%, rgb(250 246 242 / 0) 100%);
  /* The bench photograph is busy edge to edge and its copy runs the full height
     of the band, so that one needs a column of shade rather than a corner. */
  --scrim-media-band:     linear-gradient(90deg, rgb(20 12 8 / 0.93) 0%, rgb(20 12 8 / 0.88) 24%, rgb(20 12 8 / 0.62) 42%, rgb(20 12 8 / 0.16) 66%, rgb(20 12 8 / 0) 84%);
  --color-veil:           rgb(250 246 242 / 0.86);
  /* A card standing on a photograph. Same warm dark the media scrims are built
     from, so a card and the scrim under it are the same colour rather than two
     nearly equal darks. Translucent on purpose: the bench is the point of that
     section and a solid card would bury a quarter of it. Measured against the
     brightest thing the photograph could put behind it, pure white, the card
     still composites to 8.8:1 for white type. */
  --card-on-media:        rgb(20 12 8 / 0.78);
  --on-media-mute:        rgb(255 255 255 / 0.70);   /* 5.7:1 on that card */
  --on-media-rule:        rgb(255 255 255 / 0.16);   /* the card edge only */

  /* Metal colours. Product attributes, not brand colours. In Liquid these come
     from the variant swatch, so they never take a brand token name. */
  --metal-yellow:         #D9A441;
  --metal-rose:           #C08A7D;
  --metal-white:          #D8D8D4;

  /* Semantic */
  --color-sale:           #9C3A16;
  --color-success:        #16A34A;
  --color-warning:        #F59E0B;
  --color-error:          #9C3A16;

  /* Text */
  --color-text-heading:   #1A1A1A;
  --color-text-body:      #1A1A1A;
  --color-text-muted:     #6E6560;
  /* Warm Grey measures 4.32:1 on Soft Ivory and fails AA. This is the same hue
     darkened until it clears, and the Ivory band swaps it in wholesale. */
  --color-text-muted-strong: #655D58;
  --color-text-link:      #9C3A16;

  /* Typography */
  --font-heading-family:  'Marcellus', 'Cormorant Garamond', Georgia, serif;
  --font-body-family:     'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-body-size:       1rem;
  --font-body-weight:     400;
  --font-body-line-height: 1.6;
  --font-heading-weight:  400;
  --font-heading-line-height: 1.15;
  --font-heading-letter-spacing: 0.01em;

  --type-display-xl:  clamp(38px, 4.4vw, 62px);
  --type-display-lg:  clamp(32px, 4.2vw, 56px);
  --type-headline-md: clamp(22px, 2.2vw, 30px);
  --type-headline-sm: 20px;
  --type-body-lg:     18px;
  --type-body-md:     16px;
  --type-body-sm:     14px;
  --type-label-xs:    12px;
  --type-label-micro: 11px;   /* counts and inline removes only */

  /* Spacing scale. 8px base, 4px half step. Nothing between these values.
     Named --s-* so the design-debug overlay derives the same scale.         */
  --s-4:   4px;
  --s-8:   8px;
  --s-12:  12px;
  --s-16:  16px;
  --s-24:  24px;
  --s-32:  32px;
  --s-48:  48px;
  --s-64:  64px;
  --s-96:  96px;
  --s-128: 128px;

  /* Layout */
  --page-width:               1280px;
  --page-width-narrow:        760px;
  --gutter:                   var(--s-32);
  --grid-gap:                 var(--s-24);
  --spacing-sections-desktop: var(--s-96);
  --spacing-sections-mobile:  var(--s-64);
  --header-height:            72px;

  /* Shape. Rectangular surfaces stay sharp. The one exception is a control
     whose outline is its affordance: the chip reads as a token to be picked
     up rather than a box, and a square one reads as a disabled button.      */
  --border-radius:        2px;
  --button-border-radius: 0px;
  --input-border-radius:  0px;
  --card-border-radius:   2px;
  --chip-border-radius:   999px;

  /* Motion */
  --duration-short:   100ms;
  --duration-default: 250ms;
  --duration-long:    500ms;
  --duration-reveal:  700ms;
  --ease-default:     cubic-bezier(0.4, 0, 0.2, 1);
  --ease-reveal:      cubic-bezier(0.16, 1, 0.3, 1);
  /* The curtain. Leaves with zero velocity, gathers speed, then spends most of
     its length decelerating into the stop. An ease-out alone starts at full
     speed and reads as a yank. */
  --ease-curtain:     cubic-bezier(0.33, 0, 0.12, 1);
  --duration-curtain: 900ms;
  /* The featured deck's turn. Long on purpose: the pieces travel a real
     distance now, and at the old 250ms the movement was over before the eye
     found it. Paired with --ease-reveal, which is already the project's
     arrival curve, so the deck does not get a motion vocabulary of its own. */
  --duration-slide:   1100ms;
  --preload-cut:      245ms;   /* one frame of the roll */
  /* How long a featured piece holds before the deck turns. Long enough to read
     six disclosure rows without hurrying, which is the point of the section. */
  --duration-dwell:   6500ms;

  /* Buttons */
  --button-padding:        var(--s-16) var(--s-32);
  --button-font-size:      13px;   /* label-button */
  --button-font-weight:    500;
  --button-text-transform: uppercase;
  --button-letter-spacing: 0.08em;

  /* Layers. The only z-indexes on the project.                              */
  --z-sticky:  40;
  --z-header:  50;
  --z-overlay: 60;
  --z-drawer:  70;
  --z-fab:     45;
  --z-preloader: 100;   /* above everything, briefly */
}

@media (max-width: 768px) {
  :root {
    --gutter:        var(--s-16);
    --grid-gap:      var(--s-12);
    --header-height: 64px;
    /* The copy runs the full width on a phone, so the scrim rises from the
       bottom rather than out of one corner, and it is harder than the desktop
       wash. Every frame in the client catalogue is a centred product shot, so
       at this width the piece is always behind the headline. Rather than crop
       the piece out of its own photograph, the wash goes almost solid under
       the copy and lets the piece dissolve into the cream above it. This
       breakpoint and the hero <picture> source are both 768, so the tall frame
       and this scrim always change together. */
    --scrim-media:      linear-gradient(to top, rgb(250 246 242 / 0.97) 0%, rgb(250 246 242 / 0.95) 30%, rgb(250 246 242 / 0.82) 48%, rgb(250 246 242 / 0.34) 68%, rgb(250 246 242 / 0.06) 86%, rgb(250 246 242 / 0) 96%);
    --scrim-media-band: linear-gradient(to top, rgb(20 12 8 / 0.92) 0%, rgb(20 12 8 / 0.84) 32%, rgb(20 12 8 / 0.52) 58%, rgb(20 12 8 / 0.14) 82%, rgb(20 12 8 / 0) 96%);
  }
}

/* --- 2. Base --------------------------------------------------------------- */

*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-height) + var(--s-24));
}
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0;
  background: var(--color-background);
  color: var(--color-text-body);
  font-family: var(--font-body-family);
  font-size: var(--font-body-size);
  font-weight: var(--font-body-weight);
  line-height: var(--font-body-line-height);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}
body.is-locked { overflow: hidden; }

img, svg, video, canvas { display: block; max-width: 100%; }
img { height: auto; }

a { color: var(--color-text-link); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a:hover { color: var(--color-accent-deep); }

button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }

:focus-visible {
  outline: 2px solid var(--color-foreground);
  outline-offset: 2px;
}

h1, h2, h3, h4 {
  font-family: var(--font-heading-family);
  font-weight: var(--font-heading-weight);
  line-height: var(--font-heading-line-height);
  letter-spacing: var(--font-heading-letter-spacing);
  margin: 0;
  color: var(--color-text-heading);
  text-wrap: balance;
}
p { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }

.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

.skip-link {
  position: absolute; top: var(--s-8); left: var(--s-8); z-index: 100;
  transform: translateY(-200%);
  background: var(--color-foreground); color: var(--color-background);
  padding: var(--s-12) var(--s-16); font-size: var(--type-body-sm);
}
.skip-link:focus { transform: none; color: var(--color-background); }

/* --- 3. Layout primitives -------------------------------------------------- */

.shell {
  width: 100%;
  max-width: var(--page-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.shell--narrow { max-width: calc(var(--page-width-narrow) + var(--gutter) * 2); }

.section { padding-block: var(--spacing-sections-desktop); }
@media (max-width: 768px) { .section { padding-block: var(--spacing-sections-mobile); } }

/* Optical section rhythm, per DESIGN.md Layout. Both values stay on the scale. */
.section--tight-bottom { padding-bottom: var(--s-64); }

.section--ivory {
  background: var(--color-surface-1);
  --color-text-muted: var(--color-text-muted-strong);
}
/* The whole band flips by redefining two tokens rather than by restating a
   colour on every child: the heading, the certification names, their icons,
   their body copy and both hairlines all already resolve through these. */
.section--rose {
  --color-on-primary:      var(--on-ground);
  --color-rule-on-primary: var(--on-ground-rule);
  background: var(--ground-rosewood);
  color: var(--on-ground);
}
.section--rust  { background: var(--color-accent);  color: var(--color-on-accent); }

.section--rose h2, .section--rose h3 { color: var(--color-on-primary); }
.section--rust h2, .section--rust h3 { color: var(--color-on-accent); }

.rule { height: 1px; background: var(--color-border-hairline); border: 0; margin: 0; }

/* Brand pattern. The client's own vectorised artwork, and it appears in exactly
   one place: the rule across the top of the footer. It used to run here and as a
   divider between two sections as well, which spent the motif twice and left the
   footer with nothing of its own to arrive on.

   `auto 100%` locks the strip to the band's height and tiles it sideways. The
   artwork is 4301 by 314, so at this height one repeat is 658px and the join
   lands twice across the footer. The strip's ends meet cleanly, checked at 4x
   before this was wired up.

   48 and 0.55, which is the treatment the pattern carried when it was the
   primary of its two instances. At the footer's old 32 and 0.4 the motif read
   as noise rather than as the brand mark, and it is the only one left.

   No `color` here. The fill is baked into the file, and an external SVG used as
   a background-image cannot inherit currentColor, so a colour declared on this
   element would do nothing but mislead the next reader. */
.pattern-band {
  height: var(--s-48);
  background-image: url('../img/pattern.svg');
  background-repeat: repeat-x;
  background-size: auto 100%;
  background-position: center;
  opacity: 0.55;
}

/* --- 4. Typography classes ------------------------------------------------- */

.t-display-xl  { font-family: var(--font-heading-family); font-size: var(--type-display-xl);  line-height: 1.04; letter-spacing: 0.005em; font-weight: 400; }
.t-display-lg  { font-family: var(--font-heading-family); font-size: var(--type-display-lg);  line-height: 1.1;  letter-spacing: 0.01em;  font-weight: 400; }
.t-headline-md { font-family: var(--font-heading-family); font-size: var(--type-headline-md); line-height: 1.2;  letter-spacing: 0.01em;  font-weight: 400; }
.t-headline-sm { font-family: var(--font-heading-family); font-size: var(--type-headline-sm); line-height: 1.25; font-weight: 400; }

.t-body-lg { font-size: var(--type-body-lg); line-height: 1.65; max-width: 65ch; }
.t-body-md { font-size: var(--type-body-md); line-height: 1.6;  max-width: 65ch; }
.t-body-sm { font-size: var(--type-body-sm); line-height: 1.55; max-width: 65ch; }
.t-muted   { color: var(--color-text-muted); }

.t-label {
  font-size: var(--type-label-xs);
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.2;
}
.t-price { font-size: 15px; font-weight: 500; font-variant-numeric: tabular-nums; }

.t-numeral {
  font-family: var(--font-heading-family);
  font-size: clamp(32px, 3.4vw, 44px);
  line-height: 1;
  color: var(--color-surface-2);
  font-variant-numeric: tabular-nums;
  transition: color var(--duration-long) var(--ease-default);
}

/* --- 5. Buttons ------------------------------------------------------------ */

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-8);
  min-height: 48px;
  padding: var(--button-padding);
  border: 1px solid transparent;
  border-radius: var(--button-border-radius);
  font-size: var(--button-font-size);
  font-weight: var(--button-font-weight);
  letter-spacing: var(--button-letter-spacing);
  text-transform: var(--button-text-transform);
  text-decoration: none;
  white-space: nowrap;
  transition: background-color var(--duration-default) var(--ease-default),
              color var(--duration-default) var(--ease-default),
              border-color var(--duration-default) var(--ease-default),
              transform var(--duration-default) var(--ease-default);
}
.btn i { font-size: 18px; }

.btn--primary { background: var(--color-accent); color: var(--color-on-accent); }
.btn--primary:hover, .btn--primary.is-hover { background: var(--color-accent-deep); color: var(--color-on-accent); transform: translateY(-4px); }
.btn--primary:active { transform: scale(0.98); }

.btn--secondary { background: transparent; color: var(--color-foreground); border-color: var(--color-foreground); }
.btn--secondary:hover, .btn--secondary.is-hover { background: var(--color-foreground); color: var(--color-background); transform: translateY(-4px); }
.btn--secondary:active { transform: scale(0.98); }

/* Used once, inside the Rust band. Recorded in DESIGN.md Exceptions. */
.btn--on-accent { background: var(--color-on-accent); color: var(--color-accent); }
.btn--on-accent:hover, .btn--on-accent.is-hover { background: var(--color-background); color: var(--color-accent-deep); transform: translateY(-4px); }

.btn--ghost-on-accent { background: transparent; color: var(--color-on-accent); border-color: var(--color-on-accent); }
.btn--ghost-on-accent:hover, .btn--ghost-on-accent.is-hover { background: var(--color-on-accent); color: var(--color-accent); }

.btn--block { width: 100%; }
.btn[disabled], .btn[aria-disabled='true'] { opacity: 0.4; cursor: not-allowed; transform: none; }
.btn[aria-busy='true'] .btn__label { visibility: hidden; }
.btn[aria-busy='true']::after {
  content: '\eec6'; /* ri-loader-4-line, codepoint verified against remixicon 4.6.0 */
  font-family: 'remixicon';
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 20px; height: 20px;
  display: flex; align-items: center; justify-content: center;
  font-size: 20px; line-height: 1;
  animation: spin 900ms linear infinite;
}
@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } }

/* Text link with a rule that extends on hover. */
/* Text link with a real arrow. It used to end in a 24px rule that grew to 48 on
   hover, which looked considered and told the visitor nothing: a line does not
   point anywhere, and the class has been called link-arrow the whole time. The
   glyph is the one already used in the chips, the mobile nav and the footer.

   The nudge is 4px over --duration-default, the same displacement and the same
   timing as the button's hover lift, so the two do not read as different
   intensities of the same idea. */
.link-arrow {
  display: inline-flex; align-items: center; gap: var(--s-8);
  font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--color-accent); text-decoration: none;
  min-height: 44px;
}
.link-arrow i {
  font-size: 18px; line-height: 1;
  transition: transform var(--duration-default) var(--ease-default);
}
.link-arrow:hover i, .link-arrow.is-hover i,
.link-arrow:focus-visible i { transform: translateX(4px); }

/* The same label typography for a text action that goes nowhere. The cart's
   Remove was borrowing link-arrow for its type and inheriting a rule, and would
   now inherit an arrow pointing at nothing. An arrow is a destination. */
.link-quiet {
  display: inline-flex; align-items: center;
  font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--color-accent);
  background: none; border: 0; padding: 0;
  text-decoration: none; text-underline-offset: 3px;
  min-height: 44px;
}
.link-quiet:hover, .link-quiet.is-hover { color: var(--color-accent-deep); }

/* ── The drawn rule ──────────────────────────────────────────────────────
   Every underline on the page draws from the left. text-decoration cannot be
   animated, which is why the hover states this replaces simply switched on,
   the only feedback on the page that arrived instead of moving, against a
   button lift, an arrow nudge and a card rule that all travel.

   It is a background gradient on an inline span, not text-decoration and not
   an absolutely positioned ::after. inline plus box-decoration-break: clone
   gives every line box its own background area, so a label that wraps gets a
   rule the width of each line and they draw together.

   inline-block was the bug this pattern already solved once on the card title:
   shrink-to-fit only shrinks while the text fits on one line, so a wrapped
   "Gold valuation and exchange" drew a full-column rule under a half-column
   word. Inside a flex or inline-flex parent the span blockifies to a flex item
   and sizes to its content, which lands in the same place, those labels are
   single words and never wrap.

   padding-bottom sits the rule clear of the baseline. It is inline padding, so
   it does not push wrapped lines apart.

   The width is a custom property so a trigger is one inherited declaration on
   whatever is hovered, rather than this recipe repeated per component. Add a
   component by giving its span .underline and its hover target --underline-w: 100%. */
.underline {
  display: inline;
  padding-bottom: 0.08em;
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: var(--underline-w, 0%) 1px;
  -webkit-box-decoration-break: clone;
          box-decoration-break: clone;
  /* --ease-default, not --ease-reveal. The reveal curve is expo-out, an arrival
     curve: measured on this rule it drew 75% of the width in the first 80ms of
     500ms and spent the remaining 420ms on the last 25%. That is a flash with a
     long tail, not a line you can watch travel, the same objection the
     --ease-curtain note makes about a bare ease-out. This curve spends its time
     in the middle, where the movement is actually legible. */
  transition: background-size var(--duration-long) var(--ease-default);
}

/* The address links keep a permanent hairline and draw the accent rule over
   it, so the underline is present at rest and the hover has somewhere to go.
   Two layers on one span: the drawn rule first, the hairline beneath it. */
.address__value a > .underline {
  /* Two up on a phone the email wraps, and a background rule on an inline box
     paints one area across both line boxes unless each fragment gets its own.
     Same reason .card__title carries it. */
  -webkit-box-decoration-break: clone;
          box-decoration-break: clone;
  background-image:
    linear-gradient(currentColor, currentColor),
    linear-gradient(var(--color-border-hairline), var(--color-border-hairline));
  background-size: var(--underline-w, 0%) 1px, 100% 1px;
  background-position: 0 100%, 0 100%;
}

/* Triggers. --underline-w inherits, so it is set on the element the pointer is
   actually over and the span picks it up wherever it sits inside. */
.card:hover, .card.is-hover, .card:focus-within,
.service:hover, .service.is-hover, .service:focus-visible,
.link-quiet:hover, .link-quiet.is-hover, .link-quiet:focus-visible,
.fcol a:hover, .fcol a:focus-visible,
.address__value a:hover, .address__value a:focus-visible { --underline-w: 100%; }
/* .footer__contact a is deliberately absent: those links carry no rule at rest
   or on hover, only a colour change. They are the same content type as the
   address links, which do carry one, an inconsistency that predates this
   pass and is a design call, not a bug to fix here. */

@media (prefers-reduced-motion: reduce) {
  .link-arrow:hover i, .link-arrow.is-hover i, .link-arrow:focus-visible i { transform: none; }
  /* The rule still appears, it is the hover signal, it just stops travelling. */
  .underline { transition: none; }
}

/* Icon control. 44x44 tap target on every viewport. */
.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; padding: 0;
  background: none; border: 0; color: inherit;
  /* Most icon-btns are <button>, which has no underline to inherit. The
     wishlist in the header and the four social links in the footer are <a>,
     and the base a rule sets underline-offset and thickness without ever
     setting text-decoration, so those picked up the UA default and drew a
     line under the glyph. It read as a clipped icon, not as a link. */
  text-decoration: none;
  transition: color var(--duration-default) var(--ease-default);
}
.icon-btn i { font-size: 22px; }
.icon-btn:hover, .icon-btn.is-hover { color: var(--color-accent); }

/* --- 6. Forms -------------------------------------------------------------- */

.field { display: grid; gap: var(--s-8); }
.field__label {
  font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--color-text-muted);
}
.field__input {
  width: 100%;
  min-height: 48px;
  padding: var(--s-12) 0;
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--color-border-hairline);
  border-radius: var(--input-border-radius);
  color: var(--color-foreground);
  transition: border-color var(--duration-default) var(--ease-default);
}
.field__input::placeholder { color: var(--color-text-muted); opacity: 1; }
.field__input:focus,
.field__input.is-focus { outline: none; border-bottom-color: var(--color-accent); }
.field__input:focus-visible { outline: 2px solid var(--color-foreground); outline-offset: 4px; }
.field__help { font-size: var(--type-body-sm); color: var(--color-text-muted); }
.field__error { font-size: var(--type-body-sm); color: var(--color-error); display: none; }
.field__success { font-size: var(--type-body-sm); color: var(--color-success); display: none; }

.field[data-state='error'] .field__input { border-bottom-color: var(--color-error); }
.field[data-state='error'] .field__error { display: block; }
.field[data-state='error'] .field__help { display: none; }
.field[data-state='success'] .field__input { border-bottom-color: var(--color-success); }
.field[data-state='success'] .field__success { display: block; }
.field[data-state='success'] .field__help { display: none; }
.field__input[disabled] { opacity: 0.4; cursor: not-allowed; }

/* On the Rust band the underline and helper text invert. */
.section--rust .field__label,
.section--rust .field__help { color: var(--color-on-accent-soft); }
.section--rust .field__input { color: var(--color-on-accent); border-bottom-color: var(--color-on-accent-rule); }
.section--rust .field__input::placeholder { color: var(--color-on-accent-mute); }
.section--rust .field__input:focus { border-bottom-color: var(--color-on-accent); }
.section--rust .field__input:focus-visible { outline-color: var(--color-on-accent); }
.section--rust .field__success { color: var(--color-success-on-accent); }
.section--rust .field__error { color: var(--color-error-on-accent); }

/* --- 7. Reveal ------------------------------------------------------------
   Hidden only when JS is present. Without JS every element paints.          */

.js .reveal { opacity: 0; transform: translateY(24px); }
.js .reveal.is-in {
  opacity: 1;
  transform: none;
  transition: opacity var(--duration-reveal) var(--ease-default),
              transform var(--duration-reveal) var(--ease-reveal);
  transition-delay: calc(var(--i, 0) * 70ms);
}

@media (prefers-reduced-motion: reduce) {
  .js .reveal { opacity: 1; transform: none; }
  .js .reveal.is-in { transition: none; transition-delay: 0ms; }
  .btn:hover, .btn:active { transform: none; }
}

/* --- 8. Announcement bar --------------------------------------------------- */

.announcement {
  background: var(--color-foreground);
  color: var(--color-background);
}
.announcement__inner {
  display: flex; align-items: center; justify-content: center; gap: var(--s-16);
  min-height: 48px; padding-block: var(--s-8);
  text-align: center;
}
.announcement p { font-size: var(--type-body-sm); padding-inline-end: var(--s-32); }
.announcement a { display: inline-block; padding-block: var(--s-4); }
.announcement a { color: var(--color-background); }
.announcement .icon-btn { position: absolute; right: calc(var(--gutter) - var(--s-8)); }
.announcement .icon-btn i { font-size: 18px; }
.announcement__inner { position: relative; }
.announcement[hidden] { display: none; }

/* --- 9. Header ------------------------------------------------------------- */

.header {
  position: sticky; top: 0; z-index: var(--z-header);
  background: var(--color-background);
  border-bottom: 1px solid var(--color-border-hairline);
  transition: transform var(--duration-long) var(--ease-default),
              background-color var(--duration-long) var(--ease-default),
              border-color var(--duration-long) var(--ease-default);
}

/* Over the full-bleed hero the bar has no ground of its own and sits on the
   image's top scrim. It solidifies the moment the hero is behind you. */
.header[data-atop='true'] {
  background: transparent;
  border-bottom-color: transparent;
  color: var(--color-foreground);
}
/* The hero is a pale photograph, so the bar over it keeps the page's own Ink
   and the wordmark is left exactly as it is drawn. It used to invert to white
   against a brown hero; inverting over cream silk would erase it. */
.header[data-atop='true'] .nav__link,
.header[data-atop='true'] .icon-btn { color: var(--color-foreground); }
.header[data-atop='true'] .nav__link:hover,
.header[data-atop='true'] .icon-btn:hover { color: var(--color-accent); }
.header[data-atop='true'] .nav__link::after { background: var(--color-accent); }
.header[data-hidden='true'] { transform: translateY(-101%); }

.header__inner {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s-24);
  min-height: var(--header-height);
}

.header__logo { display: inline-flex; align-items: center; justify-self: center; min-height: 44px; }
.header__logo img { width: 132px; height: auto; }

.nav { display: flex; align-items: center; gap: var(--s-24); }
.nav__link {
  position: relative;
  display: inline-flex; align-items: center; gap: var(--s-4);
  padding-block: var(--s-8);
  font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--color-foreground); text-decoration: none;
  white-space: nowrap;
}
.nav__link i { font-size: 16px; transition: transform var(--duration-default) var(--ease-default); }
.nav__link::after {
  content: ''; position: absolute; left: 0; bottom: 0; height: 1px; width: 0;
  background: var(--color-accent);
  transition: width var(--duration-default) var(--ease-default);
}
.nav__link:hover, .nav__link.is-hover { color: var(--color-accent); }
.nav__link:hover::after, .nav__link.is-hover::after { width: 100%; }

.nav__item { position: relative; }
.nav__item:hover .nav__link i,
.nav__item:focus-within .nav__link i { transform: rotate(180deg); }

.nav__panel {
  position: absolute; top: 100%; left: calc(var(--s-16) * -1);
  min-width: 208px; padding: var(--s-16);
  background: var(--color-background);
  border: 1px solid var(--color-border-hairline);
  border-radius: var(--border-radius);
  display: grid; gap: var(--s-4);
  opacity: 0; visibility: hidden; transform: translateY(-8px);
  transition: opacity var(--duration-default) var(--ease-default),
              transform var(--duration-default) var(--ease-default),
              visibility var(--duration-default);
}
.nav__item:hover .nav__panel,
.nav__item.is-hover .nav__panel,
.nav__item:focus-within .nav__panel { opacity: 1; visibility: visible; transform: none; }
.nav__panel a {
  padding: var(--s-8) var(--s-8);
  font-size: var(--type-body-sm); color: var(--color-foreground); text-decoration: none;
  transition: background-color var(--duration-short) var(--ease-default);
}
.nav__panel a:hover { background: var(--color-surface-1); color: var(--color-accent); }

.header__actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--s-4); }

/* Reserved, empty. A market selector switches on here with no relayout.
   CLIENT-BRIEF.md 5.1 and 6.2. */
.currency-slot { min-width: 0; display: none; }
@media (min-width: 1024px) { .currency-slot { display: block; min-width: var(--s-64); } }

/* The badge sits in the button's corner, not on the glyph. At 18px square from
   top 6 right 4 it covered the bag's handle and its whole top-right corner, and
   a bag with its handle eaten reads as a clipped icon rather than a bag with a
   count. 16px from top 2 right 0 tucks it into the corner the 44px tap target
   already provides around a 22px glyph.

   The ring is what makes the count safe to grow. padding-inline lets the pill
   widen leftward for 2 and 3 digits, back over the glyph; a 2px ring in the
   header's own ground keeps the two readable as separate objects at any width.
   It is painted with box-shadow rather than a border so it costs no layout. */
.cart-count {
  position: absolute; top: 2px; right: 0;
  min-width: 16px; height: 16px; padding-inline: 4px;
  box-shadow: 0 0 0 2px var(--color-background);
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--color-accent); color: var(--color-on-accent);
  font-size: var(--type-label-micro); font-weight: 600; font-variant-numeric: tabular-nums;
  border-radius: 999px;
}
.cart-count[data-count='0'] { display: none; }
.header__cart { position: relative; }

.header__burger { display: none; }
@media (max-width: 1023px) {
  .header__inner { grid-template-columns: auto 1fr auto; }
  .header__nav { display: none; }
  .header__burger { display: inline-flex; }
  .header__logo { justify-self: center; }
  .header__logo img { width: 108px; }
}

/* --- 10. Hero, full bleed ---------------------------------------------------
   The photograph is the section. Type sits on a scrim in the lower left, three
   elements only: headline, one line, one action. Everything that used to live
   up here (the proof numbers, the second CTA) moved to its own band below.  */

.hero {
  position: relative;
  min-height: 88dvh;
  display: grid;
  align-items: end;
  padding: 0;
  margin-top: calc(var(--header-height) * -1);   /* the bar sits on the image */
  isolation: isolate;
}
.hero__media {
  position: absolute; inset: 0; z-index: -2;
  overflow: hidden;
}
.hero__media img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 46%; }
.hero::before,
.hero::after {
  content: ''; position: absolute; left: 0; right: 0; z-index: -1;
  pointer-events: none;
}
.hero::before { top: 0; height: 220px; background: var(--scrim-media-top); }
.hero::after  { inset: 0; background: var(--scrim-media); }

.hero__sentinel {
  position: absolute; top: 0; left: 0; width: 1px;
  height: calc(100% - var(--header-height));
  pointer-events: none;
}
.hero__copy {
  display: grid;
  gap: var(--s-24);
  max-width: min(46vw, 560px);
  padding-block: var(--s-96);
  color: var(--color-foreground);
}
.hero__title { font-size: var(--type-display-xl); color: var(--color-foreground); }
.hero__lead { font-size: var(--type-body-lg); color: var(--color-foreground); max-width: 34ch; }
/* There was a `.hero__eyebrow` here. It is gone with the label it styled: this
   project bans eyebrow kickers outright, and the Do's and Don'ts had been
   carrying the violation as an open note rather than a rule being followed. */
.hero .btn--on-media {
  justify-self: start;
  background: var(--color-accent);
  color: var(--color-on-accent);
  border-color: var(--color-accent);
}
/* The hero CTA is now the page's own primary button. A white button on cream
   silk is a shape with no edge; over the old brown hero it was the contrast. */
.hero .btn--on-media:hover,
.hero .btn--on-media.is-hover { background: var(--color-foreground); color: var(--color-background); border-color: var(--color-foreground); transform: translateY(-4px); }

@media (max-width: 1023px) {
  .hero { min-height: 78dvh; }
  .hero__copy { max-width: min(64vw, 520px); padding-block: var(--s-64); }
}
@media (max-width: 640px) {
  .hero { min-height: 74dvh; }
  .hero__copy { max-width: none; padding-block: var(--s-48); }
  .hero .btn--on-media { justify-self: stretch; }
}

/* --- 10a. Vision, asymmetric bleed ------------------------------------------
   The first thing under the hero that is not a product. Copy holds the page
   gutter on the left, the photograph runs off the right edge and off the
   bottom of the section, so the boundary with the next section is the picture
   rather than a rule.

   This is a different layout family from the two splits further down: `piece`
   and `stone` are both contained inside the shell on both sides, and this one
   is deliberately unbalanced and bleeds. The copy uses .shell so its left edge
   lands on the same line as every other section and the navbar.             */

.vision {
  position: relative;
  min-height: 760px;
  background: var(--color-background);
  /* The shell's content edge, as an inset from this section's own edge. The
     media is absolutely placed against the full bleed section rather than
     inside .shell, so it has no other way to find the line the rest of the
     page ends on. Same arithmetic .shell does: centre the page width, then
     step in by one gutter. */
  --shell-inset:   calc((100% - min(100%, var(--page-width))) / 2 + var(--gutter));
  --shell-content: calc(100% - var(--shell-inset) * 2);
}
.vision__media {
  position: absolute;
  /* Top and bottom are the same 96 the copy beside it carries as padding, so
     the picture and the words occupy one content box rather than two
     different ones. It used to sit at bottom 0, which ran it into the top
     edge of the next section: with the band below being the dark one, the
     picture appeared to be resting on it. */
  top: var(--s-96); bottom: var(--s-96);
  /* It used to sit at 0 and bleed to the viewport edge, which put its right
     edge 104px past the line every other section stops on and read as the
     picture ignoring the grid rather than as a deliberate bleed. The width is
     a share of the shell's content rather than of the viewport for the same
     reason: at 40% of a 2560px window the picture grew to 1024px and ran in
     under the copy. As a share of the content it holds its proportion at
     every width. */
  inset-inline-end: var(--shell-inset);
  width: calc(var(--shell-content) * 0.46);
}
.vision__media picture { display: block; width: 100%; height: 100%; }
.vision__media img {
  width: 100%; height: 100%; object-fit: cover;
  /* A contained panel now, so it takes the same 2px every other contained
     image on the page carries. */
  border-radius: var(--card-border-radius);
}

.vision__copy {
  position: relative;
  display: grid; gap: var(--s-48);
  /* Short of the media column, so a long line never runs under the picture. */
  max-width: min(46%, 520px);
  padding-block: var(--s-96);
}
.vision__copy p { max-width: 38ch; }

@media (max-width: 1023px) {
  /* Stacked, the picture stops being a column and leads the section as a full
     bleed band, with the words under it. Full bleed, because the hero above is
     full bleed too.

     The 64 above it is not decoration. The picture used to end the section,
     running into the dark band below, and the section carried no padding of its
     own. Leading instead puts it directly under the hero, which is also a
     photograph, and two photographs meeting on a shared edge read as one
     confused image. This is the same reason the desktop panel is held 96 off
     the top. The section's own padding now does that job on both sides: 64
     above the picture and 64 under the words, with a tighter 48 between the
     picture and the headline so the two still group as one content box. */
  .vision { min-height: 0; padding-block: var(--s-64) 0; }
  .vision__copy { max-width: 46ch; padding-block: var(--s-48) var(--s-64); }
  .vision__media {
    position: static; width: 100%; height: 320px;
    display: block;
  }
  /* Full bleed again once it is a band, so the radius comes back off. */
  .vision__media img { border-radius: 0; }
}
@media (max-width: 560px) {
  .vision__media { height: 260px; }
}

/* --- 10b. Ghost type --------------------------------------------------------
   A single display word set in Champagne Beige, the palette's decoration only
   colour, sitting behind a product. It is aria-hidden and always duplicates a
   real heading nearby, so nothing is said only by a watermark.               */

.ghost {
  /* Centred with the translate property rather than with transform, so the
     transform channel stays free for the carousel's parallax drift. The two
     are identical to look at; only the compositing channel differs. */
  position: absolute; left: 50%; translate: -50% 0;
  font-family: var(--font-heading-family);
  font-size: clamp(88px, 19vw, 280px);
  line-height: 0.78;
  letter-spacing: 0.02em;
  color: color-mix(in srgb, var(--color-border) 62%, var(--color-surface-1));
  white-space: nowrap;
  user-select: none;
  pointer-events: none;
  z-index: 0;
}

/* --- 11. Category chip rail -------------------------------------------------
   Six categories as pills, each carrying its own thumbnail. It carries
   id="collections", so every Shop anchor in the header, footer, hero and
   mobile nav still resolves.

   It no longer has a section of its own. It sits inside "Also on the counter"
   directly above the product grid, which is the thing it filters, and takes
   that section's heading rather than carrying a second one. The --inline
   modifier is the spacing that move needs: 48 above is inherited from
   .head__row, 32 below, so the rail groups with the cards rather than with
   the heading. Recorded in DESIGN.md Layout.                                */

.chips__rail {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-16);
  list-style: none;
  margin: 0;
  padding: 0;
}
.chips__rail--inline { margin-bottom: var(--s-32); }

.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--s-12);
  min-height: 48px;   /* the tap target floor, same as .btn. Content renders 54. */
  /* 8 on the left so the thumbnail sits just inside the curve, 16 on the right
     because a light glyph needs more room off the edge than a filled circle
     does. It is also what keeps all six on one line in a 1216px container:
     at 24 the set measured 1228 and orphaned Bracelets onto a second row. */
  padding: var(--s-8) var(--s-16) var(--s-8) var(--s-8);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border-hairline);
  border-radius: var(--chip-border-radius);
  color: var(--color-foreground);
  text-decoration: none;
  transition: border-color var(--duration-default) var(--ease-default),
              transform var(--duration-default) var(--ease-default);
}

.chip__thumb {
  flex: none;
  width: 36px;
  height: 36px;
  overflow: hidden;
  border-radius: var(--chip-border-radius);
  background: var(--color-surface-1);
}
.chip__thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--duration-reveal) var(--ease-reveal);
}

.chip__name {
  font-family: var(--font-heading-family);
  font-size: var(--type-headline-sm);
  line-height: 1;
}

.chip__arrow {
  margin-inline-start: var(--s-4);
  font-size: 20px;
  line-height: 1;
  transition: color var(--duration-default) var(--ease-default);
}

/* Hover reuses the two idioms the page already has, at the same intensity:
   the button's 4px lift and the tile's 1.04 scale inside a fixed frame. Rust
   arrives on the rule and the arrow because Rust is what says link elsewhere.
   The colour is restated so the base a:hover rule cannot take the name. */
.chip:hover, .chip.is-hover, .chip:focus-visible, .chip.is-focus {
  border-color: var(--color-accent);
  color: var(--color-foreground);
  transform: translateY(-4px);
}
.chip:hover .chip__arrow,
.chip.is-hover .chip__arrow,
.chip:focus-visible .chip__arrow,
.chip.is-focus .chip__arrow { color: var(--color-accent); }
.chip:hover .chip__thumb img,
.chip.is-hover .chip__thumb img,
.chip:focus-visible .chip__thumb img,
.chip.is-focus .chip__thumb img { transform: scale(1.04); }
.chip:active, .chip.is-active { transform: scale(0.98); }
/* Mirrors the global :focus-visible ring so component.html shows the real one. */
.chip.is-focus { outline: 2px solid var(--color-foreground); outline-offset: 2px; }

/* Below 1023 the rail scrolls sideways instead of wrapping, which is what the
   product rail directly beneath it already does. Two adjacent rails behaving
   differently was the thing to fix: the chips stacked into a column of six
   full width buttons on a phone, and split three and three at tablet, while the
   cards under them swiped.

   Same idiom as `.products__rail`, down to the negative margin that lets the
   track bleed to the viewport edge while the first chip still lines up with the
   page gutter. One primitive, two rails.

   The cost is that chips five and six sit off screen until the rail is swiped.
   That is acceptable here and was not acceptable for the cards: every category
   is also in the header's Shop menu and in the footer, so nothing is reachable
   only by swiping. */
@media (max-width: 1023px) {
  .chips__rail {
    display: flex;
    flex-wrap: nowrap;
    gap: var(--s-12);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-padding-inline-start: var(--gutter);
    padding-bottom: var(--s-8);
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
    scrollbar-width: thin;
  }
  .chips__rail > li { flex: none; scroll-snap-align: start; }
}

@media (max-width: 768px) {
  .chips__rail--inline { margin-bottom: var(--s-24); }
}

/* The colour change carries the whole state when motion is off. */
@media (prefers-reduced-motion: reduce) {
  .chip:hover, .chip.is-hover, .chip:focus-visible, .chip.is-focus,
  .chip:active, .chip.is-active { transform: none; }
  .chip:hover .chip__thumb img,
  .chip.is-hover .chip__thumb img,
  .chip:focus-visible .chip__thumb img,
  .chip.is-focus .chip__thumb img { transform: none; }
}

/* --- 12. Section heading --------------------------------------------------- */

/* The 44ch measure belongs to the prose under the headline, not to the block.
   Sitting on the block it capped the headline at 444px inside a 1216px shell,
   so a five word title broke over two lines with two thirds of the row empty
   beside it. The headline now runs to whatever its container gives it and the
   body copy keeps its measure.

   No `nowrap` here on purpose. These fit on one line because they have room,
   not because they are forbidden from wrapping, so a longer headline later
   wraps instead of running out of the section. */
.head { display: grid; gap: var(--s-16); margin-bottom: var(--s-48); }
.head > p { max-width: 44ch; }
/* The heading is already inside a column that owns the gap below it. */
.head--flush { margin-bottom: 0; }
.head__row {
  display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: var(--s-24);
  margin-bottom: var(--s-48);
}
.head__row .head { flex: 1 1 auto; margin-bottom: 0; }
/* The link holds its size, and the row wraps rather than squeezing the headline.
   When a headline needs the whole row, the link drops below it: something has to
   give and it is not the title. */
.head__row > :not(.head) { flex: none; }

/* A section heading inside a split layout. Lifting the 44ch cap does nothing for
   those, because the constraint is the column: `stone` and `visit` gave their
   headings 5 of 12 columns, 493px, and a 56px headline cannot sit on one line in
   that. So the heading leaves the column and spans the grid, and the split
   starts underneath it. The row gap the grid already declares is the space. */
.section__head { grid-column: 1 / -1; }
/* The default row hangs its link off the baseline of the last line, which is
   right when the heading is two lines and the link is the section's action. On
   a three line heading it strands the link in the middle of the block, so this
   variant sets it against the first line instead. */
.head__row--top { align-items: flex-start; }
@media (max-width: 768px) {
  .head { margin-bottom: var(--s-32); }
  .head__row { flex-direction: column; align-items: flex-start; margin-bottom: var(--s-32); }
}

/* --- 13. Product cards ----------------------------------------------------- */

/* One rail, one behaviour, every width. It was a 5 column grid at desktop and a
   swipe track below 1023, which meant ten products read as two rows of five on a
   laptop and as a carousel on a phone. A grid also implies completeness, and this
   section is a selection with a "browse all pieces" link beside its heading.

   The card basis shows **4.5 cards**, so half a card is always cut by the right
   edge. That is the whole scroll affordance at desktop, where there is no swipe
   to discover: a card that is visibly interrupted says the row continues, which a
   row of five flush cards does not. The same reasoning gives the phone its 66%.

   The percentage in the calc resolves against the rail's content box, which is
   the shell's 1216 rather than the 1280 its negative margin bleeds to, so the
   figure is the one that matters: (1216 - 96) / 4.5 = 249px a card. */
.products__rail {
  display: flex;
  gap: var(--grid-gap);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline-start: var(--gutter);
  padding-bottom: var(--s-16);
  margin-inline: calc(var(--gutter) * -1);
  padding-inline: var(--gutter);
  /* No scrollbar. It drew a full width rule under the cards and read as a
     border on the section rather than as a control. What says the rail scrolls
     is the half card at the end: the flex basis is (100% - gaps) / 4.5, so a
     fifth card is always cut by the right edge, which is a stronger affordance
     than a 10px bar and does not cost a line of chrome. Nothing else changes:
     the rail still scrolls, still snaps, and still takes the keyboard.
     -ms-overflow-style is deliberately not here; it only ever served IE. */
  scrollbar-width: none;
}
.products__rail::-webkit-scrollbar { display: none; }
.products__rail .card {
  flex: 0 0 calc((100% - var(--grid-gap) * 4) / 4.5);
  scroll-snap-align: start;
}
/* Cards past the first few sit outside the viewport horizontally at every width
   now, so a per card reveal would leave them blank until the rail is scrolled.
   The rail carries the .reveal and arrives as one block; a card never hides. */
.js .products__rail .reveal { opacity: 1; transform: none; }

/* The rail is flex, so every card is stretched to the tallest one. align-content
   start keeps each card's own content packed at its top, or the extra height is
   shared out between the rows inside a short card and its title stops sharing a
   baseline with its neighbours. The two cards carrying an availability line are
   what make the heights differ. */
.card { display: grid; gap: var(--s-12); align-content: start; position: relative; }
.card__frame {
  position: relative;
  overflow: hidden;
  background: var(--color-background);
  border-radius: var(--card-border-radius);
  aspect-ratio: 4 / 5;
}
.card__frame img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.card__img-alt { opacity: 0; transition: opacity var(--duration-long) var(--ease-default); }
.card:hover .card__img-alt,
.card.is-hover .card__img-alt { opacity: 1; }

.card__wish {
  position: absolute; top: var(--s-4); right: var(--s-4); z-index: 2;
  background: var(--color-background);
  border-radius: 999px;
}
.card__wish[aria-pressed='true'] { color: var(--color-accent); }

.card__quick {
  position: absolute; left: var(--s-8); right: var(--s-8); bottom: var(--s-8); z-index: 2;
  min-height: 44px; padding: var(--s-12);
  background: var(--color-foreground); color: var(--color-background);
  border: 0; border-radius: var(--button-border-radius);
  font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase;
  transform: translateY(calc(100% + var(--s-8)));
  opacity: 0;
  transition: transform var(--duration-default) var(--ease-reveal), opacity var(--duration-default) var(--ease-default);
}
.card:hover .card__quick,
.card.is-hover .card__quick,
.card:focus-within .card__quick { transform: none; opacity: 1; }
.card__quick[disabled] { background: var(--color-secondary); opacity: 0.6; }
/* The enquiry variant is a link, so it needs the centring a button gets free. */
a.card__quick { display: block; text-align: center; text-decoration: none; }
/* And it has to be told to stay the colour it already is. The page's global
   a:hover sets the deep Rust, and at (0,1,1) that outranks .card__quick at
   (0,1,0), so the label turned Rust on Ink the moment the card was hovered:
   measured rgb(131,48,15) on a #1A1A1A ground, which is 1.7:1. Every link
   state is pinned rather than just :hover, because :visited and :active reach
   the same rule. This is the component fix, so it holds for any card whose
   action is Enquire rather than Add, wherever that card is used. The sibling
   button variant needs none of this: a <button> never matched a:hover. */
a.card__quick:link,
a.card__quick:visited,
a.card__quick:hover,
a.card__quick:focus,
a.card__quick:focus-visible,
a.card__quick:active { color: var(--color-background); }

.card__title {
  font-family: var(--font-heading-family);
  font-size: var(--type-headline-sm);
  min-height: 2.5em;    /* two lines reserved, so the rail keeps its rows */
}
.card__title a { color: inherit; text-decoration: none; }
/* The whole card is the link. This resolves against .card, the only positioned
   ancestor, so nothing between here and there may be positioned or the target
   collapses to the title. That is why the rule below lives on a span. */
.card__title a::after { content: ''; position: absolute; inset: 0; }

/* The title rule is the shared .underline utility, see "The drawn rule" up with the
   link components, which carries the recipe, the measurement that ruled out
   inline-block, and every hover trigger on the page. The span here takes
   class="rule"; nothing card-specific is left to say. */
.card__meta { font-size: var(--type-body-sm); color: var(--color-text-muted); }
.card__price { display: flex; align-items: baseline; gap: var(--s-8); }

/* Metal options. The category chip's pill at card scale, without the arrow:
   a chip navigates somewhere, this one chooses something, so it keeps the
   shape and drops the affordance that means "onward".

   The colour is a dot rather than the whole shape, which is the real gain over
   the squares this replaces. Those said the metal in colour alone, and three
   warm metals at 20px are not reliably tellable apart. Now the name carries it
   and the dot confirms it. */
.swatches { display: flex; flex-wrap: wrap; gap: var(--s-8); }

.swatch {
  /* Positioned, and it has to stay that way. .card__title a::after covers the
     whole card, and only a positioned sibling later in the DOM paints above it.
     Static here and the pills silently stop being clickable. */
  position: relative;
  display: inline-flex; align-items: center; gap: var(--s-8);
  min-height: 32px;
  padding: var(--s-4) var(--s-12) var(--s-4) var(--s-4);
  background: var(--color-surface-raised);
  color: var(--color-foreground);
  border: 1px solid var(--color-border-hairline);
  border-radius: var(--chip-border-radius);
  font-size: var(--type-body-sm);
  text-align: left;
  transition: border-color var(--duration-default) var(--ease-default),
              box-shadow var(--duration-default) var(--ease-default);
}
.swatch__dot {
  flex: none;
  width: 20px; height: 20px;
  border-radius: var(--chip-border-radius);
  /* White gold sits a shade off the pill's own fill, so every dot takes an
     edge rather than only the pale one, which would read as a bug. */
  border: 1px solid var(--color-border-hairline);
}
.swatch--yellow .swatch__dot { background: var(--metal-yellow); }
.swatch--rose   .swatch__dot { background: var(--metal-rose); }
.swatch--white  .swatch__dot { background: var(--metal-white); }

.swatch:hover, .swatch.is-hover { border-color: var(--color-accent); }
/* Chosen. The Ink rule is doubled with an inset shadow rather than a 2px
   border, so picking a metal does not change the pill's size. */
.swatch[aria-pressed='true'] {
  border-color: var(--color-foreground);
  box-shadow: inset 0 0 0 1px var(--color-foreground);
}

@media (max-width: 768px) {
  /* CLIENT-BRIEF.md 7.2 item 18 asks for a 44 tap target on a phone. */
  .swatch { min-height: 44px; padding-inline: var(--s-8) var(--s-16); }
}

.card[data-state='unavailable'] .card__frame img { filter: grayscale(0.35); }
.card__note { font-size: var(--type-body-sm); color: var(--color-text-muted); }
.card__note--enquiry { color: var(--color-accent); }

/* The rail itself is unchanged below 1023 now: only the card's share of it and
   the gap move, because a 249px card on a phone would be four to a screen. */
@media (max-width: 1023px) {
  .products__rail { gap: var(--s-16); }
  .products__rail .card { flex: 0 0 66%; }
  /* Hover does not exist here, so quick-add cannot be the thing it reveals. */
  .card__quick { transform: none; opacity: 1; }
}
@media (max-width: 560px) { .products__rail .card { flex: 0 0 78%; } }

/* --- 13b. Featured pieces ---------------------------------------------------
   Four products, cut out, standing on the ground rather than inside a card,
   each with the disclosure the brand promises set out as a table beside it.
   This is the "itemised pricing" claim shown instead of described.

   The slides stack in one grid cell rather than being absolutely positioned,
   so the deck is always as tall as its tallest slide and the page does not
   resize as the carousel turns.                                             */

.piece {
  position: relative; overflow: hidden;
  --piece-dir: 1;   /* 1 = the next piece arrives from the right, -1 = left */

  /* Declared on the band, not on the stage, because the control rail needs to
     read it too: stacked, the rail sits in a gap under the picture and finds
     that line by stepping down exactly one stage height. */
  --stage-height: 560px;

  /* The ground changes with the piece. JS copies the active slide's data-ground
     onto this property and the band cross fades to it over the same duration as
     the slide, so the colour turn and the product turn are one movement. */
  --piece-ground: var(--ground-umber);
  background: var(--piece-ground);
  color: var(--on-ground);
  transition: background-color var(--duration-slide) var(--ease-default);

  /* Scoped palette. Declared once here so every child, including components
     shared with the light page, inherits the dark band without its own rule. */
  --color-text-heading:    var(--on-ground);
  --color-text-body:       var(--on-ground);
  --color-text-muted:      var(--on-ground-mute);
  --color-foreground:      var(--on-ground);
  --color-border-hairline: var(--on-ground-rule);
}
/* Champagne Beige is a light-band colour and would shout on a dark one. On this
   band the watermark is the ground's own type colour at 8%. */
.piece .ghost {
  top: 12%;
  color: color-mix(in srgb, var(--on-ground) 8%, transparent);
}

/* Horizontal gestures belong to the deck, vertical ones still scroll the page.
   Without this the browser claims the drag for scrolling and the swipe never
   completes on a phone. The image is also told not to be draggable, because a
   half finished text selection or a ghost image under the cursor is what a
   drag across a picture gives you otherwise. The script refuses dragstart as
   well; this is the half that stops the selection. */
.piece__deck { display: grid; touch-action: pan-y; }
.piece__stage img { -webkit-user-drag: none; user-select: none; }

/* The wrapper only decides what is painted. All the fading and travelling is
   done by the three planes inside, so the two cannot fight over opacity. */
.piece__slide {
  grid-area: 1 / 1;
  position: relative;
  opacity: 0;
  visibility: hidden;
}
.piece__slide.is-active,
.piece__slide.is-leaving { opacity: 1; visibility: visible; }
.piece__slide.is-active { z-index: 1; }   /* the arriving piece passes in front */

.piece__grid {
  position: relative; z-index: 1;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  gap: var(--s-32) var(--grid-gap);
  align-items: center;
}
.piece__stage {
  grid-column: 2 / span 5;
  display: grid; justify-items: center; align-items: end;
  /* One box for every piece, and the cut out stands on its floor. The height
     belongs to the stage rather than to the image because the pieces are not
     the same shape: a hand is narrow and tall, a display bust is wide and
     short. Sized by the image, the deck would take the tallest slide and the
     shorter ones would carry the difference as an empty band underneath.

     The cap is repeated on the image through the same custom property rather
     than set as max-height: 100%. A percentage there resolves against the grid
     area, which is a stretched auto track and so not definite enough for
     Chrome to use, and the cut out renders at full size. */
  height: var(--stage-height);
}
.piece__stage img {
  width: auto; height: auto; max-width: 100%; max-height: var(--stage-height);
  /* The cut out ends where the photograph ended. Fading the last few percent
     stops that hard horizontal edge reading as a mistake. */
  -webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 88%, transparent 100%);
}
.piece__copy { grid-column: 8 / span 4; display: grid; gap: var(--s-24); }

.disclosure { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border-top: 1px solid var(--color-border-hairline); }
.disclosure__row { padding-block: var(--s-16); border-bottom: 1px solid var(--color-border-hairline); }
.disclosure__row:nth-child(odd) { padding-inline-end: var(--s-16); }
.disclosure__row:nth-child(even) { padding-inline-start: var(--s-16); border-left: 1px solid var(--color-border-hairline); }
.disclosure__key {
  display: block;
  font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--color-text-muted);
  margin-bottom: var(--s-4);
}
.disclosure__val { font-size: var(--type-body-md); font-variant-numeric: tabular-nums; }
.disclosure__row--total .disclosure__val { font-family: var(--font-heading-family); font-size: var(--type-headline-sm); }

/* Controls. Centred on the band at every width. They used to sit on the copy
   column's left edge, under the CTA, which read as a fifth item belonging to
   the copy rather than as the control for the whole deck. Centred they belong
   to the section, which is what they steer, and desktop and phone now say the
   same thing in the same place. Hidden without JS, because without JS there is
   one slide and nothing to steer.                                           */

.piece__navwrap {
  display: none;
  grid-template-columns: repeat(12, 1fr);
  gap: 0 var(--grid-gap);
  margin-top: var(--s-32);
}
.js .piece__navwrap { display: grid; }

.piece__nav {
  grid-column: 1 / -1;
  display: flex; align-items: center; justify-content: center; gap: var(--s-8);
}
/* Controls hold their size. Left to shrink, they go under the 44 tap target on
   a narrow phone and the dwell ring stops being concentric with its dot. */
.piece__nav > * { flex: none; }
/* The arrows are the page's icon-btn untouched, 44 square with a 22px glyph, so
   the rail keeps the header's control rhythm. Only the colour changes: Rust
   measures 1.68:1 on these grounds and would read as a dead control, so on this
   band the resting state is the muted type colour and the hover is full cream. */
.piece .icon-btn { color: var(--on-ground-mute); }
.piece .icon-btn:hover, .piece .icon-btn.is-hover { color: var(--on-ground); }

.piece__dots { display: flex; align-items: center; }

.piece__dot {
  position: relative;
  width: 44px; height: 44px; padding: 0;
  background: none; border: 0;
  display: inline-flex; align-items: center; justify-content: center;
  transition: transform var(--duration-default) var(--ease-default);
}
/* The dot itself. Full cream once current, cream at 45% until then. On the old
   Soft Ivory band this pair was Ink and Rust; on a dark ground Rust falls to
   1.68:1 and disappears, so the band says "current" with brightness instead.
   The resting dot measures 4.6:1 and the current one 13.4:1 against the darkest
   of the four grounds, both clear of the 3:1 WCAG 1.4.11 asks of a control you
   have to find. The accent has not left the band: it is on the CTA. */
.piece__dot::after {
  content: '';
  width: 10px; height: 10px; border-radius: var(--chip-border-radius);
  background: var(--on-ground-dot);
  transition: background-color var(--duration-default) var(--ease-default);
}
.piece__dot:hover::after,
.piece__dot.is-hover::after { background: var(--on-ground); }
.piece__dot[aria-current='true']::after { background: var(--on-ground); }
.piece__dot:active { transform: scale(0.98); }

/* The dwell ring. Drawn outside the dot, running clockwise from twelve. Only
   the current dot carries one, so moving aria-current restarts the animation
   from zero with no JS reset. */
.piece__ring {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  transform: rotate(-90deg);
  opacity: 0;
  fill: none; stroke-width: 1.5;
}
.piece__dot[aria-current='true'] .piece__ring { opacity: 1; }
/* The track is the socket the arc fills, decoration for a timer rather than the
   thing that identifies the control, so it sits below 3:1 on purpose. What says
   "this one is current" is the cream dot at 13.4:1 and the ring being there at
   all, neither of which is carried by colour alone. */
.piece__ring-track { stroke: var(--on-ground-rule); }
.piece__ring-fill {
  stroke: var(--on-ground);
  stroke-linecap: round;
  /* 2 pi r, r = 13. The whole length is offset, so the ring starts empty. */
  stroke-dasharray: 81.681;
  stroke-dashoffset: 81.681;
}

@media (prefers-reduced-motion: no-preference) {
  /* The ring is the timer. Its animationend is what advances the deck, so the
     indicator can never disagree with the rotation, and stopping is a single
     play state rather than a second clock to keep in step. */
  .piece__dot[aria-current='true'] .piece__ring-fill {
    animation: piece-dwell var(--duration-dwell) linear forwards;
  }
  .piece[data-rolling='false'] .piece__ring-fill { animation-play-state: paused; }

  /* Parallax, both ways. Three planes, each travelling a different distance over
     the same time: the watermark least, the copy next, the product most, so the
     section reads as depth rather than as one block sliding.

     Distances are double what they were (32 / 64 / 96 against 16 / 32 / 48) and
     the time is 1100ms against 700. The old pair was far enough and quick enough
     that the movement was finished before the eye arrived, and the turn read as
     a cut. The section has overflow hidden, so 96px of travel costs no scrollbar.

     The outgoing piece now leaves rather than vanishing. It drifts the opposite
     way over the same duration, which is what makes the turn read as one
     movement instead of two unrelated ones. */
  .js .piece:not(.is-armed) .ghost,
  .js .piece:not(.is-armed) .piece__stage,
  .js .piece:not(.is-armed) .piece__copy { opacity: 0; }

  .piece.is-armed .piece__slide.is-active .ghost {
    --piece-dx: var(--s-32);
    animation: piece-arrive var(--duration-slide) var(--ease-reveal) both;
  }
  .piece.is-armed .piece__slide.is-active .piece__copy {
    --piece-dx: var(--s-64);
    animation: piece-arrive var(--duration-slide) var(--ease-reveal) 90ms both;
  }
  .piece.is-armed .piece__slide.is-active .piece__stage {
    --piece-dx: var(--s-96);
    animation: piece-arrive var(--duration-slide) var(--ease-reveal) 50ms both;
  }

  .piece.is-armed .piece__slide.is-leaving .ghost {
    --piece-dx: var(--s-32);
    animation: piece-leave var(--duration-slide) var(--ease-default) both;
  }
  .piece.is-armed .piece__slide.is-leaving .piece__copy {
    --piece-dx: var(--s-64);
    animation: piece-leave var(--duration-slide) var(--ease-default) both;
  }
  .piece.is-armed .piece__slide.is-leaving .piece__stage {
    --piece-dx: var(--s-96);
    animation: piece-leave var(--duration-slide) var(--ease-default) both;
  }
}

@keyframes piece-dwell { to { stroke-dashoffset: 0; } }

@keyframes piece-arrive {
  from { opacity: 0; transform: translate3d(calc(var(--piece-dx) * var(--piece-dir)), 0, 0); }
  to   { opacity: 1; transform: none; }
}

/* Clear of the frame by 55% of the time, still drifting to the end. Holding the
   old piece at any opacity while the new one fades in muddies both. */
@keyframes piece-leave {
  from { opacity: 1; transform: none; }
  55%  { opacity: 0; }
  to   { opacity: 0; transform: translate3d(calc(var(--piece-dx) * var(--piece-dir) * -1), 0, 0); }
}

/* Reduced motion keeps the carousel, drops the rotation. Nothing turns on its
   own, so the dwell ring has nothing to count and goes. The band still changes
   colour with the piece, because that is the ground and not an animation. */
@media (prefers-reduced-motion: reduce) {
  .piece__ring { display: none; }
  .piece { transition: none; }
}

@media (max-width: 1023px) {
  .piece__stage { grid-column: 1 / span 12; }
  .piece__copy  { grid-column: 1 / span 12; }
  .piece .ghost { top: 4%; }

  /* Stacked, the rail moves out from under the button and sits directly under
     the picture, centred. Under the button it was the last thing on a very tall
     slide, so on a phone you steered the deck from a control you had to scroll
     past the whole disclosure to reach.

     It rides in a gap the slide reserves for it rather than being taken out of
     flow: the deck and the rail share one grid cell, and the rail steps down
     exactly one stage height to land on the picture's bottom edge. The gap is
     the rail plus one 24 either side, so the two can never collide. */
  .piece { display: grid; }
  .piece__deck { grid-area: 1 / 1; }
  .piece__grid { row-gap: calc(var(--s-24) * 2 + 44px); }
  .js .piece__navwrap {
    grid-area: 1 / 1;
    align-self: start;
    display: block;
    margin-top: calc(var(--stage-height) + var(--s-24));
    /* The rail and the deck share one grid cell, and .piece__grid inside the
       deck carries z-index 1. The rail was painted underneath it: visible,
       because it rides in a row-gap where the grid has no content, but not
       hittable, because the grid's box still covered it. elementFromPoint at
       the centre of the next arrow returned .piece__grid, not the button. It
       has to stack above the thing it shares a cell with. */
    position: relative;
    z-index: 2;
  }
}

@media (max-width: 560px) {
  /* Two columns held, not collapsed to one. Six stacked rows ran 498px, which
     put the price and the button below the fold on every phone; three rows of
     two run about 200 and keep the piece, its disclosure and its button in one
     viewport, which is the whole job of the section. The value steps down one
     level and the padding tightens to buy back the width the second column
     costs: "BIS, six digit HUID" is the longest string and it has to hold one
     line in half a 320px screen. The total keeps Marcellus at 20, because the
     face has no size below that. */
  .disclosure__row { padding-block: var(--s-12); }
  .disclosure__row:nth-child(odd) { padding-inline-end: var(--s-12); }
  .disclosure__row:nth-child(even) { padding-inline-start: var(--s-12); }
  .disclosure__val { font-size: var(--type-body-sm); }

  .piece__copy { gap: var(--s-12); }
  /* A 560 stage is over half a phone screen before the name is even read, and
     the rail now costs a further 88 between it and the copy. */
  .piece { --stage-height: 300px; }
  .piece__grid { row-gap: calc(var(--s-12) * 2 + 36px); }
  .js .piece__navwrap { margin-top: calc(var(--stage-height) + var(--s-12)); }
  /* Six controls at 44 need 288 and a 320 phone has 288 to give, with nothing
     left for the gaps. At 36 the rail fits with room to spare and still clears
     the 24px WCAG 2.5.8 minimum. */
  .piece__nav { gap: var(--s-4); }
  .piece__arrow, .piece__dot { width: 36px; height: 36px; }
}

/* --- 13c. The range, a scatter around centred copy ---------------------------
   The page's one centred moment, and the only place type sits on the middle
   line. It earns that by being the hinge: the deck above shows four pieces in
   full detail, the grid below shows the catalogue, and this says the standard
   is the same across both. Centred and unaligned is the right shape for a
   statement that belongs to no column.

   Five tiles ringed around the copy rather than set in a grid. They are
   deliberately uneven in size and position and they do not share an axis; a
   grid of five would read as a product row, which is the section directly
   below. The composition leaves the top right and bottom left empty on
   purpose, so the ring is a scatter and not a frame.                        */

.range__stage {
  position: relative;
  min-height: 700px;
  display: grid; align-content: center; justify-items: center;
}

/* The brand motif, sitting behind the copy as a watermark.

   assets/img/motif.svg is the single mark AS SUPPLIED by the client, 326 x 285,
   four paths, installed verbatim. It is not cut from pattern.svg: the strip is
   a band of rosettes that interlock through a shared lens, so a rosette taken
   out of it is missing the closing curves where its neighbours met it. The
   supplied file is the closed form and is the correct mark.

   A pseudo-element rather than an <img>, so it is out of the accessibility
   tree by construction and nobody has to remember an aria-hidden. It sits
   under .range__copy, which already holds z-index 1, and under the tiles,
   which are positioned. Nothing here is interactive, so pointer-events are
   off as well.

   Sized off the viewport rather than off the copy: it is the ground the
   statement stands on, and it should breathe wider than the text does. */
.range__copy::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  /* Behind the copy's own text, inside the stacking context .range__copy
     already opens with its z-index 1, so it cannot escape backwards past the
     section ground. On the stage instead of the copy it centred on the whole
     stack, which is right on desktop, where the copy is the centre of the
     scatter, and wrong on a phone, where the stack is copy plus four tiles and
     the mark drifted down under the link. */
  z-index: -1;
  width: clamp(280px, 34vw, 460px);
  aspect-ratio: 326 / 285;          /* the supplied motif's own viewBox */
  transform: translate(-50%, -50%);
  background: url('../img/motif.svg') center / contain no-repeat;
  /* Rose Wood at 12% on Atelier Cream. Enough that the mark reads as a shape
     when looked at, little enough that the Ink headline over it loses nothing
     measurable. */
  opacity: 0.12;
  pointer-events: none;
}

.range__copy {
  position: relative; z-index: 1;
  display: grid; gap: var(--s-24); justify-items: center;
  max-width: min(560px, 46%);
  text-align: center;
}
.range__copy p { max-width: 46ch; }

/* The row is a box only once the scatter collapses. On desktop it steps out of
   the way so the tiles position against the stage, which lets the copy come
   first in the DOM and still be the thing under the tiles on a phone. */
.range__row { display: contents; }

.range__tile { position: absolute; margin: 0; }
.range__tile img {
  width: 100%; height: auto;
  border-radius: var(--card-border-radius);
}

/* Widths and insets are percentages of the shell, tops percentages of the
   stage, so the scatter holds its proportions instead of crowding the copy as
   the window narrows.

   Two tiles sit inside the copy's horizontal band and clear it vertically
   instead: that is what stops the ring reading as a frame around the type. The
   copy occupies roughly 29 to 71 percent of the stage height and 28 to 72 of
   its width, so the rule is that a tile clears the copy on one axis, never on
   both, and never on neither. Change a top or a left here and check the
   bottom of the tile against 29 and 71 before shipping it. */
.range__tile--1 { width: 11.5%; left: 22%;  top: 1%; }    /* clears above */
.range__tile--2 { width: 12.5%; left: 0;    top: 36%; }   /* clears left */
.range__tile--3 { width: 18.5%; right: 0;   top: 10%; }   /* clears right */
.range__tile--4 { width: 16%;   left: 19%;  top: 74%; }   /* clears below */
.range__tile--5 { width: 13.5%; right: 7%;  top: 64%; }   /* clears right */

/* Below 1100 the scatter stops working: the copy and the tiles are both
   fighting for the middle of a narrower shell and the tiles start landing on
   the type. The ring opens out, but it does not stop being a ring. The copy
   stays in the middle of the run with tiles above it and tiles below it, which
   is the portrait reading of the desktop composition. Dropping every tile
   below the copy, which is what this did, left the statement sitting on top of
   a slab and the section bottom heavy.

   The row stops being a box here exactly as it does on the desktop. It is
   `display: contents` in every band, so the stage is the only grid and the
   copy and the five tiles are all its children. That is what lets a tile be
   placed above the copy while staying after it in the DOM, so a screen reader
   and a stylesheet-less render both still get the statement first. Nothing in
   a tile is focusable, so there is no tab order to disagree with.

   The run keeps the scatter's rules rather than its geometry. Every tile takes
   its own width, no two in a band share a bottom edge, and the copy spanning
   the full width is what breaks the columns. Each tile is placed by hand, the
   same preset model the desktop uses, so reordering blocks cannot land one on
   another.                                                                   */

/* --- Tablet, 640 to 1100. Three unequal columns, two tiles above. --------- */
@media (max-width: 1100px) {
  /* Stacked, the copy is narrower and the mark comes in with it. */
  .range__copy::before { width: clamp(220px, 52vw, 320px); }

  .range__stage {
    min-height: 0;
    display: grid;
    grid-template-columns: 1.2fr 1fr 0.88fr;
    column-gap: var(--s-24);
    row-gap: var(--s-24);
    align-content: start; align-items: start; justify-items: stretch;
    /* Capped, or the tiles keep growing with the shell and arrive at 1100
       wider than anything on the desktop scatter. Centred, because this is
       the page's centred composition. */
    max-width: var(--page-width-narrow); margin-inline: auto;
  }
  .range__row { display: contents; }
  .range__tile { position: static; width: 100%; }

  .range__copy {
    grid-column: 1 / -1; grid-row: 2;
    max-width: 46ch; margin-inline: auto;
    /* With the 24 row gap either side this is the 64 the stage used to carry
       between the copy and the tiles. */
    margin-block: var(--s-32);
  }

  /* Above the copy. The middle column is left empty, so the two tiles read as
     shoulders around the heading rather than as the top row of a grid. */
  .range__tile--4 { grid-column: 1; grid-row: 1; margin-block-start: 0; }
  .range__tile--2 { grid-column: 3; grid-row: 1; margin-block-start: var(--s-32); }

  /* Below it. Three tiles, three columns, three different drops, so the band
     has no top line and no bottom line. */
  .range__tile--1 { grid-column: 1; grid-row: 3; margin-block-start: var(--s-32); }
  .range__tile--3 { grid-column: 2; grid-row: 3; margin-block-start: 0; }
  .range__tile--5 {
    grid-column: 3; grid-row: 3;
    width: 88%; margin-inline-start: auto;
    margin-block-start: var(--s-64);
  }
}

/* --- Phone, 639 and below. Two unequal columns, two tiles above. ---------- */
/* Two columns rather than three, because three at this width gave a 105px
   thumbnail and a bangle at 105 is a smudge. At 390 these measure 189 and 153.

   Two above the copy, two beside it below, and the crimson bangle last, out of
   the columns and on the centre line at 78 percent. It is the widest of the
   five on the desktop scatter too, and it closes the run the way the heading
   opens it.                                                                  */
@media (max-width: 639px) {
  .range__stage {
    grid-template-columns: 1.24fr 1fr;
    column-gap: var(--s-16);
    /* Percentage tiles in a two column run grow with the viewport, and by 639
       they were 400 wide inside a 1773 tall section. The cap holds them at the
       size they take on a 430 phone and centres the run above that. */
    max-width: 400px;
  }

  .range__copy { grid-row: 2; margin-block: var(--s-24); }

  /* Above. Landscape left and short, portrait right and dropped, so the pair
     has neither a top line nor a bottom line and the heading is not buried. */
  .range__tile--4 { grid-column: 1; grid-row: 1; margin-block-start: 0; }
  .range__tile--2 { grid-column: 2; grid-row: 1; margin-block-start: var(--s-32); }

  /* Below. The drops anchor the left at full height, the earrings hang 96 low
     on the right. At 48 the two bottoms landed 15px apart, which reads as a
     botched alignment rather than as a scatter. */
  .range__tile--1 { grid-column: 1; grid-row: 3; margin-block-start: 0; }
  .range__tile--5 {
    grid-column: 2; grid-row: 3;
    width: 100%; margin-inline: 0;
    margin-block-start: var(--s-96);
  }

  /* Out of the columns, on the centre line. Width is a share of the whole run
     rather than of a track, so it clears both column edges. */
  .range__tile--3 {
    grid-column: 1 / -1; grid-row: 4;
    width: 78%; margin-inline: auto;
    margin-block-start: 0;
  }
}

/* --- 14. Stone section (the one WebGL moment) ------------------------------ */

/* The stone is in the middle and the four proofs are annotations ringing it,
   two either side, each on a rule that runs out of its own block and points
   back at the stone. That is the section's argument drawn rather than listed:
   a third party pointing at the piece. It replaces a headline with two
   unrelated objects parked under it and an empty band between them.

   Three things carry it, and all three are load bearing:
   the faceted ground, so the band is a cut surface and not paint;
   the loupe light, so the stone is lit rather than pasted on;
   and the leader rules, which are the only reason four labels read as one
   diagram instead of as two more lists.                                     */

.stone {
  position: relative; overflow: hidden;
  /* Cream and Ink at 1 to 6 percent over the band's own ground, so the field
     carries no colour of its own and survives a change of ground token.
     Generated from a fixed seed by scripts/make-facets.py. */
  background-image: url('../img/facets.svg');
  /* cover, not 100% 100%. The field is written at 16:9 and the band is 1:3 on
     a phone; stretched to fit, the facets shear into vertical streaks and stop
     reading as planes. Cover keeps them facet shaped and crops instead, which
     costs nothing because the field is abstract and has no subject to lose. */
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
/* Cover solved the shearing and then failed the other way round. On a
   375 x 1159 phone band it scales the 16:9 field to about 2060px wide just to
   reach the height, so roughly 18% of the field is shown at 5x: the facets
   stop being a surface and read as three or four flat blotches across the
   whole section. No background-size setting fixes that, because the field is
   simply the wrong shape for the box. The portrait field is the same lattice
   re-tuned at 900 x 1600, which lands facets at about 90px here instead of
   500px. Swapped at the width where the band goes single column, so the
   layout and its ground change shape together. */
@media (max-width: 1023px) {
  .stone { background-image: url('../img/facets-tall.svg'); }
}
.stone__grid { position: relative; z-index: 1; }

/* The head is centred here and nowhere else on the page bar the range. The
   ring below it is symmetrical by construction, two proofs, the stone, two
   proofs, so a left aligned heading over it was the thing out of step rather
   than the centring. Scoped to the section: `.head` is shared and every other
   one on the page stays left aligned. `justify-items` centres the 44ch measure
   as a box, `text-align` centres the lines inside it. */
.stone .section__head { justify-items: center; text-align: center; }
/* Centred type is far less forgiving of a bad rag than left aligned type is.
   At the shared 44ch measure this deck broke with "about it." alone on the
   second line, which reads as a mistake under a centred heading. `balance`
   evens the two lines at every width and keeps doing so if the copy changes,
   which a hand tuned `ch` measure would not. */
.stone .section__head > * { text-wrap: balance; }

/* The ring. Two annotation columns with the stage between them. */
.stone__ring {
  display: grid;
  /* The stage takes a share of the window rather than a fixed width. At a flat
     480 the annotation columns fell to 192px at 1024, which is not enough for a
     headline-md name and a line of body under it, and the ring collapsed into
     two cramped gutters either side of a large stone. */
  grid-template-columns: 1fr minmax(0, clamp(300px, 34vw, 520px)) 1fr;
  align-items: center;
  gap: var(--s-32);
  margin-top: var(--s-48);
}
/* Transparent on desktop, so the two columns stay direct children of the ring
   and the geometry below is untouched. It becomes a real box, and the scroll
   rail, only below 560. */
.cert__set { display: contents; }
.stone__col { display: grid; gap: var(--s-96); }
.stone__col--l { text-align: right; }
/* Placed by column AND by row, not by source order. The stone is first in the
   source so that the four proofs read as one run, and the column alone is not
   enough to survive that: placing the stage in column 2 leaves the auto
   placement cursor at row 1 column 3, the left column then asks for column 1,
   which is behind the cursor, and sparse packing never goes backwards. It
   dropped to a second row and took the right column with it, so the ring came
   apart into a stone with two columns parked underneath. The explicit row is
   what pins all three to the ring's single row. Cleared again below 1023,
   where the ring is one track and the three are meant to stack. */
.stone__col--l { grid-column: 1; grid-row: 1; }
.stone__stage  { grid-column: 2; grid-row: 1; }
.stone__col--r { grid-column: 3; grid-row: 1; }

.stone__stage { position: relative; }
/* The loupe light. Without it the stone sits on the facets rather than in
   them, and the facets read as a texture behind a cut out. */
.stone__stage::before {
  content: ''; position: absolute; inset: -20%;
  background: radial-gradient(circle at 50% 46%,
              rgb(250 246 242 / 0.12), rgb(250 246 242 / 0.035) 42%, transparent 68%);
  pointer-events: none;
}

.stone__viewport {
  position: relative;
  aspect-ratio: 1 / 1;
  border-radius: var(--card-border-radius);
  overflow: hidden;
  /* Transparent, not the ground colour: the facets and the loupe light both
     have to show through, and the stage has no edge of its own either way. */
  background: transparent;
}
.stone__poster { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.stone__canvas {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0;
  transition: opacity var(--duration-long) var(--ease-default);
  touch-action: pan-y;
}
.stone__viewport[data-live='true'] .stone__canvas { opacity: 1; }
.stone__viewport[data-live='true'] .stone__poster { opacity: 0; transition: opacity var(--duration-long) var(--ease-default); }


/* Each proof is a block under its own rule, not a row in a table. The old
   version was the hairline-under-every-row spec sheet, which is the flattest
   way there is to present four things; this gives each one a heading at
   headline-md and room to be read on its own. */
.cert__item {
  position: relative;
  display: grid; gap: var(--s-8);
  padding-top: var(--s-16);
}
/* The rule is also the leader. It runs the width of the block and then keeps
   going, out of the block and toward the stone, so one line does both jobs:
   it heads the proof and it points at what the proof is about. */
.cert__item::before {
  content: ''; position: absolute; top: 0; height: 1px;
  background: var(--color-rule-on-primary);
  width: calc(100% + var(--s-64));
}
.stone__col--l .cert__item::before { left: 0; }    /* overshoots right, toward the stone */
.stone__col--r .cert__item::before { right: 0; }   /* overshoots left, toward the stone */

.cert__item i { font-size: 24px; color: var(--color-on-primary); }
.cert__name { font-family: var(--font-heading-family); font-size: var(--type-headline-md); }
.cert__by { font-size: var(--type-body-sm); color: var(--color-on-primary); opacity: 0.82; max-width: 46ch; }

@media (max-width: 1023px) {
  /* Stacked, the ring stops being a ring. The stone goes on its own row and
     the four proofs become a two by two under it, which is the same block
     treatment without the geometry it can no longer afford. The leaders go
     with the geometry: a rule pointing at nothing is just a longer rule. */
  .stone__ring {
    grid-template-columns: 1fr;
    gap: var(--s-48);
    justify-items: center;
  }
  .stone__col {
    display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-32);
    width: 100%;
  }
  /* One column, so the desktop placement has to be cleared: a grid-column of 2
     or 3 against a single track would invent two implicit columns. No `order`
     any more either, because the stone is first in the source, which is the
     order it is wanted in here. */
  .stone__col--l, .stone__col--r, .stone__stage { grid-column: 1; grid-row: auto; }
  .stone__col--l { text-align: left; }
  .stone__stage { width: min(440px, 72vw); }
  .cert__item::before { width: 100%; left: 0; right: auto; }
}
@media (max-width: 560px) {
  .stone__ring { gap: var(--s-32); }
  /* Four proofs in one scrollable row rather than four stacked blocks. At a
     phone width the two by two has already collapsed to one per row, and four
     headline-md blocks under the stone ran the section to about two and a half
     screens.

     The set is the scroller and the two columns go transparent inside it, which
     is the whole reason for the wrapper: the four are split across two parents
     and a scroller can only scroll its own children.

     Same mechanics as the products and decisions rails: x mandatory snapping,
     bled to the viewport edge against the shell's own padding. 82% here rather
     than the decisions rail's 78%, because a proof carries a headline-md name
     and at 78 'Graduate Gemologist' wrapped. */
  .cert__set {
    display: flex;
    /* The ring centres its items at this width, which shrink wraps a scroller
       instead of stretching it: measured, the rail came out 416px wide starting
       at -13 in a 390 viewport. It has to own the full track before the
       negative margin can bleed it to the screen edge. */
    justify-self: stretch;
    gap: var(--s-16);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-padding-inline-start: var(--gutter);
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  .cert__set::-webkit-scrollbar { display: none; }
  .stone__col { display: contents; }
  .cert__item { flex: 0 0 82%; scroll-snap-align: start; }
  /* Proofs three and four sit outside the viewport horizontally, so a per item
     reveal never fires for them: measured, both stayed at opacity 0 until the
     rail was swiped. The rail reveals as one block instead and the items are
     never hidden, which is exactly what .products__rail does and for the same
     reason. Above 560 cert__set is display:contents and generates no box, so
     its own .reveal is inert there and the per item reveals stand. */
  .js .cert__set .reveal { opacity: 1; transform: none; }
}

/* --- 14b. Testimonial -------------------------------------------------------
   Quotes, centred, on the cream ground, one visible at a time. It sits above
   the stone: a person's sentence is the way into the certification argument
   rather than the footnote to it, and the ground stays the page default
   because the Rose Wood panel follows immediately.

   Not the reviews app zone. See the section comment in index.html: no rating
   data, no schema, merchant-authored copy only.

   THE STACK. The list is the sizing box and every quote is absolutely placed
   inside it, so the section height is the tallest quote and never changes as
   the deck turns. A grid stack would do the same, but the outgoing quote has
   to be painted over the incoming one during the crossfade, and absolute
   positioning is the only one of the two that guarantees the paint order.  */

/* Instance exception from the 96 base, from a review at 1920. The quote is a
   short centred measure in a narrow shell, so the section carries most of a
   screen's worth of its own air and the base padding read as a hole under the
   product rail. Below it a full bleed colour panel starts, which earns the full
   gap, so the two sides differ on purpose.

   Painted gaps, measured between real content rather than between section boxes:
   168 above and 160 below became **120 and 160**.

   The handoff named this element but not the 768 rule that also sets its
   padding, so the mobile value is a decision rather than a copy. 32 keeps the
   desktop relationship: 0.75 above against below at both widths, where 48 would
   have left mobile at 0.92 and the two breakpoints would not have matched. */
.testimonial { padding-top: var(--s-48); }
@media (max-width: 768px) { .testimonial { padding-top: var(--s-32); } }

.quotes {
  position: relative;
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
}
/* Every quote occupies the same cell, so the box is the tallest of them and
   the section does not resize under the reader mid-sentence. */
.quotes > li { grid-area: 1 / 1; }

.quote {
  opacity: 0;
  transform: translateY(var(--s-8));
  /* Invisible quotes must not swallow a click or a text selection. They are
     deliberately NOT hidden from assistive technology: see index.html. */
  pointer-events: none;
  transition: opacity var(--duration-long) var(--ease-reveal),
              transform var(--duration-long) var(--ease-reveal);
}
.quote.is-active {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}

.quote__figure {
  margin: 0;
  display: grid;
  justify-items: center;
  text-align: center;
  gap: var(--s-32);
}

/* The stars are the one -fill glyph on a project locked to -line. An outlined
   star is how a rating draws the part that was NOT earned, so five of them
   reads as zero out of five. Recorded in DESIGN.md Exceptions. */
.quote__stars {
  display: inline-flex;
  gap: var(--s-8);
  color: var(--color-accent);
  font-size: 18px;
  line-height: 1;
}

.quote__text {
  margin: 0;
  /* headline-md, not display-lg. A customer's sentence set at the same size as
     a section headline flattens the two into one level; the quote is a pause
     and should read quieter than the section it introduces. Capped short of
     the 760 the narrow shell allows so it breaks to balanced lines rather than
     long ones with an orphan under them. Marcellus runs about 0.68em to the
     ch, so 20ch measured 760 and never constrained anything. */
  max-width: 27ch;
}
.quote__text p { margin: 0; }

.quote__source {
  font-size: var(--type-body-sm);
  color: var(--color-text-muted);
  /* Tighter to the quote than the stars are, so the attribution reads as part
     of the quote rather than as a third element in an evenly spaced stack. */
  margin-top: calc(var(--s-32) * -1 + var(--s-24));
}

@media (max-width: 768px) {
  .quote__figure { gap: var(--s-24); }
  .quote__text { max-width: none; }
  .quote__source { margin-top: calc(var(--s-24) * -1 + var(--s-16)); }
}

/* Nothing moves under reduced motion. The deck does not advance at all (the
   script checks the same query), so this only has to make the change instant
   for the swipe and keyboard cases. */
@media (prefers-reduced-motion: reduce) {
  .quote { transition: none; transform: none; }
}

/* --- 15. Decided (sticky headline + drawn steps) --------------------------- */

.decided__grid {
  display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--s-48) var(--grid-gap);
  align-items: start;
}
.decided__head {
  grid-column: 1 / span 4;
  position: sticky;
  top: calc(var(--header-height) + var(--s-48));
  align-self: start;
}
.decided__list { grid-column: 6 / span 7; display: grid; gap: var(--s-48); }

.step { display: grid; grid-template-columns: auto 1fr; gap: var(--s-24); align-items: start; }
.step__num { }
.step__body { display: grid; gap: var(--s-8); }
.step__line { height: 1px; width: 0; background: var(--color-accent); transition: width 900ms var(--ease-reveal); margin-bottom: var(--s-8); }
.step.is-in .step__line { width: 100%; }
.step.is-in .t-numeral { color: var(--color-accent); }

@media (max-width: 1023px) {
  .decided__head { grid-column: 1 / span 12; position: static; }
  .decided__list { grid-column: 1 / span 12; gap: var(--s-32); }
}
@media (max-width: 560px) {
  .step { grid-template-columns: 1fr; gap: var(--s-12); }
}

/* --- 16. Full bleed band, and the four decisions ---------------------------
   Replaces what used to be two prose sections. The claim goes on the picture
   of the bench that backs it, and the four decisions sit below it as a row of
   numerals rather than four paragraphs.                                      */

.band {
  position: relative;
  min-height: 60vh;
  display: grid;
  align-items: end;
  isolation: isolate;
  overflow: hidden;
}
.band__media { position: absolute; inset: 0; z-index: -2; }
.band__media img { width: 100%; height: 100%; object-fit: cover; }
.band::after {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background: var(--scrim-media-band);
  pointer-events: none;
}
/* The claim and the four decisions share the photograph now. The top padding is
   the photograph's share: without it the bench is a strip above the headline
   and the section stops being a picture of a workshop. */
.band__inner {
  display: grid;
  gap: var(--s-48);
  padding-block: var(--s-128) var(--s-64);
}
.band__copy {
  display: grid; gap: var(--s-16);
  max-width: min(42vw, 520px);
  color: var(--color-on-media);
}
.band__copy h2 { color: var(--color-on-media); }
/* Kept although the band currently has no paragraph. The Shopify section this
   maps to carries an optional body setting, so a merchant filling it in must
   land on the measured 6.59:1 rather than on unstyled 16px. */
.band__copy p { font-size: var(--type-body-lg); color: var(--color-on-media); max-width: 40ch; }

.decisions {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--grid-gap);
}
/* Cards, because they stand on a photograph rather than on a ground. There are
   still no shadows on this page: what lifts them is the ground change and a
   hairline, which is the same way everything else on the project is lifted. */
.decision {
  display: grid; gap: var(--s-12); align-content: start;
  padding: var(--s-24);
  background: var(--card-on-media);
  border: 1px solid var(--on-media-rule);
  border-radius: var(--card-border-radius);
  color: var(--color-on-media);
}
/* Rust measures 1.55:1 on this card and would read as a dead rule, so the draw
   and the arrived numeral are white here, the same swap the featured deck makes
   on its dark grounds. */
.decision .t-numeral { color: var(--on-media-mute); }
.decision__line { height: 1px; width: 0; background: var(--color-on-media); transition: width 900ms var(--ease-reveal); }
.decision.is-in .decision__line { width: 100%; }
.decision.is-in .t-numeral { color: var(--color-on-media); }
.decision h3 { font-size: var(--type-headline-sm); color: var(--color-on-media); }
.decision p { font-size: var(--type-body-sm); color: var(--on-media-mute); max-width: 30ch; }

@media (max-width: 900px) {
  .decisions { grid-template-columns: repeat(2, 1fr); gap: var(--grid-gap); }
}
@media (max-width: 768px) {
  /* The scrim runs bottom up on a phone, so the photograph's share moves to
     the top of the band rather than being padding under the cards. */
  .band__inner { padding-block: var(--s-96) var(--s-48); gap: var(--s-32); }
  .decision { padding: var(--s-16); }
}
@media (max-width: 560px) {
  .band { min-height: 56vh; }
  .band__copy { max-width: none; }
  /* One row that rolls, not four stacked cards. Stacked, the four ran the band
     to roughly three phone screens and the photograph underneath stopped being
     readable as a bench: the cards became the section. As a rail the band keeps
     one screen's height and the picture stays the thing it sits on.

     Same mechanics as .products__rail, which is the page's existing rail: flex
     with x mandatory snapping, bled to the viewport edge by a negative margin
     so a card can sit on the screen edge rather than inside the shell. 78%
     rather than the rail's 66%, because a decision is a heading plus two lines
     of body and at 66 the body wrapped to five. The remainder is the peek that
     says there is another card.

     The gap is still the only place the photograph shows between cards, so it
     holds at 16: at 12 they read as one dark slab. */
  .decisions {
    display: flex;
    grid-template-columns: none;
    gap: var(--s-16);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-padding-inline-start: var(--gutter);
    margin-inline: calc(var(--gutter) * -1);
    padding-inline: var(--gutter);
    /* The rail is driven by snapping and by the timer, and a visible bar under
       four cards on a photograph is noise. The peek carries the affordance. */
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  .decisions::-webkit-scrollbar { display: none; }
  .decision { flex: 0 0 78%; scroll-snap-align: start; }
}

/* --- 17. Services ---------------------------------------------------------- */

/* Three columns, not three rows. There are no rules between them and no box
   around them: the reference this follows carries the whole thing on alignment
   and air, and a hairline grid would put the table back. The first column's
   text sits on the same line as the headline above it, which is the only thing
   holding the left edge. */
.services__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--grid-gap);
  /* .head__row already carries 48 below it. 48 more makes 96, the section
     rhythm, which is the distance the columns need to stop reading as part of
     the heading block. */
  margin-top: var(--s-48);
}

.service {
  display: grid;
  align-content: start;
  gap: var(--s-16);
  text-decoration: none; color: inherit;
}

/* The mark carries what the words cannot, so it is given room rather than
   tucked against the title: 48 to the heading, against 16 inside the text. */
.service__icon {
  font-size: 32px;
  line-height: 1;
  color: var(--color-foreground);
  margin-bottom: var(--s-32);
  transition: color var(--duration-default) var(--ease-default);
}

.service__name {
  font-family: var(--font-heading-family);
  font-size: var(--type-headline-md);
  line-height: 1.2;
  font-weight: 400;
  margin: 0;
  /* Two lines reserved whether or not the second is used. "Gold valuation and
     exchange" wraps and the other two do not, and without this the bodies and
     the three Enquire links sat at three different heights, which is the one
     thing a three column row of equals cannot do. Costs a line of air above
     the two short titles, which is cheaper than the stagger. */
  min-height: 2.4em;
}
.service__body { font-size: var(--type-body-md); color: var(--color-text-muted); margin: 0; max-width: 34ch; }

.service__go {
  display: inline-flex; align-items: center; gap: var(--s-8);
  color: var(--color-accent);
  font-size: var(--type-label-xs); letter-spacing: 0.08em;
  text-transform: uppercase; font-weight: 500;
  margin-top: var(--s-8);
}
.service__go i { transition: transform var(--duration-default) var(--ease-default); }

/* No fill and no lift on hover. With no card around it there is no edge for a
   background to belong to, so the feedback is on the three things that are
   actually there: the mark takes the accent, the name underlines, the arrow
   moves. */
.service:hover .service__icon,
.service.is-hover .service__icon,
.service:focus-visible .service__icon { color: var(--color-accent); }
/* The name's rule is the shared .underline utility on a span inside the h3; the
   trigger is --underline-w on .service:hover, declared with the other triggers. */
.service:hover .service__go i,
.service.is-hover .service__go i,
.service:focus-visible .service__go i { transform: translateX(4px); }

/* Two columns would leave one service on a line of its own, so three goes
   straight to one. The gap grows to 48 because stacked columns with a 24 gap
   read as one paragraph with headings in it. */
@media (max-width: 900px) {
  .services__grid { grid-template-columns: 1fr; gap: var(--s-48); margin-top: var(--s-32); }
  .service__icon { margin-bottom: var(--s-16); }
  .service__body { max-width: 52ch; }
  /* Stacked there is nothing to line up with, so the reserved second line is
     just a hole under every short title. */
  .service__name { min-height: 0; }
}

/* --- 18. Visit ------------------------------------------------------------- */

.visit__grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--s-48) var(--grid-gap); align-items: center; }
.visit__media { grid-column: 7 / span 6; }
.visit__media img { width: 100%; border-radius: var(--card-border-radius); }
.visit__copy { grid-column: 1 / span 5; display: grid; gap: var(--s-32); }

.address { display: grid; gap: var(--s-16); }
/* Two actions now, so they need a row that wraps rather than a bare div. The
   pair stays side by side wherever there is room and stacks when there is not,
   which on a phone is immediately: two 44px targets across 358px would leave
   each too narrow to read its own label. */
.visit__actions { display: flex; flex-wrap: wrap; gap: var(--s-12); }
@media (max-width: 560px) {
  .visit__actions { display: grid; }
}
.address__row { display: grid; grid-template-columns: 20px 1fr; gap: var(--s-12); align-items: start; }
.address__row i { font-size: 18px; color: var(--color-accent); line-height: 1.5; }
.address__label { font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); margin-bottom: var(--s-4); }
.address__value { font-size: var(--type-body-md); }
.address__value a {
  display: inline-flex; align-items: center; min-height: 44px;
  color: var(--color-foreground); text-decoration: none;
}
.address__value a:hover { color: var(--color-accent); }

@media (max-width: 1023px) {
  /* The counter leads the section rather than closing it, the same move the
     vision section makes when it stacks: the picture is the invitation and the
     address is what you act on after it. order rather than a DOM move, because
     this grid places its items by column on desktop and reordering the source
     would change which row each one lands in. */
  .visit__media { grid-column: 1 / span 12; order: -1; }
  .visit__copy  { grid-column: 1 / span 12; order: 1; }
  /* Four short blocks in one column ran the section long for no gain. Two up
     halves it. The row gap opens to 24 because two columns put the label of one
     block level with the last line of another, and at 16 they read as one list. */
  .address { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-24) var(--s-16); }
  /* inline-flex sizes to its content and will not shrink past it, so at half
     width the email ran straight out of its column. min-width lets the flex
     item shrink and anywhere lets the address break; it is the one string here
     with no space in it to break at. The 44px tap target is untouched. */
  .address__value a { min-width: 0; }
  .address__value a > .underline { overflow-wrap: anywhere; }
}

@media (max-width: 560px) {
  /* The icon column costs 32 of a 171px half column, a fifth of the line, and
     it carries nothing: every icon here is aria-hidden and the label beside it
     already reads Store, Atelier, Hours, Reach us. Dropping it at two up buys
     the address a fifth more line length.

     The column template has to go with it. Hiding the icon alone removes a grid
     item, and the block after it then auto-places into the 20px track the icon
     used to hold, which collapses every address to a 20px column. */
  .address__row { grid-template-columns: 1fr; gap: var(--s-4); }
  .address__row i { display: none; }

  /* The email is the one link here that wraps at half width, and inline-flex
     blockifies the span inside it: one background box the width of the column,
     so the rule ran 171px under 33px of "com". Inline gives the span real line
     boxes for box-decoration-break to fragment, and the rule measures 169 and
     33 against ink of 169 and 33. Padding keeps the tap target, 70px against
     the 44 the brief asks for, because vertical padding on an inline box still
     counts for hit testing even though it does not move the line. */
  .address__value a { display: inline; padding-block: var(--s-12); }
}

/* --- 19. Reviews app zone -------------------------------------------------- */
/* A marked placeholder. CLIENT-BRIEF.md 5.1: never a custom review UI.
   No instance on the homepage since 2026-09-11, removed at the client's
   request. The component is kept because 5.1 still requires a reviews
   placeholder and the PDP will need one. See DESIGN.md above Exceptions.    */

.app-zone {
  padding-block: var(--s-48) var(--s-64);
}
.app-zone__box {
  border: 1px dashed var(--color-surface-2);
  border-radius: var(--border-radius);
  padding: var(--s-48) var(--s-32);
  display: grid; gap: var(--s-8); justify-items: center; text-align: center;
  background: transparent;
}
.app-zone__title { font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-muted); }
.app-zone__body { font-size: var(--type-body-sm); color: var(--color-text-muted); max-width: 56ch; }

/* --- 20. Newsletter -------------------------------------------------------- */

.news__grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--s-32) var(--grid-gap); align-items: end; }
.news__copy { grid-column: 1 / span 5; display: grid; gap: var(--s-16); }
.news__form { grid-column: 7 / span 6; display: grid; grid-template-columns: 1fr auto; gap: var(--s-16); align-items: end; }
.news__copy p { color: var(--color-on-accent-soft); }

@media (max-width: 1023px) {
  .news__copy, .news__form { grid-column: 1 / span 12; }
}
@media (max-width: 560px) {
  .news__form { grid-template-columns: 1fr; }
}

/* --- 21. Footer ------------------------------------------------------------ */

.footer {
  background: var(--color-background);
  border-top: 1px solid var(--color-border-hairline);
  padding-bottom: var(--s-64);   /* clears the fixed WhatsApp action */
}
.footer__top { padding-block: var(--s-64); }
.footer__grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--s-32) var(--grid-gap); }
.footer__brand { grid-column: 1 / span 4; display: grid; gap: var(--s-24); align-content: start; }
.footer__brand img { width: 176px; height: auto; }
.footer__cols { grid-column: 6 / span 7; display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--grid-gap); }

.fcol summary {
  list-style: none;
  display: flex; align-items: center; justify-content: space-between;
  min-height: 52px;
  cursor: default;
}
.fcol summary::-webkit-details-marker { display: none; }
.fcol h3 { font-family: var(--font-heading-family); font-size: var(--type-headline-sm); }
.fcol summary i { display: none; font-size: 20px; }
.fcol ul { display: grid; gap: var(--s-4); padding-block: var(--s-8) var(--s-16); }
.fcol a {
  display: flex; align-items: center; min-height: 40px;
  font-size: var(--type-body-sm); color: var(--color-foreground); text-decoration: none;
}
.fcol a:hover { color: var(--color-accent); }

.footer__contact { display: grid; gap: var(--s-8); font-size: var(--type-body-sm); }
.footer__contact a {
  display: inline-block; padding-block: var(--s-8);
  color: var(--color-foreground); text-decoration: none;
}
.footer__contact a:hover { color: var(--color-accent); }

.footer__social { display: flex; gap: var(--s-4); }

.footer__legal {
  border-top: 1px solid var(--color-border-hairline);
  padding-block: var(--s-24);
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s-16);
  font-size: var(--type-body-sm); color: var(--color-text-muted);
}
.footer__legal nav { display: flex; flex-wrap: wrap; gap: var(--s-16); }
.footer__legal a {
  display: inline-block; padding-block: var(--s-12);
  color: var(--color-text-muted); text-decoration: none;
}
.footer__legal a:hover { color: var(--color-accent); }

@media (max-width: 1023px) {
  .footer__brand { grid-column: 1 / span 12; }
  .footer__cols { grid-column: 1 / span 12; }
}
@media (max-width: 768px) {
  .footer__cols { grid-template-columns: 1fr; gap: 0; }
  .fcol { border-bottom: 1px solid var(--color-border-hairline); }
  .fcol summary { cursor: pointer; }
  .fcol summary i { display: inline-flex; }
  .fcol a { min-height: 44px; }
  .fcol[open] summary i { transform: rotate(180deg); }
}
@media (min-width: 769px) {
  .fcol summary { pointer-events: none; }
}

/* --- 21b. Preloader ---------------------------------------------------------
   A hard cut roll of twelve frames with the wordmark held in the centre, then
   the whole thing wipes up.

   Three things keep it from becoming a trap:
   - It is gated on `.js`. With no script the markup never paints, so the site
     is never sitting behind a curtain that nothing will lift.
   - A CSS failsafe lifts it at 8s even if luvera.js never runs at all.
   - It is aria-hidden. It says nothing, so a screen reader goes straight to the
     page underneath rather than waiting out a decoration.                    */

.preloader { display: none; }

.js .preloader {
  position: fixed;
  inset: 0;
  z-index: var(--z-preloader);
  display: grid;
  place-items: center;
  overflow: hidden;
  background: var(--color-foreground);
  transform: translateY(0);
  will-change: transform;
  animation: preloader-failsafe 320ms linear 8s forwards;
}

.preloader__frame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  /* No transition anywhere here. The roll is a cut, not a dissolve. */
}
.preloader__frame[data-on] { opacity: 1; }

/* The pool of shade the wordmark sits in. Without it the mark lands on whatever
   the frame happens to put there, and a lit chain behind a letterform takes
   white type down to about 1.1:1. */
.preloader__scrim {
  position: absolute;
  inset: 0;
  background: radial-gradient(58% 62% at 50% 50%,
              rgb(20 12 8 / 0.62) 0%,
              rgb(20 12 8 / 0.44) 45%,
              rgb(20 12 8 / 0.10) 78%,
              rgb(20 12 8 / 0) 100%);
}

/* Grain as one tiling sheet rather than baked into every frame. Baked, it
   measured five times the file size, because noise does not compress. */
.preloader__grain {
  position: absolute;
  inset: 0;
  background-image: url('../img/grain.png');
  background-repeat: repeat;
  background-size: 128px 128px;
  mix-blend-mode: soft-light;
  opacity: 0.42;
  pointer-events: none;
}

.preloader__mark {
  position: relative;
  z-index: 1;
  width: min(46vw, 360px);
  transform: translateY(0);
  will-change: transform;
}
.preloader__mark img { width: 100%; height: auto; }

/* The exit. The mark leads and travels further than the curtain, so it reads as
   the nearer plane; the curtain follows it up 90ms later. */
.preloader.is-leaving {
  animation: none;
  pointer-events: none;   /* stops swallowing clicks the moment it starts to go */
  transform: translateY(-100%);
  transition: transform var(--duration-curtain) var(--ease-curtain) 90ms;
}
.preloader.is-leaving .preloader__mark {
  transform: translateY(-30vh);
  transition: transform var(--duration-curtain) var(--ease-curtain) 0ms;
}

@keyframes preloader-failsafe {
  to { opacity: 0; visibility: hidden; pointer-events: none; }
}

@media (prefers-reduced-motion: reduce) {
  /* No roll and no curtain. One frame, then it goes. A nine cut a second
     slideshow is the thing someone asking for reduced motion is asking not to
     be shown. */
  .js .preloader { animation: preloader-failsafe 200ms linear 5s forwards; }
  .preloader__frame:first-of-type { opacity: 1; }
  .preloader.is-leaving {
    transform: none;
    opacity: 0;
    transition: opacity 240ms linear;
  }
  .preloader.is-leaving .preloader__mark { transform: none; transition: none; }
}

/* --- 22. WhatsApp floating action ------------------------------------------ */

.wa {
  position: fixed; right: var(--s-24); bottom: var(--s-24); z-index: var(--z-fab);
  display: inline-flex; align-items: center; gap: var(--s-8);
  min-height: 48px; padding: var(--s-12) var(--s-16);
  background: var(--color-foreground); color: var(--color-background);
  border-radius: var(--border-radius);
  font-size: var(--type-label-xs); font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase;
  text-decoration: none;
  transition: background-color var(--duration-default) var(--ease-default),
              transform var(--duration-default) var(--ease-default);
}
.wa i { font-size: 20px; }
.wa:hover, .wa.is-hover { background: var(--color-accent); color: var(--color-on-accent); transform: translateY(-4px); }
.wa__label { display: inline; }
@media (max-width: 560px) {
  .wa { right: var(--s-16); bottom: var(--s-16); padding: var(--s-12); }
  .wa__label { display: none; }
}

/* --- 23. Overlays: drawer, search, mobile nav ------------------------------ */

.scrim {
  position: fixed; inset: 0; z-index: var(--z-overlay);
  background: var(--color-scrim);
  opacity: 0; visibility: hidden;
  transition: opacity var(--duration-default) var(--ease-default), visibility var(--duration-default);
}
.scrim[data-open='true'] { opacity: 1; visibility: visible; }

.drawer {
  position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--z-drawer);
  width: min(420px, 100vw);
  background: var(--color-background);
  display: grid; grid-template-rows: auto 1fr auto;
  transform: translateX(100%);
  transition: transform var(--duration-long) var(--ease-reveal);
}
.drawer[data-open='true'] { transform: none; }
.drawer--left { right: auto; left: 0; transform: translateX(-100%); width: min(380px, 100vw); }
.drawer--left[data-open='true'] { transform: none; }

.drawer__head {
  display: flex; align-items: center; justify-content: space-between;
  padding: var(--s-16) var(--s-24);
  border-bottom: 1px solid var(--color-border-hairline);
}
.drawer__title { font-family: var(--font-heading-family); font-size: var(--type-headline-sm); }
.drawer__body { overflow-y: auto; padding: var(--s-24); display: grid; gap: var(--s-24); align-content: start; }
.drawer__foot { border-top: 1px solid var(--color-border-hairline); padding: var(--s-24); display: grid; gap: var(--s-16); }

.line-item { display: grid; grid-template-columns: 72px 1fr auto; gap: var(--s-16); align-items: start; }
.line-item img { width: 72px; height: 90px; object-fit: cover; border-radius: var(--card-border-radius); }
.line-item__name { font-family: var(--font-heading-family); font-size: var(--type-headline-sm); }
.line-item__meta { font-size: var(--type-body-sm); color: var(--color-text-muted); margin-top: var(--s-4); }
.line-item__remove { font-size: var(--type-label-micro); }

.qty { display: inline-flex; align-items: center; border: 1px solid var(--color-border-hairline); margin-top: var(--s-8); }
.qty button { width: 44px; height: 44px; background: none; border: 0; display: inline-flex; align-items: center; justify-content: center; }
.qty button:hover { color: var(--color-accent); }
.qty output { min-width: 32px; text-align: center; font-variant-numeric: tabular-nums; font-size: var(--type-body-sm); }

.cart-total { display: flex; align-items: baseline; justify-content: space-between; }

/* In-cart cross sell. Voice-safe copy only, no scarcity framing. */
.cross-sell { border-top: 1px solid var(--color-border-hairline); padding-top: var(--s-16); display: grid; gap: var(--s-12); }
.cross-sell__row { display: grid; grid-template-columns: 56px 1fr auto; gap: var(--s-12); align-items: center; }
.cross-sell__row img { width: 56px; height: 70px; object-fit: cover; border-radius: var(--card-border-radius); }

/* Reserved for Shopify additional_checkout_buttons. Dev guards the render. */
.express-slot {
  border: 1px dashed var(--color-surface-2);
  border-radius: var(--border-radius);
  padding: var(--s-12);
  text-align: center;
  font-size: var(--type-body-sm);
  color: var(--color-text-muted);
}

.empty { display: grid; gap: var(--s-16); justify-items: start; padding-block: var(--s-32); }
.empty i { font-size: 32px; color: var(--color-surface-2); }

.search-panel {
  position: fixed; top: 0; left: 0; right: 0; z-index: var(--z-drawer);
  background: var(--color-background);
  border-bottom: 1px solid var(--color-border-hairline);
  transform: translateY(-100%);
  transition: transform var(--duration-long) var(--ease-reveal);
  padding-block: var(--s-32);
}
.search-panel[data-open='true'] { transform: none; }
.search-panel__row { display: grid; grid-template-columns: 1fr auto; gap: var(--s-16); align-items: end; }
.search-suggest { display: flex; flex-wrap: wrap; gap: var(--s-8); margin-top: var(--s-24); }
.search-suggest a {
  padding: var(--s-8) var(--s-12);
  border: 1px solid var(--color-border-hairline);
  font-size: var(--type-body-sm); color: var(--color-foreground); text-decoration: none;
}
.search-suggest a:hover { border-color: var(--color-accent); color: var(--color-accent); }

.mnav { display: grid; gap: var(--s-4); }
.mnav a {
  display: flex; align-items: center; justify-content: space-between;
  min-height: 52px;
  font-family: var(--font-heading-family); font-size: var(--type-headline-sm);
  color: var(--color-foreground); text-decoration: none;
  border-bottom: 1px solid var(--color-border-hairline);
}
.mnav a:hover { color: var(--color-accent); }

@media (prefers-reduced-motion: reduce) {
  .drawer, .search-panel, .scrim, .tile__frame img, .card__quick, .stone__canvas, .step__line { transition-duration: 1ms; }
  .service:hover { padding-inline: 0; }
}

/* --- 24. Forced states -----------------------------------------------------
   `.is-hover` and `.is-focus` are attached to the same declaration blocks as
   the real pseudo classes above, so component.html can show a hover or focus
   state on the page without a second copy of the CSS. Nothing on a real page
   ever carries them.                                                        */
