/* ============================================================================
   QUARYN · page styles
   ----------------------------------------------------------------------------
   scrollcraft.css is the floor and the mechanism. Nothing here restyles a
   .sc-stage, a .sc-copy or any [data-sc-*] selector. This file is the brand
   (six colours, two faces) plus the markup this page invented.
   ========================================================================== */

:root {
  /* Off-black, and neutral. Not #000: pure black has no air in it and a star
     field on it bands badly on anything that is not OLED, but everything here
     is within a couple of points of it. The GLOW is not in the ground, it is
     in the star field, the nebulae and the accent, which is why the ground can
     be this dark and the page still does not read as flat. */
  --sc-canvas:     #030304;
  --sc-surface:    #0C0C0E;
  --sc-ink:        #F2F2F4;
  --sc-ink-soft:   #9B9AA0;   /* a hair of violet, so it is not flat grey */
  --sc-accent:     #9FC4FF;   /* light enough to read as light, not as blue */
  --sc-accent-ink: #050506;

  --sc-font-display: "Archivo", system-ui, sans-serif;
  --sc-font-text:    "Geist", system-ui, sans-serif;
  /* THE MONO FACE IS GONE. Not overridden in a few places — gone, everywhere,
     which is why this is an alias rather than fifty-seven edits. Every rule
     that asked for --sc-font-mono still asks for it and now gets the same face
     the header uses, so the site has ONE voice instead of a heading voice and
     a caption voice arguing under it.

     Numbers still need to line up in a price column or a total, so anything
     tabular carries font-variant-numeric below rather than leaning on a
     monospace face to do it. Geist has proper tabular figures; using a
     typewriter to align three digits was always the heavier way. */
  --sc-font-mono:    var(--sc-font-text);

  --sc-shadow-color: 240 12% 1%;

  /* ── liquid glass ────────────────────────────────────────────────────────
     Ported from webild.io's idiom, translated for a near-black ground. They
     sit on photography, so white at 25% over a 4px blur reads as glass. On
     this canvas that same fill is a milky grey panel, so the fills drop to
     5-9% and the blur goes up: there is real content behind ours, and the
     blur is what proves the panel is in front of it rather than painted on.

     What carries over unchanged is the STRUCTURE, which is the actual style:
     two levels of glass (a quiet outer panel holding brighter inner
     surfaces), a 1px rim rather than a drop shadow, modest radii on boxes and
     full pills on controls. The Apple part is the specular: the rim is
     brightest along the top lip and fades down, so the panel reads as a thick
     lens catching light rather than a flat translucent rectangle. */
  --glass:         rgb(255 255 255 / 0.055);
  --glass-strong:  rgb(255 255 255 / 0.09);
  --glass-hover:   rgb(255 255 255 / 0.13);
  --glass-rim:     rgb(255 255 255 / 0.13);
  --glass-rim-hi:  rgb(255 255 255 / 0.28);   /* the top lip */
  --glass-blur:    blur(22px) saturate(1.45);
  --glass-r:       16px;
  --glass-r-lg:    22px;

  --bar-h: 64px;
  --mark-rest: 0.148;   /* the scale the wordmark comes to rest at, in the bar */
}

@media (max-width: 860px) { :root { --bar-h: 56px; --mark-rest: 0.24; } }

/* Light-on-dark compensation: a touch more leading and tracking, one step more
   weight than the same type would take on a light ground. */
body { font-weight: 350; letter-spacing: 0.004em; }

/* ═══════════════════════════════════════════════════════ the dome ═══════ */

.dome {
  position: fixed; inset: 0;
  width: 100%; height: 100%;
  z-index: 0;
  pointer-events: none;
}

/* The enclosure. A radial darkening that is heavier at the rim than the centre
   reads as a curved surface overhead rather than a flat wall, and it is what
   makes the field feel like it wraps rather than recedes. */
.dome__horizon {
  position: fixed; inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(120% 78% at 50% 42%,
      transparent 0%,
      transparent 38%,
      color-mix(in oklab, var(--sc-canvas) 55%, transparent) 74%,
      color-mix(in oklab, var(--sc-canvas) 92%, transparent) 100%),
    radial-gradient(60% 34% at 50% 106%,
      rgb(150 160 190 / 0.032) 0%,
      transparent 62%);
}

/* Every act travels in front of the dome. The stages carry no ground of their
   own, which is the whole reason the field stays continuous across the page. */
main { position: relative; z-index: 1; }
.act-hero, .act-premise, .act-aperture, .act-catalog,
.act-rail, .act-how, .act-close { background: transparent; }

/* ═══════════════════════════════════════════════ the mark and the bar ═══ */

/* One element for the life of the page. site.js drives --m (0 at the hero,
   1 parked in the bar) and writes the transform. */
.mark {
  position: fixed;
  left: 50%; top: 50%;
  z-index: 70;
  transform-origin: 50% 50%;
  /* The centring HAS to be here and not only in site.js. left:50% puts the
     wordmark's left edge on the centre line; the -50% is what actually centres
     it, and until the first frame of JS ran there was no transform at all — so
     the mark painted half a word to the right and jumped left the moment the
     scroll handler wrote its first value. site.js overwrites this every frame;
     it exists for the frames before that. */
  transform: translate(-50%, -50%);
  will-change: transform;
  pointer-events: none;
}
.mark__l {
  display: inline-block;
  opacity: 0;
  filter: blur(16px);
  /* forwards, so the letter holds its landed state instead of snapping back
     to opacity 0 when the animation ends. */
  animation: letter-in 1200ms var(--sc-ease-out) forwards;
  /* The wave. Each letter waits on the one in front of it; the small base
     delay lets the display face finish loading so the wave is not spent on a
     fallback that then reflows. */
  animation-delay: calc(var(--i) * 92ms + 180ms);
  will-change: opacity, transform;
}
/* The wordmark condenses out of the same haze as everything else, just
   slower and heavier — it is the biggest thing on the page, so it should be
   the last to resolve. */
@keyframes letter-in {
  from { opacity: 0; filter: blur(16px); transform: translateY(0.24em) scale(1.12); }
  55%  { opacity: 1; }
  to   { opacity: 1; filter: blur(0); transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .mark__l { opacity: 1; animation: none; }
}

.mark__word {
  display: block;
  pointer-events: auto;
  font-family: var(--sc-font-display);
  font-weight: 600;
  font-size: clamp(4.4rem, 15vw, 13rem);
  line-height: 0.9;
  letter-spacing: var(--mark-track, -0.045em);
  color: var(--sc-ink);
  text-decoration: none;
  white-space: nowrap;
  /* Shadow is declared with the bar's chrome, so the wordmark keeps the same
     ground-of-its-own once it lands there. */
}

.bar {
  position: fixed; inset: 0 0 auto 0;
  height: var(--bar-h);
  z-index: 60;
  /* Three columns, and the middle one is EMPTY. The wordmark is fixed and
     always centred on the viewport, so the bar has to keep that space clear or
     the mark lands on top of whatever sits there. On a phone, where the nav is
     hidden, that was the bag button. The slot is sized from the mark's own
     measured rest width, so it can never drift out of agreement with it. */
  display: grid;
  grid-template-columns: 1fr var(--mark-w, 5rem) 1fr;
  align-items: center;
  padding-inline: var(--sc-gutter);
  font-size: var(--sc-t-sm);
  /* Opacity is driven by site.js off the same progress the mark uses, so the
     bar materialises exactly as the wordmark lands in it. */
  opacity: var(--bar-o, 0);
  /* Transparent. No plate, no blur, no rule: the dome runs unbroken from the
     top of the page to the bottom, and the bar is just type sitting in it.
     What keeps the type legible instead is the shadow below, which darkens
     only the few pixels behind each glyph rather than banding the whole
     width. */
  background: none;
  transition: opacity 200ms var(--sc-ease-out);
}

/* A transparent bar means the page runs underneath it, and sooner or later a
   heading passes directly behind the wordmark and the two print through each
   other. This is the fix that keeps the bar transparent: a tall, soft, edgeless
   fade rather than a plate. It has no border and no hard bottom, stars read
   straight through it, and it only ever darkens the band the chrome sits in.
   It also fades in with the bar, because it shares the bar's opacity. */
.bar::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  /* Tall enough to cover the bar and land, and no taller. At 2.6 bar-heights
     this reached 166px down the page — and on The Line the heading sits at 76,
     so the scrim was a dark veil laid over the biggest word on the screen. It
     measured 146 of a possible 255 with every style stripped off it, which is
     why no amount of glow ever made it look bright: the light was going on
     underneath the shade. */
  height: calc(var(--bar-h) * 1.35);
  z-index: -1;
  pointer-events: none;
  /* Fully opaque for the bar's own height, and only then a fade. It used to
     start at 95% and drop to 82% by 30% down — which is inside the bar — so a
     price or a heading passing behind the chrome came through as a five
     percent ghost. Five percent of white on near-black is legible, and a
     legible smudge behind the wordmark is worse than either a plate or
     nothing at all. bar-h is 1/2.6 of this element, so 39% is its bottom
     edge. */
  /* NOTHING. The bar is glass now.

     This used to be the page's own colour, opaque for three quarters of its
     height — which is a painted lid over the dome, and the dome is the whole
     idea of the site. The stars stopped at the top of the screen.

     What made the scrim necessary was legibility: type scrolling up behind the
     bar collided with the bar's own type. That is solved below by a much
     lighter strip that goes fully opaque only across the top of the bar and is
     transparent by the bottom of it — so the dome runs unbroken through the
     lower half of the header, which is what it was always supposed to do. */
  background: none;
}

/* THE FADE IS A SCRIM NOW, NOT A MASK ON THE PAGE.

   It used to be a mask on <main> whose gradient stops were rewritten on every
   animation frame. That is a full-page repaint per frame: a mask forces the
   masked element into its own rasterised layer, and <main> on the home page is
   ten thousand pixels tall. On a desktop it went unnoticed. On a phone it was
   the single most expensive thing the site did, and it caused a second bug
   that looked unrelated — under momentum scrolling iOS throttles rAF, so the
   stops went stale while the page kept moving and a whole band of the screen
   masked itself out. "Scroll up fast and half the screen goes black" was this.

   A fixed strip under the bar does the same job for nothing: content scrolling
   up passes behind it and dissolves into the page's own colour. No mask, no
   layer, no JavaScript, and it cannot go stale because it never moves.

   It goes opaque only across the top half of the bar, so the lower half of the
   header still shows the sky through it — which was the point of making the
   bar glass in the first place. */
body:not(.page-ring)::before {
  content: '';
  position: fixed; top: 0; left: 0; right: 0;
  height: calc(var(--bar-h) * 1.15);
  z-index: 2;
  pointer-events: none;
  background: linear-gradient(to bottom,
    var(--sc-canvas) 0%,
    color-mix(in oklab, var(--sc-canvas) 78%, transparent) 42%,
    color-mix(in oklab, var(--sc-canvas) 34%, transparent) 72%,
    transparent 100%);
}
.bar[data-off] { pointer-events: none; }
.bar__nav, .bar__end { display: flex; align-items: center; gap: var(--sc-5); }
/* Columns are assigned EXPLICITLY. A display:none grid item is removed from
   the grid entirely, so hiding the nav on a phone silently shifted the slot
   into column 1 and the bag into column 2 — which put the bag directly under
   the centred wordmark. Pinning each child to its own column means hiding one
   moves nothing. */
.bar__nav  { grid-column: 1; justify-self: start; }
.bar__slot { grid-column: 2; display: block; }
.bar__end  { grid-column: 3; justify-self: end; }
.bar a { white-space: nowrap; }
/* The bar has no ground of its own, so every glyph in it carries its own.
   A tight, offset, heavily-blurred shadow reads as nothing at all on the star
   field and still holds the contrast when a light product plate passes under
   the bar. */
.bar a, .bar__cart, .mark__word {
  text-shadow: 0 1px 2px hsl(var(--sc-shadow-color) / 0.95),
               0 1px 7px hsl(var(--sc-shadow-color) / 0.8);
}
.bar__cart {
  background: color-mix(in oklab, var(--sc-canvas) 55%, transparent);
  backdrop-filter: blur(10px);
}
.bar a { color: var(--sc-ink-soft); text-decoration: none; }
.bar a:hover { color: var(--sc-ink); }

.bar__cart {
  display: inline-flex; align-items: center; gap: var(--sc-2);
  background: none; border: 1px solid var(--sc-hairline-strong);
  border-radius: var(--sc-r-pill);
  padding: 0.4rem 0.85rem;
  color: var(--sc-ink); cursor: pointer;
  transition: border-color var(--sc-d-fast) var(--sc-ease-out);
}
.bar__cart:hover { border-color: var(--sc-accent); }
.bar__count {
  min-width: 1.35em; padding: 0 0.35em;
  border-radius: var(--sc-r-pill);
  background: var(--sc-accent); color: var(--sc-accent-ink);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  font-weight: 500; text-align: center;
}
.bar__count[data-zero] { background: color-mix(in oklab, var(--sc-ink) 14%, transparent); color: var(--sc-ink-soft); }

@media (max-width: 700px) {
  /* The right column keeps the bag and nothing else: at this width a second
     link there crowds the wordmark's slot and wraps to three lines. */
  .bar__end a { display: none; }
}

/* ═══════════════════════════════════════════════════════════ act 1 ══════ */

.hero {
  display: grid; place-content: center; justify-items: center;
  text-align: center;
  padding-inline: var(--sc-gutter);
  gap: var(--sc-4);
}
.hero__over {
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.34em; text-transform: uppercase;
  /* font-weight is inherited on purpose: this element is an <h1> for search
     engines, and the UA stylesheet makes an h1 bold. Everything else here was
     already explicit, so weight was the one property that would have shown. */
  color: var(--sc-accent); margin: 0; font-weight: inherit;
}
/* The wordmark is fixed and therefore out of flow, so the hero reserves its
   optical height here instead. Without this the lede sits under the mark. */
.hero__gap { height: clamp(4.4rem, 15vw, 13rem); }
.hero__lede { text-align: center; margin-inline: auto; }
/* opacity and transform are written by site.js off act 1's progress. The only
   thing declared here is what happens once it is gone: an invisible button is
   still a clickable button, and a keyboard user can still tab into one. */
.hero__fade { will-change: opacity, transform; }
.hero__fade[data-gone] { pointer-events: none; visibility: hidden; }
.hero__acts { display: flex; gap: var(--sc-2); flex-wrap: wrap; justify-content: center; margin-top: var(--sc-3); }

/* ═══════════════════════════════════════════════════════════ act 2 ══════ */

.premise { display: grid; gap: var(--sc-7); max-width: 54rem; }
.premise__line {
  font-family: var(--sc-font-display);
  font-size: var(--sc-t-xl);
  line-height: 1.28;
  letter-spacing: var(--sc-track-snug);
  margin: 0;
  text-wrap: pretty;
}
.premise__line--soft { color: var(--sc-ink-soft); }

/* ═══════════════════════════════════════════════ act 3 · the peak ═══════ */

.aperture { display: grid; place-items: center; }
.aperture__title { text-align: center; position: relative; z-index: 2; }
.aperture__foot { position: absolute; bottom: clamp(2rem, 8vh, 5rem); z-index: 2; text-align: center; }

/* The three cards that come out of depth. site.js positions and scales them
   off the act's --sc-p; the CSS only says what one looks like. */
.aperture__field { position: absolute; inset: 0; z-index: 1; }
.ap-card {
  position: absolute; left: 50%; top: 50%;
  width: clamp(140px, 17vw, 230px);
  aspect-ratio: 3 / 4;
  margin: 0;
  border-radius: var(--sc-r-lg);
  border: 1px solid var(--sc-hairline-strong);
  background:
    linear-gradient(155deg,
      color-mix(in oklab, hsl(var(--ap-hue) 60% 42%) 26%, var(--sc-surface)),
      var(--sc-surface) 62%);
  box-shadow: var(--sc-e3), var(--sc-edge);
  will-change: transform, opacity;
  display: grid; align-content: end; gap: 0.2rem;
  padding: var(--sc-4);
}
.ap-card__n { font-family: var(--sc-font-display); font-weight: 600; font-size: var(--sc-t-base); }
.ap-card__p { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-accent); }

/* The peak used to be switched off entirely below 700px, which left the whole
   act as a heading, a line of small caps, and a screen of empty sky between
   them — the biggest hole on the phone version of the page. It runs here too
   now, at a size that fits the column rather than the desktop's. */
@media (max-width: 700px) {
  .ap-card { width: clamp(150px, 52vw, 210px); padding: var(--sc-3); }
  .ap-card--solo { width: clamp(160px, 58vw, 230px); }
  .ap-card__n { font-size: var(--sc-t-sm); }
}

/* ═══════════════════════════════════════════════════ act 4 · the grid ══ */

.catalog__head {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: var(--sc-5); flex-wrap: wrap;
  margin-bottom: var(--sc-7);
}
.chips { display: flex; gap: var(--sc-2); flex-wrap: wrap; }
.chip {
  border: 1px solid var(--sc-hairline-strong);
  background: none; color: var(--sc-ink-soft);
  border-radius: var(--sc-r-pill);
  padding: 0.4rem 0.9rem;
  font-size: var(--sc-t-sm); cursor: pointer;
  transition: color var(--sc-d-fast) var(--sc-ease-out),
              border-color var(--sc-d-fast) var(--sc-ease-out),
              background-color var(--sc-d-fast) var(--sc-ease-out);
}
.chip:hover { color: var(--sc-ink); border-color: var(--sc-ink-soft); }
.chip[aria-pressed="true"] {
  background: var(--sc-accent); border-color: var(--sc-accent);
  color: var(--sc-accent-ink); font-weight: 500;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(clamp(15rem, 26vw, 20rem), 1fr));
  gap: var(--sc-5);
}

.card {
  position: relative;
  display: flex; flex-direction: column;
  border: 1px solid var(--sc-hairline);
  border-radius: var(--sc-r-lg);
  background: color-mix(in oklab, var(--sc-surface) 82%, transparent);
  backdrop-filter: blur(8px);
  box-shadow: var(--sc-e2), var(--sc-edge);
  overflow: hidden;
  transition: border-color var(--sc-d-base) var(--sc-ease-out),
              transform var(--sc-d-base) var(--sc-ease-out);
}
.card:hover { border-color: color-mix(in oklab, var(--sc-accent) 45%, transparent); }

/* The plate. No photography exists yet, so each card carries a lit field keyed
   to its own hue rather than a grey box or a stock photo. Replacing this with
   a real product shot is one <img> per card and nothing else moves. */
.card__plate {
  aspect-ratio: 4 / 3;
  position: relative;
  background:
    radial-gradient(90% 80% at 30% 18%,
      hsl(var(--hue) 62% 46% / 0.42), transparent 62%),
    radial-gradient(70% 70% at 82% 88%,
      hsl(calc(var(--hue) + 40) 58% 40% / 0.3), transparent 60%),
    var(--sc-surface);
  border-bottom: 1px solid var(--sc-hairline);
  display: grid; place-items: center;
}
.card__glyph {
  font-family: var(--sc-font-display);
  font-size: clamp(2.6rem, 6vw, 4rem); font-weight: 700;
  color: color-mix(in oklab, var(--sc-ink) 12%, transparent);
  letter-spacing: -0.06em;
}
/* Once a real photograph is in, the initial is redundant. It stays in the DOM
   as the decode-time holder and simply steps back. */
.card__plate--shot .card__glyph { opacity: 0; }

/* The product levitates. A slow vertical drift plus a shadow that breathes in
   antiphase: the shadow tightens and darkens as the object falls and softens
   as it rises, which is the cue that actually sells "floating" rather than
   "sliding up and down". Each card is offset by --float-i so a grid of them
   never bobs in unison, which reads as a carousel rather than as objects. */
.card__shot {
  position: absolute; inset: 8% 8% 10%;
  width: auto; height: auto;
  max-width: 84%; max-height: 82%;
  margin: auto;
  object-fit: contain;
  filter: drop-shadow(0 18px 22px hsl(var(--sc-shadow-color) / 0.55));
  animation: levitate 6.5s var(--sc-ease-in-out) infinite;
  animation-delay: calc(var(--float-i, 0) * -0.9s);
  will-change: transform;
}
@keyframes levitate {
  0%, 100% { transform: translateY(2.2%)  scale(0.985); }
  50%      { transform: translateY(-2.6%) scale(1.015); }
}
/* Hovering a card brings its product forward, which is the seam the turntable
   frames drop into later: the same element, driven instead of animated. */
.card:hover .card__shot { animation-play-state: paused; }
.card__plate--shot { cursor: pointer; }

@media (prefers-reduced-motion: reduce) {
  /* Levitation is decoration, and the product still reads perfectly at rest. */
  .card__shot { animation: none; transform: none; }
}
.card__flag {
  position: absolute; top: var(--sc-3); right: var(--sc-3);
  font-family: var(--sc-font-mono); font-size: 0.66rem;
  letter-spacing: 0.1em; text-transform: uppercase;
  padding: 0.25rem 0.5rem; border-radius: var(--sc-r-sm);
  background: color-mix(in oklab, var(--sc-canvas) 70%, transparent);
  border: 1px solid var(--sc-hairline-strong);
  color: var(--sc-ink-soft);
}

.card__body { padding: var(--sc-4); display: flex; flex-direction: column; gap: var(--sc-2); flex: 1; }
.card__n { font-family: var(--sc-font-display); font-size: var(--sc-t-lg); font-weight: 600; letter-spacing: var(--sc-track-snug); margin: 0; }
.card__sub { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-ink-soft); margin: 0; letter-spacing: 0.02em; }
.card__blurb { font-size: var(--sc-t-sm); color: var(--sc-ink-soft); margin: 0.4rem 0 0; line-height: 1.55; max-width: 42ch; }

.card__spec { list-style: none; margin: var(--sc-3) 0 0; padding: 0; display: grid; gap: 0.28rem; }
.card__spec li { font-size: var(--sc-t-xs); color: var(--sc-ink-soft); padding-left: 0.9rem; position: relative; }
.card__spec li::before { content: ""; position: absolute; left: 0; top: 0.62em; width: 4px; height: 1px; background: var(--sc-accent); }

.card__econ {
  margin-top: auto; padding-top: var(--sc-4);
  display: grid; grid-template-columns: auto auto; gap: 0.15rem var(--sc-4);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft);
}
.card__econ b { color: var(--sc-ink); font-weight: 500; }
.card__econ .win { color: var(--sc-accent); }

.card__buy { display: flex; align-items: center; gap: var(--sc-3); padding: var(--sc-4); border-top: 1px solid var(--sc-hairline); }
.card__price { font-family: var(--sc-font-display); font-size: var(--sc-t-lg); font-weight: 600; }
.card__price small { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); font-weight: 400; color: var(--sc-ink-soft); margin-left: 0.35rem; }

.card__buy > :last-child, .tile__buy > :last-child { margin-left: auto; }
.qty { display: inline-flex; align-items: center; border: 1px solid var(--sc-hairline-strong); border-radius: var(--sc-r-pill); }
.qty button {
  background: none; border: 0; color: var(--sc-ink-soft); cursor: pointer;
  width: 2rem; height: 2rem; line-height: 1; font-size: var(--sc-t-base);
  border-radius: var(--sc-r-pill);
}
.qty button:hover { color: var(--sc-ink); }
.qty output { font-family: var(--sc-font-mono); font-size: var(--sc-t-sm); min-width: 1.6rem; text-align: center; }

.catalog__note { margin-top: var(--sc-7); font-size: var(--sc-t-sm); }

/* ═══════════════════════════════════════════════════ act 5 · the rail ══ */

.rail-stage { display: flex; align-items: center; }
.rail { align-items: stretch; gap: var(--sc-5); padding-inline: var(--sc-gutter); }
/* Each card is lifted individually by the wave, so it needs its own layer and
   a starting state that matches where the wave begins. */
.rail > * { opacity: 0.12; will-change: opacity, transform; }
/* The sideways gesture is claimed on the horizontal axis only; the browser
   keeps the vertical one so the page still scrolls normally under a finger. */
/* SIDEWAYS ONLY, inside the rail.

   Andre: "since it goes side to side, it's more intuitive for someone to
   swipe their fingers side to side." It was pan-y, which handed the vertical
   axis to the browser - so a vertical swipe scrolled straight past a section
   that moves horizontally, and the gesture that matched the content was the
   one the browser could steal.

   pan-x gives this element's vertical axis to the page's JS instead. A
   horizontal drag is translated into page scroll by the handler in site.js,
   which is what advances the rail; a vertical drag over the stage now does
   nothing.

   THE TRAP THIS WOULD OTHERWISE CREATE. On a phone the stage fills the entire
   viewport - measured 0 to 664 on an iPhone 14, with no margin above or below
   - so "just swipe somewhere else" is not an escape, it is a dead end. A
   reader who only swipes up and down would be held in this section.

   So site.js marks the stage `data-rail-end` whenever the rail is at its start
   or its finish, and the vertical axis comes straight back. Sideways is the
   gesture in the MIDDLE of the section, where sideways is the only thing worth
   doing; at either edge the page behaves normally and you can leave. */
/* `none`, NOT `pan-x`. This is the difference between the rail working and
   the rail being infuriating, and pan-x was my mistake.

   pan-x does not mean "the app handles horizontal". It means the BROWSER may
   handle horizontal panning - so the moment a finger moves sideways the
   browser claims the gesture, fires pointercancel, and stops delivering
   pointermove. Instrumented on a real touch sequence: 15 touchmove events
   dispatched, only 3-4 pointermove seen by the page, then pointercancel on
   every single swipe. The rail was being told to stop listening a fifth of
   the way into every gesture.

   `none` gives the whole gesture to the page, which is correct here because
   the page is what turns it into scroll. The vertical escape at the two ends
   of the rail is unaffected - that is the [data-rail-end] rule below, still
   pan-y, so a reader is never trapped. */
/* ═══════════════════════════════════ the ring's arrows ═══════════════════
   Mobile only. On a desktop the wheel and a drag both work and the pointer
   already tells you the ring turns; on a phone there is nothing on screen
   that says "this moves" until you try it.

   A CHEVRON, not a filled triangle and not an arrow: two strokes meeting at a
   point, no shaft. Andre's words - "the first half of an arrow, like the
   triangle part ... it literally just doesn't have a line."

   Deliberately quiet. 26px of button around an 18px glyph at 38% white: big
   enough to hit with a thumb (the tap target is the button, not the glyph),
   faint enough that it reads as a hint rather than a control competing with
   the product. Sizes are here in one place if they need adjusting. */
.ring-nav {
  position: absolute;
  top: 39%;                           /* was 50%: sat about 27px below the ring's visual centre */
  transform: translateY(-50%);
  display: none;                      /* desktop: hidden, see the query below */
  place-items: center;
  width: 26px; height: 26px;
  padding: 0;
  border: 0;
  background: none;
  color: rgb(255 255 255 / 0.38);
  cursor: pointer;
  z-index: 4;
  -webkit-tap-highlight-color: transparent;
  transition: color 160ms var(--sc-ease-out, ease);
}
.ring-nav svg { width: 18px; height: 18px; display: block; }
.ring-nav--prev { left: 2px; }
.ring-nav--prev svg { transform: rotate(180deg); }
.ring-nav--next { right: 2px; }
.ring-nav:active { color: rgb(255 255 255 / 0.75); }
.ring-nav:focus-visible {
  outline: 2px solid rgb(255 255 255 / 0.5);
  outline-offset: 3px;
  border-radius: 50%;
}
@media (max-width: 700px) {
  .ring-nav { display: grid; }
}
@media (prefers-reduced-motion: reduce) {
  .ring-nav { transition: none; }
}

.act-rail .sc-stage, .act-rail [data-sc-stage] { touch-action: none; }
.act-rail [data-rail-end] { touch-action: pan-y; }
.rail__lead { flex: 0 0 min(30rem, 78vw); display: grid; align-content: center; gap: var(--sc-4); }
.rail__lead h2 { margin: 0; }

.tile {
  flex: 0 0 min(21rem, 74vw);
  display: flex; flex-direction: column;
  border: 1px solid var(--sc-hairline);
  border-radius: var(--sc-r-lg);
  background: color-mix(in oklab, var(--sc-surface) 86%, transparent);
  box-shadow: var(--sc-e2), var(--sc-edge);
  padding: var(--sc-5);
  gap: var(--sc-2);
}
.tile__n { font-family: var(--sc-font-display); font-size: var(--sc-t-xl); font-weight: 600; letter-spacing: var(--sc-track-snug); margin: 0; }
.tile__sub { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-accent); margin: 0; }
.tile__blurb { font-size: var(--sc-t-sm); color: var(--sc-ink-soft); margin: var(--sc-2) 0 0; line-height: 1.55; }
.tile__econ { margin-top: auto; padding-top: var(--sc-5); font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-ink-soft); }
.tile__econ b { color: var(--sc-ink); font-weight: 500; }
.tile__buy { display: flex; align-items: center; gap: var(--sc-3); padding-top: var(--sc-4); }
.tile__price { font-family: var(--sc-font-display); font-size: var(--sc-t-lg); font-weight: 600; }

/* ═══════════════════════════════════════════════════════════ act 6 ══════ */

.how { display: grid; gap: var(--sc-8); }
.how__intro { max-width: 46rem; }
.steps { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sc-5); grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
.step { border-top: 1px solid var(--sc-hairline-strong); padding-top: var(--sc-4); }
/* More space above a heading than below it: the gap belongs to the boundary. */
.step__h { font-family: var(--sc-font-display); font-size: var(--sc-t-lg); font-weight: 600; margin: 0 0 var(--sc-2); letter-spacing: var(--sc-track-snug); }

/* ═══════════════════════════════════════════════════════════ act 7 ══════ */

.close { display: grid; place-items: center; text-align: center; }
.close__inner { display: grid; gap: var(--sc-4); justify-items: center; padding-inline: var(--sc-gutter); }
.close__body { max-width: 38ch; margin-inline: auto; }
.foot {
  position: absolute; inset: auto 0 0 0;
  display: flex; align-items: center; justify-content: space-between; gap: var(--sc-5);
  padding: var(--sc-4) var(--sc-gutter);
  border-top: 1px solid var(--sc-hairline);
  font-size: var(--sc-t-xs); color: var(--sc-ink-soft);
  text-align: left; flex-wrap: wrap;
}
.foot__mark { font-family: var(--sc-font-display); font-weight: 600; color: var(--sc-ink); }
.foot__fine { max-width: 52ch; }
/* On a phone the footer wraps to 4-5 lines (180-240px) and is pinned to the
   bottom of the stage, so the centred call to action ran into it. Reserving the
   footer's height under the content centres the content in what is left. */
@media (max-width: 700px) {
  .close { padding-bottom: 11rem; box-sizing: border-box; }
}

/* ══════════════════════════════════════════════════════════ buttons ═════ */

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--sc-2);
  padding: 0.72rem 1.5rem;
  border-radius: var(--sc-r-pill);
  border: 1px solid transparent;
  background: none;
  font-size: var(--sc-t-sm); font-weight: 500;
  text-decoration: none; cursor: pointer;
  transition: transform var(--sc-d-fast) var(--sc-ease-out),
              background-color var(--sc-d-fast) var(--sc-ease-out),
              border-color var(--sc-d-fast) var(--sc-ease-out);
}
.btn--solid { background: var(--sc-accent); color: var(--sc-accent-ink); box-shadow: var(--sc-e1); }
.btn--solid:hover { background: color-mix(in oklab, var(--sc-accent) 86%, white); }
.btn--ghost { border-color: var(--sc-hairline-strong); color: var(--sc-ink); }
.btn--ghost:hover { border-color: var(--sc-ink-soft); }
.btn--lg { padding: 0.95rem 2rem; font-size: var(--sc-t-base); }
.btn--full { width: 100%; }
.btn--sm { padding: 0.5rem 1rem; }
.btn[disabled] { opacity: 0.5; cursor: default; }

/* ---- the press ----------------------------------------------------------
   A button that navigates used to give nothing back: you clicked, and the
   next page arrived with no moment in between that said the click had landed.
   The transform transition was already on .btn — there was simply never an
   :active state to trigger it, and there was not one anywhere in this
   stylesheet.

   0.965 rather than something springier. This is feedback, not a toy: the eye
   should register that the thing moved without the layout appearing to wobble.

   .is-pressed is the same state held by shared.js for the ~130ms it delays a
   navigation, because :active is released the instant the pointer lifts and
   the whole point is to still be pressed while the page is leaving. */
.btn:active, .btn.is-pressed,
.ring-detail__act .btn:active, .ring-detail__act .btn.is-pressed,
.pick button:active {
  transform: scale(0.965);
}
.btn[disabled]:active { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .btn:active, .btn.is-pressed,
  .ring-detail__act .btn:active, .ring-detail__act .btn.is-pressed,
  .pick button:active { transform: none; }
}
@media (max-width: 860px) { .btn { min-height: 44px; } .qty button { width: 44px; height: 44px; } }

/* ═══════════════════════════════════════════════════ drawer · liquid glass ══
   A floating glass panel, inset from the edges rather than welded to them, so
   it reads as an object over the page instead of a wall bolted to the side.

   THE BUG THIS ALSO FIXES: the address form used `grid-template-columns:
   1fr 1fr`, and a grid item defaults to `min-width: auto` — it will not shrink
   below its content's intrinsic width. A text input's intrinsic width is its
   `size` attribute, about 170px plus padding, so two of them plus the gap
   could not fit the panel and the right-hand column ran off the screen. Every
   grid child here sets `min-width: 0`. */

.drawer { position: fixed; inset: 0; z-index: 90; }
.drawer[hidden] { display: none; }
.drawer__scrim {
  position: absolute; inset: 0;
  background: hsl(var(--sc-shadow-color) / 0.55);
  backdrop-filter: blur(6px) saturate(0.9);
}

.drawer__panel {
  position: absolute;
  top: var(--sc-4); right: var(--sc-4); bottom: var(--sc-4);
  width: min(34rem, calc(100vw - var(--sc-6)));
  display: flex; flex-direction: column;
  border-radius: 26px;
  overflow: hidden;                       /* clip the glass to the radius */

  /* The glass itself: a translucent ground, a real blur behind it, and a
     bright hairline on the lip. The lip is what sells it — glass catches light
     on its edge, and without that this is just a dark box with blur. */
  background:
    linear-gradient(160deg,
      rgb(255 255 255 / 0.10) 0%,
      rgb(255 255 255 / 0.04) 30%,
      rgb(255 255 255 / 0.02) 100%),
    color-mix(in oklab, var(--sc-surface) 74%, transparent);
  backdrop-filter: blur(28px) saturate(1.5);
  -webkit-backdrop-filter: blur(28px) saturate(1.5);
  border: 1px solid rgb(255 255 255 / 0.14);
  box-shadow:
    0 30px 80px -20px hsl(var(--sc-shadow-color) / 0.7),
    inset 0 1px 0 rgb(255 255 255 / 0.22),
    inset 0 -1px 0 rgb(255 255 255 / 0.05);
  animation: drawer-in var(--sc-d-slow) var(--sc-ease-drawer);
}
@keyframes drawer-in { from { transform: translateX(calc(100% + var(--sc-6))); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .drawer__panel { animation: none; } }

.drawer__head {
  display: flex; align-items: center; justify-content: space-between;
  padding: var(--sc-5) var(--sc-5) var(--sc-4);
  border-bottom: 1px solid rgb(255 255 255 / 0.08);
  flex: none;
}
.drawer__h { font-family: var(--sc-font-display); font-size: var(--sc-t-lg); font-weight: 600; margin: 0; }
.drawer__x {
  background: rgb(255 255 255 / 0.07);
  border: 1px solid rgb(255 255 255 / 0.12);
  border-radius: var(--sc-r-pill);
  padding: 0.35rem 0.85rem;
  color: var(--sc-ink-soft); cursor: pointer; font-size: var(--sc-t-xs);
  transition: background-color var(--sc-d-fast) var(--sc-ease-out), color var(--sc-d-fast) var(--sc-ease-out);
}
.drawer__x:hover { background: rgb(255 255 255 / 0.14); color: var(--sc-ink); }

/* The scroll region. A mask fades the content out at both ends instead of
   letting it collide with the header and the totals, which is what made the
   panel read as "cut off" rather than as scrollable. */
.drawer__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--sc-5);
  scrollbar-width: thin;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 22px), transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0, #000 18px, #000 calc(100% - 22px), transparent 100%);
}
.drawer__empty { color: var(--sc-ink-soft); font-size: var(--sc-t-sm); }

.drawer__foot {
  flex: none;
  padding: var(--sc-4) var(--sc-5) var(--sc-5);
  border-top: 1px solid rgb(255 255 255 / 0.08);
  background: rgb(255 255 255 / 0.03);
  display: grid; gap: var(--sc-3);
}
.drawer__fine { font-size: var(--sc-t-xs); color: var(--sc-ink-soft); margin: 0; text-align: center; }

/* ---- the bag lines, each its own small glass tile ---- */
.lines { list-style: none; margin: 0 0 var(--sc-5); padding: 0; display: grid; gap: var(--sc-3); }
.line {
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  gap: 0.15rem var(--sc-4); align-items: center;
  padding: var(--sc-4);
  border-radius: 16px;
  background: rgb(255 255 255 / 0.05);
  border: 1px solid rgb(255 255 255 / 0.08);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.10);
}
.line > * { min-width: 0; }
.line__n { font-weight: 500; }
.line__q { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-ink-soft); }
.line__p { font-family: var(--sc-font-mono); }
.line__x {
  grid-column: 2; background: none; border: 0; color: var(--sc-ink-soft);
  font-size: var(--sc-t-xs); cursor: pointer; padding: 0; text-align: right;
}
.line__x:hover { color: var(--sc-accent); }

.total { font-variant-numeric: tabular-nums; display: flex; justify-content: space-between; align-items: baseline; font-family: var(--sc-font-mono); }
.total strong { font-family: var(--sc-font-display); font-size: var(--sc-t-xl); font-weight: 600; }

/* ---- the address form ---- */
.ship {
  display: grid; gap: var(--sc-3);
  border-top: 1px solid rgb(255 255 255 / 0.08);
  padding-top: var(--sc-5);
}
.ship__h { font-family: var(--sc-font-display); font-size: var(--sc-t-base); font-weight: 600; margin: 0 0 var(--sc-1); }
.ship__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sc-3); }
.ship__row > * { min-width: 0; }          /* the blowout fix */
.ship__err { color: #FF9A8A; font-size: var(--sc-t-sm); margin: 0; }

.field { display: grid; gap: 0.32rem; min-width: 0; }
.field > span { font-size: var(--sc-t-xs); color: var(--sc-ink-soft); font-family: var(--sc-font-mono); letter-spacing: 0.02em; }
.field > span em { font-style: normal; opacity: 0.6; }
.field input, .field select {
  width: 100%;
  min-width: 0;                            /* an input will not shrink without this */
  background: rgb(255 255 255 / 0.06);
  border: 1px solid rgb(255 255 255 / 0.12);
  border-radius: 13px;
  padding: 0.68rem 0.8rem;
  color: var(--sc-ink);
  min-height: 46px;
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.07);
  transition: background-color var(--sc-d-fast) var(--sc-ease-out), border-color var(--sc-d-fast) var(--sc-ease-out);
}
.field input:hover, .field select:hover { background: rgb(255 255 255 / 0.09); }
.field input:focus-visible, .field select:focus-visible {
  border-color: color-mix(in oklab, var(--sc-accent) 70%, transparent);
  background: rgb(255 255 255 / 0.10);
  outline: none;
}
.field input::placeholder { color: color-mix(in oklab, var(--sc-ink) 30%, transparent); }
.check { display: flex; align-items: center; gap: var(--sc-2); font-size: var(--sc-t-sm); color: var(--sc-ink-soft); margin-top: var(--sc-1); }
.billing { display: grid; gap: var(--sc-3); margin-top: var(--sc-3); }

@media (max-width: 560px) {
  .drawer__panel {
    top: auto; right: 0; bottom: 0; left: 0;
    width: auto; max-height: 92svh;
    border-radius: 26px 26px 0 0;
  }
  @keyframes drawer-in { from { transform: translateY(100%); opacity: 0; } }
  .ship__row { grid-template-columns: 1fr; }
}

.sums {
  display: grid; grid-template-columns: 1fr auto;
  gap: 0.3rem var(--sc-4); margin: 0 0 var(--sc-2);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft);
}
.sums dt, .sums dd { margin: 0; }
.sums dd { text-align: right; color: var(--sc-ink); }

/* The partner code row (built by bag.js above the sums). */
.code { margin: 0 0 var(--sc-2); }
.code__l { display: block; font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft); letter-spacing: 0.04em; margin: 0 0 0.3rem; }
.code__row { display: flex; gap: 0.4rem; }
.code__in { flex: 1; min-width: 0; font: inherit; font-family: var(--sc-font-mono); text-transform: uppercase;
  padding: 0.55rem 0.7rem; border-radius: 10px; color: var(--sc-ink);
  background: color-mix(in oklab, var(--sc-ink) 6%, transparent);
  border: 1px solid color-mix(in oklab, var(--sc-ink) 16%, transparent); }
.code__in:focus-visible { outline: 2px solid var(--sc-accent); outline-offset: 1px; }
.code__go { font: inherit; font-size: var(--sc-t-xs); padding: 0 0.9rem; border-radius: 10px; cursor: pointer;
  color: var(--sc-ink); background: none; border: 1px solid color-mix(in oklab, var(--sc-ink) 24%, transparent); }
.code__go:hover { border-color: var(--sc-ink-soft); }
.code__note { margin: 0.3rem 0 0; min-height: 1em; font-size: var(--sc-t-xs); color: var(--sc-ink-soft); }
.code__note:empty { display: none; }

/* ═══════════════════════════════════════════════════ the pointer glow ═════
   A third of the size the engine's act spotlight was, and page-level so it
   never meets an edge to cut against. Soft all the way out: a gradient that
   still has substance at its last stop draws a visible disc, which is the
   thing that reads as a cut rather than a glow. */
.cursorglow {
  position: fixed; left: 0; top: 0;
  width: 9rem; height: 9rem;        /* radius 4.5rem. Was 18, was 28 before that */
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  will-change: transform, opacity;
  background: radial-gradient(closest-side,
    color-mix(in oklab, var(--sc-accent) 15%, transparent) 0%,
    color-mix(in oklab, var(--sc-accent) 7%, transparent) 42%,
    color-mix(in oklab, var(--sc-accent) 2%, transparent) 70%,
    transparent 100%);
}
@media (hover: none), (prefers-reduced-motion: reduce) { .cursorglow { display: none; } }

/* The crawl needs somewhere to lie down into. Perspective on the stage rather
   than on each line, so all three share one vanishing point and read as one
   plane instead of three separately-tilted blocks. */
.hero { perspective: 900px; perspective-origin: 50% 0%; }
.hero__fade { transform-origin: 50% 0%; }

/* ═════════════════════════════════════════════════ the solo product ═══════
   One product is a feature, not a one-cell grid. Left in the auto-fill grid
   the card keeps a single column's width and leaves two thirds of the row
   empty. The card turns on its side instead: object beside the copy. */

.grid--solo { grid-template-columns: 1fr; max-width: 62rem; margin-inline: auto; }
.grid--solo .card {
  display: grid;
  grid-template-columns: minmax(0, 44%) minmax(0, 1fr);
  grid-template-rows: 1fr auto;
}
.grid--solo .card > * { min-width: 0; }
.grid--solo .card__plate {
  grid-column: 1; grid-row: 1 / 3;
  aspect-ratio: auto; min-height: 25rem;
  border-bottom: 0;
  border-right: 1px solid var(--sc-hairline);
}
.grid--solo .card__body { grid-column: 2; grid-row: 1; padding: var(--sc-6); }
.grid--solo .card__buy  { grid-column: 2; grid-row: 2; padding: var(--sc-5) var(--sc-6); }
.grid--solo .card__n     { font-size: var(--sc-t-xl); }
.grid--solo .card__blurb { font-size: var(--sc-t-base); max-width: 46ch; }
.grid--solo .card__price { font-size: var(--sc-t-xl); }

/* A supplier photo still on its own white background sits on a light plate so
   it reads as a photograph rather than a white rectangle on black. Only for
   SKUs without a 3D model. */
/* The lit plate exists for photographs shot on white: without it a white
   rectangle is pasted onto a dark card. It is scoped to :not(:has(canvas))
   because products that carry a 3D MODEL now also emit their photograph as a
   stand-in (see cardHTML). When the model mounts, the card must look exactly
   as it always did - dark plate, object floating - so the light background
   must not fire. When the context budget refuses the model, the photo shows
   and the plate lights up behind it. The canvas is the condition, again. */
.card__plate--lit:not(:has(canvas)) { background: linear-gradient(168deg, #F4F6FA, #DCE3ED 78%); }
.card__plate--lit:not(:has(canvas)) .card__shot { inset: 4%; max-width: 92%; max-height: 92%;
  filter: drop-shadow(0 10px 14px rgb(18 26 44 / 0.22)); }
.card__plate--lit:not(:has(canvas)) .card__flag { background: rgb(255 255 255 / 0.82);
  border-color: rgb(18 26 44 / 0.16); color: #35405A; }

/* ══════════════════════════════════════ nothing is selectable ═════════════
   The page is not a document to be lifted. Copy is off everywhere by default,
   which does two jobs at once:

     · the COPY one. The product copy, the spec, the margin arithmetic and the
       reviews are the work; a competitor should have to retype them.
     · the DRAG one, which is the reason it went in. Dragging The Line to turn
       the ring used to smear a blue selection across every caption it passed,
       because a horizontal drag over text IS a text selection as far as the
       browser is concerned. `user-select: none` is the fix for that, not a
       pile of preventDefault() in the pointer handlers.

   THREE CARVE-OUTS, and each is deliberate.

   Anything the customer TYPES stays selectable — you cannot correct an address
   in a field you cannot select a word in, and a checkout that fights the
   person paying is worse than a copied paragraph.

   Anything the customer must TAKE AWAY stays selectable: the order number and
   the tracking number. Locking those down does not protect anything; it just
   means somebody transcribes LP7D844EC4A6CN by eye, gets a character wrong,
   and emails you about it. That is a support cost paid to stop nothing.

   And this is a nuisance, not a lock. Anyone who wants the text can read the
   source, and it is deliberately not defended with contextmenu handlers or
   key traps — those break find-in-page and refresh for real customers while
   stopping nobody who was actually going to take it. */

html, body {
  -webkit-user-select: none;
  user-select: none;
  /* The iOS long-press menu. Holding a finger on the ring to turn it brought
     up Copy / Look Up / Share, which made a drag gesture feel broken. */
  -webkit-touch-callout: none;
}

/* Photographs cannot be dragged out of the page as a file either. */
img, .plate3d, .obj3d, canvas {
  -webkit-user-drag: none;
  user-drag: none;
}

/* What a person types, and what a person has to keep. */
input, textarea, select, [contenteditable="true"],
.order__ref, .order__track b, [data-selectable] {
  -webkit-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
}

/* Selected text inside those, in the page's own colours rather than the
   browser's default blue on near-black. */
::selection {
  background: color-mix(in oklab, var(--sc-accent) 34%, transparent);
  color: var(--sc-ink);
}

/* ═══════════════════════════════════ the plate, in three dimensions ═══════
   The product is a flat slab, which is the one shape CSS 3D models honestly:
   two photographed faces at plus and minus half the thickness, four edge slabs
   between them. The faces ARE the supplier's photographs, rectified square, so
   what turns is the real object.

   Thickness is 15mm on a 120mm plate — 12.5% of the width, set in px by
   site.js from the measured body so it holds at any card size. */

.plate3d {
  /* Inset, not flush: turned side-on the near edge projects wider than its own
     box, and the card clips at its rounded corner. */
  position: absolute; inset: 7%;
  perspective: 1400px;
  perspective-origin: 50% 46%;
  touch-action: pan-y;              /* a vertical drag still scrolls the page */
  cursor: grab;
}
.plate3d[data-dragging] { cursor: grabbing; }

.plate3d__body {
  position: absolute;
  left: 50%; top: 50%;
  translate: -50% -50%;              /* composes BEFORE transform, which the JS owns */
  /* Size is set in PIXELS by site.js from the measured host, and deliberately
     not in CSS. A percentage here is circular (the old auto grid track sized
     to its own absolutely-positioned content and collapsed), and container
     units brought their own resolution problems. One measured number, written
     on build and on resize, is the thing that cannot be argued with. */
  width: 60px; height: 60px;         /* placeholder until the first measure */
  transform-style: preserve-3d;
  will-change: transform;
}

.plate3d__face {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  backface-visibility: hidden;
  border-radius: 8.5%;
}
/* HOW THE PART IS ACTUALLY BUILT, and it is the wrong way round from the
   obvious reading: the two stickers are at the BACK, and the polymer sits in
   front of both of them. The 3M adhesive is the rearmost layer, the printed
   Google face is stuck just in front of it, and everything you look at, you
   look at THROUGH the plastic. So the printed face is parked near the back of
   the slab rather than on its front surface, and the clear cover below owns
   the front. That is why the graphic reads as embedded rather than stuck on. */
.plate3d__face--front { transform: translateZ(calc(var(--t, 8px) / -2 + var(--t, 8px) * 0.16)); }
.plate3d__face--back  { transform: translateZ(calc(var(--t, 8px) / -2)) rotateY(180deg); }

/* The polymer itself: the front surface of the slab. Clear, so the artwork
   behind it shows through, with only the faintest body tint and the sheen a
   polished face catches. */
.plate3d__cover {
  position: absolute; inset: 0;
  border-radius: 8.5%;
  transform: translateZ(calc(var(--t, 8px) / 2));
  background:
    linear-gradient(150deg,
      rgb(232 242 255 / 0.10) 0%,
      rgb(210 226 246 / 0.03) 34%,
      rgb(255 255 255 / 0.00) 62%),
    rgb(226 238 255 / 0.02);
  box-shadow: inset 0 0 0 1px rgb(240 248 255 / 0.16);
  pointer-events: none;
}

/* The 15mm rim. This is the only part of the object that is actually glass —
   the faces are printed and opaque — so it is mostly TRANSPARENT, with light
   concentrated at the two ends the way a real slab pipes it, and a bright
   hairline on the arris where the polish catches. Opaque here would read as a
   plastic sandwich rather than a block you can see into. */
/* The rim: the polymer seen edge-on, and the only part of the object that is
   actually clear plastic — the faces are printed and opaque.

   Built from many small facets around the rounded-rect perimeter rather than
   four slabs, one per side. Four slabs stop where each corner's curve begins
   and leave a flat chamfer across it, so the silhouette reads as an octagon
   however round the faces themselves are. The facets are generated in
   shared.js; this only says what one looks like. */
.plate3d__rim { position: absolute; inset: 0; transform-style: preserve-3d; pointer-events: none; }
.plate3d__facet {
  position: absolute;
  left: 50%; top: 50%;
  background: linear-gradient(0deg,
    rgb(228 242 255 / 0.50) 0%,
    rgb(178 204 234 / 0.15) 26%,
    rgb(152 182 216 / 0.10) 50%,
    rgb(178 204 234 / 0.15) 74%,
    rgb(228 242 255 / 0.50) 100%);
}

/* The gloss. A narrow band that slides across the face as the plate turns:
   the single thing that makes it read as polished acrylic rather than print. */
.plate3d__gloss {
  position: absolute; inset: 0;
  border-radius: 8.5%;
  transform: translateZ(calc(var(--t, 8px) / 2 + 0.5px));
  background: linear-gradient(105deg,
    transparent calc(var(--gloss, 50%) - 24%),
    rgb(255 255 255 / 0.08) calc(var(--gloss, 50%) - 8%),
    rgb(255 255 255 / 0.24) var(--gloss, 50%),
    rgb(255 255 255 / 0.06) calc(var(--gloss, 50%) + 8%),
    transparent calc(var(--gloss, 50%) + 24%));
  /* NO mix-blend-mode. It is a grouping property, and a grouping property on a
     child of a `transform-style: preserve-3d` element forces the whole context
     to flatten: every translateZ collapses to zero, the four edge slabs fall
     onto the face plane, and 15mm of acrylic renders as nothing when the plate
     turns side-on. A plain white gradient over a bright printed face reads the
     same and keeps the geometry 3D. */
  pointer-events: none;
  backface-visibility: hidden;
}

/* The contact shadow: what says the plate is ABOVE something rather than
   pasted on it. Breathes opposite the levitation. */
.plate3d__shadow {
  position: absolute;
  left: 50%; bottom: 3%;
  width: 42%; height: 4%;
  translate: -50% 0;
  border-radius: 50%;
  background: radial-gradient(closest-side, hsl(var(--sc-shadow-color) / 0.55), transparent);
  will-change: transform, opacity;
}
.card__plate--lit .plate3d__shadow {
  background: radial-gradient(closest-side, rgb(40 52 74 / 0.3), transparent);
}

.plate3d__hint {
  position: absolute; left: 50%; bottom: 2%;
  translate: -50% 0;
  font-family: var(--sc-font-mono); font-size: 0.62rem;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--sc-ink-soft);
  opacity: 0; transition: opacity var(--sc-d-slow) var(--sc-ease-out);
  pointer-events: none;
}
.plate3d:hover .plate3d__hint { opacity: 0.7; }

/* ═══════════════════════════════ the other three forms ════════════════════
   The slab above is exact for a flat part. Three more, so that every product
   in the catalogue is an OBJECT on the page rather than a photograph of one,
   and so that importing a link does not mean choosing between a turntable
   that lies about the geometry and a jpeg on a dark rectangle.

   All three reuse .plate3d's stage, body, shadow and hint: the levitation,
   the breathing shadow and the drag are properties of the stage, not of the
   shape standing on it. */

/* ---- back face when the listing photographed only one side ---------------
   Not a mirrored copy of the front, which is the obvious cheat and is wrong
   in a way people notice immediately — printing reads reversed and the part
   turns into its own reflection. A blank card in the polymer's own tint is
   what the back of an unprinted part actually looks like. */
.plate3d__face--blank {
  transform: translateZ(calc(var(--t, 8px) / -2)) rotateY(180deg);
  background: linear-gradient(160deg, rgb(226 238 255 / 0.10), rgb(160 184 214 / 0.06));
  box-shadow: inset 0 0 0 1px rgb(240 248 255 / 0.10);
}

/* ---- a solid-bodied object ----------------------------------------------
   The default rim is clear polymer seen edge-on. A steel case, a painted tin
   or a brick is not, and a translucent rim on an opaque object reads as a
   modelling error rather than as material. */
.obj3d--solid .plate3d__facet {
  background: linear-gradient(0deg,
    rgb(255 255 255 / 0.30) 0%,
    rgb(120 132 152 / 0.55) 22%,
    rgb(84 94 112 / 0.62) 50%,
    rgb(120 132 152 / 0.55) 78%,
    rgb(255 255 255 / 0.30) 100%);
}
.obj3d--solid .plate3d__cover { background: none; box-shadow: none; }

/* ═══ form: cylinder ══════════════════════════════════════════════════════
   A bottle, a tin, a can, a tube. The label wraps a drum built out of chords
   of the circle — the segment transforms are written by shared.js, where the
   measured radius exists — and the light does NOT rotate with it. A specular
   that turns with the object is the single tell that a CSS cylinder is a
   cylinder of stickers; a fixed one in screen space is what a real curved
   surface under a real lamp does. */
.obj3d--cylinder .plate3d__body { transform-style: preserve-3d; }

.obj3d__drum {
  position: absolute; left: 50%; top: 50%;
  width: 0; height: 0;
  transform-style: preserve-3d;
  pointer-events: none;
}
.obj3d__seg {
  position: absolute; left: 0; top: 0;
  background-repeat: no-repeat;
  /* Each chord is a hair wider than its neighbour's edge so the seams close.
     Without it a drum shows a hairline of the dome between every segment,
     which reads as a barcode wrapped round the product. */
  outline: 0.5px solid transparent;
  backface-visibility: hidden;
}
.obj3d__cap {
  position: absolute; left: 50%; top: 0;
  width: var(--w); height: auto;
  translate: -50% -46%;
  transform: rotateX(78deg);
  pointer-events: none;
}
/* The lamp. Fixed to the screen, not to the drum. */
.obj3d__lit {
  position: absolute; inset: 0;
  border-radius: 50% / 8%;
  background:
    linear-gradient(100deg,
      rgb(0 0 0 / 0.34) 0%,
      rgb(0 0 0 / 0.10) 16%,
      rgb(255 255 255 / 0.16) 38%,
      rgb(255 255 255 / 0.05) 54%,
      rgb(0 0 0 / 0.16) 78%,
      rgb(0 0 0 / 0.40) 100%);
  pointer-events: none;
}

/* ═══ form: frames ════════════════════════════════════════════════════════
   A real turntable: N photographs of one object at even angles, and rotating
   it swaps which one is showing. There is no perspective and no transform on
   the body, because there is nothing to transform — the geometry is in the
   photographs. */
.obj3d--frames { perspective: none; }
/* ─────────────────────────────────────────────── choosing a size ──────
   A wrist is not a standard size, and a bracelet that does not fit is a
   return whose postage costs more than the margin on the sale. So the
   four lengths sit next to the Add button rather than behind a select:
   what is chosen is readable without opening anything, which is the only
   way a visible default is honest. */
/* The buy row is a flex line, so a picker dropped into it would sit BESIDE
   the stepper and shove the Add button off the edge of a narrow card — which
   is exactly what it did: 605px of content in a 600px window. The choice
   belongs above the controls, not next to them, so it claims a whole line and
   the row wraps under it. */
.card__buy, .tile__buy, .pdp__buy { flex-wrap: wrap; }
.pick { display: block; flex: 1 0 100%; margin: 0 0 var(--sc-3); }
.pick__l {
  display: block;
  font: 500 0.72rem/1 var(--sc-font-text);
  letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--sc-dim, #8b8f9a); margin-bottom: 0.5rem;
}
.pick__row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.pick__b {
  appearance: none; cursor: pointer;
  padding: 0.44rem 0.72rem;
  border-radius: 999px;
  border: 1px solid color-mix(in oklab, currentColor 22%, transparent);
  background: color-mix(in oklab, var(--sc-canvas, #0b0c10) 60%, transparent);
  color: inherit;
  font: 500 0.86rem/1 inherit;
  transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.pick__b:hover { border-color: color-mix(in oklab, currentColor 45%, transparent); }
.pick__b.is-on {
  background: var(--sc-accent, #a8c7ff);
  border-color: var(--sc-accent, #a8c7ff);
  color: #06070a;
}
.pick__h {
  display: block; margin-top: 0.45rem;
  font-size: 0.78rem; color: var(--sc-dim, #8b8f9a);
}
/* The chosen length, on its line in the bag. */
.line__opt {
  font: 500 0.78rem/1 var(--sc-font-text);
  color: var(--sc-dim, #8b8f9a);
  margin-left: 0.35rem;
}
/* The delivered total, said before anybody clicks anything. */
.pdp__allin {
  margin: var(--sc-3) 0 0;
  font-size: 0.9rem; line-height: 1.5;
  color: var(--sc-dim, #8b8f9a);
}
.pdp__allin b { color: inherit; filter: brightness(1.5); }

/* BUYING A FEW. Quiet by design: this is a table of what things cost, not an
   upsell. No badge, no "most popular", no colour shouting at a row - the only
   emphasis is on the quantity where shipping stops being charged, because that
   is a fact about the order rather than a nudge about it. */
.pdp__batch { margin-top: var(--sc-5); }
.pdp__batch-h {
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--sc-ink-3); margin: 0 0 var(--sc-2); font-weight: 500;
}
.pdp__batch-t {
  width: 100%; max-width: 26rem; border-collapse: collapse;
  font-variant-numeric: tabular-nums; font-size: var(--sc-t-sm);
}
.pdp__batch-t th[scope="col"] {
  text-align: right; font-weight: 500; color: var(--sc-ink-3);
  font-size: var(--sc-t-xs); padding: 0 0 0.35rem;
}
.pdp__batch-t th[scope="col"]:first-child { text-align: left; }
.pdp__batch-t th[scope="row"] { text-align: left; font-weight: 500; color: var(--sc-ink-2); }
.pdp__batch-t td { text-align: right; color: var(--sc-ink-2); }
.pdp__batch-t th, .pdp__batch-t td {
  padding: 0.4rem 0; border-bottom: 1px solid var(--sc-hairline);
}
.pdp__batch-t tr:last-child th, .pdp__batch-t tr:last-child td { border-bottom: 0; }
.pdp__batch-t tr.is-free th[scope="row"], .pdp__batch-t tr.is-free td { color: var(--sc-ink); }
.pdp__batch-n {
  font-size: var(--sc-t-xs); color: var(--sc-ink-3);
  margin: var(--sc-2) 0 0; max-width: 34rem; line-height: 1.55;
}
.pdp__batch-n a { color: var(--sc-accent); }

.obj3d--loop .plate3d__body { transform-style: preserve-3d; }
/* The ring is a point at the centre of the body that its links hang off, the
   same construction as the drum: zero-sized, so a link's transform starts from
   the middle rather than from a corner. */
.obj3d__loop {
  position: absolute; left: 50%; top: 50%;
  width: 0; height: 0;
  transform-style: preserve-3d;
  pointer-events: none;
}
.obj3d__link {
  position: absolute; left: 0; top: 0;
  transform-style: preserve-3d;
  /* Every link is the same sprite, so one drop-shadow per link would be 56
     shadow passes for one object. The shadow under the whole bracelet is the
     plate's own, which is one.

     And NO `will-change: transform`. It was here out of habit and it was
     costing real money on a phone: a link's transform is written once when the
     ring is built and never touched again — only the PARENT animates — so all
     the hint did was promote fifty-six static elements to their own compositing
     layers, per instance, on a device with the least memory bandwidth to spare.
     The hint belongs on the thing that actually moves. */
}
.obj3d__face {
  position: absolute; inset: 0;
  background-repeat: no-repeat;
  background-size: 100% 100%;
}
/* The side of a setting. Not a tinted silhouette of the top — a rendering of
   the actual side, standing at the edge of the link as a real face in real 3D
   space, which is the same thing the card's rim does and the reason the card
   reads as an object rather than as a picture of one.

   Both faces are drawn: from outside the ring you see the outer wall, and on
   the near arc you are looking down into the ring at the inner one. A wall
   with a hidden backface would leave that arc open. */
.obj3d__wall {
  /* CENTRED ON THE LINK, not on its corner.

     `left:0` is the link's top-left, and the link's own centre is half a tile
     away from that — so every wall was being built about 21px off its stone in
     both axes, then rotated about that wrong point. On a 129px ring that threw
     the whole band a link-width clear of the settings and produced the thing
     that looked like a second bracelet orbiting the first. The geometry, the
     lighting and the texture were all right; the anchor was wrong. */
  position: absolute; left: 50%; top: 50%;
  background-repeat: no-repeat;
  background-size: 100% 100%, 100% 100%;
  backface-visibility: visible;
  /* The render on top of SOLID SILVER, not on top of the page.

     A brilliant cut seen edge-on is mostly transparent, so the side render has
     real holes in it — around the girdle, past the culet — and a wall made of
     that alone tiled into a row of bright blocks with the night sky showing
     between them. A bracelet is not perforated. The gradient underneath is the
     body of the setting: bright along the top lip where it catches the light,
     falling to a shadowed base, so the band reads as one piece of metal with
     the stones set into it rather than as fifty-six separate pieces. */
  background-image:
    var(--side, none),
    linear-gradient(to bottom, #c9ccd2 0%, #a2a7b0 36%, #767c86 74%, #4d525a 100%);
  /* Dimmer than the stones on purpose. Lit to the same value it read as a
     second bright ring beside them rather than as the shadowed flank of one
     object — the side of a solid is always darker than the face catching the
     light, and that difference is most of what tells an eye it is looking at
     one thing rather than two. */
}

.obj3d--frames .plate3d__body { transform-style: flat; }
.obj3d__reel { position: absolute; inset: 0; }
.obj3d__frame {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  opacity: 0;
  /* No transition. A cross-fade between two adjacent angles of a turntable is
     a double exposure, and at drag speed it smears the object into a ghost.
     A hard swap is what a turntable actually is. */
  filter: drop-shadow(0 18px 26px rgb(0 0 0 / 0.5));
}
.obj3d__frame.is-on { opacity: 1; }

/* ═══ form: extrude ═══════════════════════════════════════════════════════
   A cut-out given thickness by repeating its own silhouette back through Z.

   The front slice is the photograph. Every slice behind it is that same shape
   drawn as flat material through a mask — which is both cheaper than stacking
   filtered copies of the image and more correct: the side of a solid object is
   not a faded picture of its front, it is material turned away from the light.

   The darkening runs on --k, which is 0 at the front and 1 at the back, so the
   body reads as a form receding rather than as a deck of cards. */
.obj3d--extrude { perspective: 1500px; }
.obj3d--extrude .plate3d__body { transform-style: preserve-3d; }

.obj3d__body3d {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
}

.obj3d__slice {
  position: absolute; inset: 0;
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  /* Warm dark rather than neutral: this is the shadowed side of a gold-plated
     object, and neutral grey there reads as plastic. The hue comes from the
     product's own --hue so a silver piece does not get a gold interior. */
  /* Lit metal in shadow, not black. The first pass ran 30% down to 6%
     lightness, which on a near-black page meant the whole extrusion was
     invisible — technically present, visually absent. It falls from a lit
     edge to a deep but still readable back. */
  background:
    hsl(var(--hue, 44) 42% calc(46% - var(--k) * 28%) / calc(1 - var(--k) * 0.05));
  backface-visibility: hidden;
  pointer-events: none;
}

/* The front face sits proud of the stack and keeps the photograph's own
   colour. A drop-shadow here would double with the stage's, so the depth is
   doing that job instead. */
.obj3d--extrude .obj3d__cut--front { filter: none; }

/* The lip. One bright hairline where the front face meets the extruded side,
   which is what makes a rendered solid look machined rather than moulded. */
.obj3d__body3d::after {
  content: "";
  position: absolute; inset: 0;
  transform: translateZ(calc(var(--depth, 6px) * -0.04));
  background: hsl(var(--hue, 44) 60% 70% / 0.16);
  -webkit-mask-image: var(--cut); mask-image: var(--cut);
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  pointer-events: none;
}

/* ═══ form: float ═════════════════════════════════════════════════════════
   One photograph, cut out of its background, levitating. It does not claim to
   turn: the sway is a few degrees of parallax, which is what a suspended
   object does when the room moves, and stopping there is the difference
   between a floating object and a picture being waggled.

   The sheen is masked by the cut-out's own alpha, so the light lands on the
   product's silhouette rather than on the rectangle it was cropped from —
   that mask is the entire reason this reads as an object at all. */
.obj3d--float { perspective: 1600px; }
.obj3d__cut {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 22px 30px rgb(0 0 0 / 0.55));
  pointer-events: none;
}
.obj3d__sheen {
  position: absolute; inset: 0;
  background: linear-gradient(104deg,
    transparent calc(var(--gloss, 50%) - 26%),
    rgb(255 255 255 / 0.06) calc(var(--gloss, 50%) - 9%),
    rgb(255 255 255 / 0.20) var(--gloss, 50%),
    rgb(255 255 255 / 0.05) calc(var(--gloss, 50%) + 9%),
    transparent calc(var(--gloss, 50%) + 26%));
  -webkit-mask-image: var(--cut); mask-image: var(--cut);
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  pointer-events: none;
}

/* No ground behind the product. The lit field is the fallback for a card with
   no model; once the real object is floating there, a panel behind it reads as
   a box the product is stuck in. */
.card__plate--3d {
  background: none;
  border-right-color: transparent;
  border-bottom-color: transparent;
}
.card__plate--3d .card__glyph { display: none; }

/* THE STAND-IN, and why it is CSS rather than JavaScript.

   Every card that has a photograph now renders it, including the cards that
   carry a 3D model (see cardHTML in site.js). The moment a real canvas exists
   inside the plate, the photograph gets out of its way — so a card whose 3D
   mounted looks exactly as it always did, and a card whose 3D was REFUSED by
   the context budget in viewer.js shows the product shot instead of an empty
   box. No mount callback, no class juggling, no state to get out of sync:
   the presence of the canvas is the condition.

   :has() is already load-bearing in this file (see .card:has(> .rim)), so this
   adds no new support requirement. */
.card__plate--3d:has(canvas) .card__shot,
.ap-card:has(canvas) .ap-card__shot { display: none; }

/* At the peak the plate is decoration at that size: no drag, no hint. */
.ap-card .plate3d { cursor: default; inset: 4%; }

.ap-card--solo {
  width: clamp(200px, 24vw, 320px);
  box-shadow: var(--sc-e3), var(--sc-edge),
              0 0 45px -10px color-mix(in oklab, var(--sc-accent) 30%, transparent);
}
.ap-card__shot {
  position: absolute; inset: 8% 8% 26%;
  width: auto; height: auto; margin: auto;
  max-width: 84%; max-height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 14px 18px hsl(var(--sc-shadow-color) / 0.6));
}

@media (prefers-reduced-motion: reduce) {
  .plate3d__shadow { transform: none !important; }
}

/* ═══════════════════════════════════════════════════════ step tiles ══════ */
.step-tile { justify-content: flex-start; }
.step-tile__n {
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-accent); letter-spacing: 0.1em; margin-bottom: var(--sc-2);
}
.step-tile .tile__n { font-size: var(--sc-t-2xl); margin-bottom: var(--sc-2); }

/* The rail carries the review line when a SKU has photography. */
.tile__plate {
  position: relative; aspect-ratio: 16 / 11;
  margin: calc(var(--sc-5) * -1) calc(var(--sc-5) * -1) var(--sc-3);
  border-radius: var(--sc-r-lg) var(--sc-r-lg) 0 0;
  overflow: hidden; border-bottom: 1px solid var(--sc-hairline);
  background: radial-gradient(90% 80% at 30% 18%, hsl(var(--hue) 62% 46% / 0.34), transparent 62%), var(--sc-surface);
}

@media (max-width: 860px) {
  /* Back to a stack: a 44% plate beside copy on a phone is two unreadable
     columns rather than one good one. */
  .grid--solo .card { grid-template-columns: 1fr; grid-template-rows: auto auto auto; }
  .grid--solo .card__plate {
    grid-column: 1; grid-row: 1;
    aspect-ratio: 4 / 3; min-height: 0;
    border-right: 0; border-bottom: 1px solid var(--sc-hairline);
  }
  .grid--solo .card__body { grid-column: 1; grid-row: 2; padding: var(--sc-4); }
  .grid--solo .card__buy  { grid-column: 1; grid-row: 3; padding: var(--sc-4); }
}

/* ═══════════════════════════════════════════════════ liquid glass ════════
   One recipe, applied to every surface that should read as glass. The rim is
   drawn with an inset ring rather than a border so it sits inside the radius
   and never fights the backdrop blur at the corners, and the top lip is
   brighter than the rest, which is the whole trick: real glass catches light
   on its upper edge. No drop shadow — webild uses none, and the translucency
   plus the lip is what does the work. */

.card,
.tile,
.line,
.drawer__panel,
.field input,
.field select,
.chip,
.btn--ghost,
.bar__cart,
.drawer__x,
.qty {
  background: var(--glass);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  border: 1px solid transparent;
  box-shadow:
    inset 0 0 0 1px var(--glass-rim),
    inset 0 1px 0 var(--glass-rim-hi);
}

/* Boxes take the modest radius, controls take a full pill. */
.card, .tile, .drawer__panel { border-radius: var(--glass-r-lg); }
.line, .field input, .field select { border-radius: var(--glass-r); }
.chip, .btn--ghost, .bar__cart, .drawer__x, .qty { border-radius: var(--sc-r-pill); }

.card:hover { background: var(--glass-strong); }
.chip:hover, .btn--ghost:hover, .bar__cart:hover, .drawer__x:hover,
.field input:hover, .field select:hover { background: var(--glass-strong); }

.field input:focus-visible, .field select:focus-visible {
  background: var(--glass-hover);
  box-shadow:
    inset 0 0 0 1px color-mix(in oklab, var(--sc-accent) 55%, transparent),
    inset 0 1px 0 var(--glass-rim-hi);
  outline: none;
}
.chip[aria-pressed="true"] {
  background: var(--sc-accent);
  color: var(--sc-accent-ink);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.35);
  font-weight: 500;
}

/* The drawer is the one panel deep enough to want a lift under it as well as
   a rim, because it floats clear of the page on all four sides. */
.drawer__panel {
  background:
    linear-gradient(168deg, rgb(255 255 255 / 0.07), rgb(255 255 255 / 0.02) 62%),
    color-mix(in oklab, var(--sc-surface) 62%, transparent);
  backdrop-filter: blur(30px) saturate(1.6);
  -webkit-backdrop-filter: blur(30px) saturate(1.6);
  border-radius: 26px;
  box-shadow:
    inset 0 0 0 1px var(--glass-rim),
    inset 0 1px 0 var(--glass-rim-hi),
    0 26px 70px -24px hsl(var(--sc-shadow-color) / 0.75);
}

/* Nested glass: a surface INSIDE a glass panel is brighter, not dimmer. That
   two-level hierarchy is what webild's hero card does and it is most of why
   theirs reads as depth rather than as a tint. Blur is dropped on the inner
   ones — stacking backdrop filters costs a lot and buys nothing, since the
   only thing behind them is the panel that already blurred the page. */
.drawer__panel .line,
.drawer__panel .field input,
.drawer__panel .field select {
  background: var(--glass-strong);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
.drawer__panel .field input:hover,
.drawer__panel .field select:hover { background: var(--glass-hover); }

/* The product card sits over the star field, where a heavy blur is wasted
   (there is nothing behind it with detail to bend) and costs a full-size
   backdrop pass on every frame the plate turns. */
.card { backdrop-filter: blur(10px) saturate(1.3); -webkit-backdrop-filter: blur(10px) saturate(1.3); }

/* Small round glass chips, the way webild treats icon buttons. */
.qty button {
  border-radius: var(--sc-r-pill);
  transition: background-color var(--sc-d-fast) var(--sc-ease-out);
}
.qty button:hover { background: var(--glass-hover); }

/* The solid accent pill keeps its own lip so it belongs to the same family. */
.btn--solid { box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.42), var(--sc-e1); }

/* ═══════════════════════════════════════════════════════ the ring page ════
   A different room from the home page: no travelling wordmark, no acts, no
   scroll story. One fixed stage you stand inside, and an empty track behind it
   whose only job is to give the wheel something to move. */

.page-ring { min-height: 100%; }

.bar--static { opacity: 1 !important; }
.bar__mark {
  grid-column: 2; justify-self: center;
  font-family: var(--sc-font-display); font-weight: 600;
  /* The SAME size and tracking the home wordmark comes to rest at in its bar:
     its font-size times --mark-rest, tracking at the 0.006em site.js lerps to.
     It was a hand-picked size and -0.02em, so the logo changed when you left
     the home page. */
  font-size: calc(clamp(4.4rem, 15vw, 13rem) * var(--mark-rest)); letter-spacing: 0.006em;
  color: var(--sc-ink) !important; text-decoration: none;
}

/* The stage is a BAND, not the viewport. Its top edge is under the heading and
   its bottom edge is the top of the caption, both written by ring.js off what
   those two things actually measure — so the object is centred in the space
   that is genuinely free, and the caption has nowhere to reach into. The old
   version was inset:0 with a padding-top guess of 34vh, which is why on a
   700px-tall laptop the caption landed across the middle of the product. */
.ring-stage {
  position: fixed;
  /* THE FALLBACKS MUST MATCH WHAT ring.js IS ABOUT TO COMPUTE, or the stage
     is laid out once at the guess and again at the answer.

     The floor's guess was 22vh. fit() actually sets 100vh minus the caption's
     resting top plus CLEAR, which with the default --ring-cap-top of 60vh is
     40vh + 30px - 410px at a 950px window against a guess of 209px. That
     201px correction is a layout shift on every load of The Line, measured at
     0.016 on 2026-09-29, and it is the last of the movement Andre could see.

     Expressed the same way fit() computes it, so the two cannot drift. */
  /* 222px, not 22vh, because --ring-top is not a fraction of the window.

     It is the bottom of .ring-copy plus GAP_TOP, and .ring-copy is text: a
     heading and a label at fixed sizes. Measured across window heights of
     1080, 950 and 900 it is 222px every time - 20.6vh, 23.4vh and 24.7vh of
     those windows respectively, which is why a vh guess could never match.
     Below the 820px type breakpoint the heading steps down and it becomes
     191px; the override in that media query carries the matching number. */
  inset: var(--ring-top, 222px) 0 var(--ring-floor, calc(40 * var(--rvh, 1vh) + 30px)) 0;
  display: grid; place-items: center;
  /* A LONGER LENS. At 1500px the ring's own depth was a large fraction of the
     viewing distance, so items at 80 degrees were shrunk and dragged inward
     hard enough to collide with the pair at 40. Pulling the camera back
     flattens that convergence and lets the angular spacing survive to the
     screen, which is what stops them piling up. */
  perspective: 2600px;
  perspective-origin: 50% 50%;
  z-index: 1;
  pointer-events: none;
}
.ring {
  position: relative;
  width: 0; height: 0;
  transform-style: preserve-3d;
  will-change: transform;
}

/* --ring-item is written by ring.js: the CSS ideal, cut down if the band is
   not tall enough to hold it. The fallback is the ideal on its own. */
.ring__item {
  position: absolute;
  left: 50%; top: 50%;
  width: var(--ring-item, clamp(170px, 19vw, 250px));
  margin-left: calc(var(--ring-item, clamp(170px, 19vw, 250px)) / -2);
  margin-top: calc(var(--ring-item, clamp(170px, 19vw, 250px)) / -1.55);
  transform-style: preserve-3d;
  will-change: transform, opacity;
  pointer-events: auto;
  cursor: pointer;
  text-align: center;
}

/* The item's own glass. Same recipe as everywhere else, so the ring belongs to
   the same object family as the cards and the bag. */
.ring__plate {
  position: relative;
  aspect-ratio: 1;
  border-radius: var(--glass-r-lg);
  display: grid; place-items: center;
  overflow: hidden;
  background:
    radial-gradient(90% 80% at 30% 18%, hsl(var(--hue) 55% 46% / 0.20), transparent 62%),
    var(--glass);
  box-shadow: inset 0 0 0 1px var(--glass-rim), inset 0 1px 0 var(--glass-rim-hi);
  transition: box-shadow var(--sc-d-base) var(--sc-ease-out);
}
/* A 3D plate needs no ground behind it and no clipping to fight its rim. */
.ring__plate--3d { background: none; box-shadow: none; overflow: visible; }

/* The photograph, for a product that has one but no model yet. Inset rather
   than flush so it sits ON the glass panel instead of filling it edge to
   edge, which is what makes it read as a product shot rather than a tile. */
.ring__shot {
  position: absolute; inset: 9%;
  width: 82%; height: 82%;
  object-fit: contain;
  filter: drop-shadow(0 10px 16px rgb(0 0 0 / 0.4));
}

.ring__glyph {
  font-family: var(--sc-font-display); font-weight: 700;
  font-size: clamp(2.2rem, 5vw, 3.4rem);
  color: color-mix(in oklab, var(--sc-ink) 13%, transparent);
  letter-spacing: -0.06em;
}
.ring__soon {
  position: absolute; top: 8%; right: 8%;
  font-family: var(--sc-font-mono); font-size: 0.6rem;
  letter-spacing: 0.12em; text-transform: uppercase;
  padding: 0.2rem 0.5rem; border-radius: var(--sc-r-pill);
  background: rgb(255 255 255 / 0.08); color: var(--sc-ink-soft);
  box-shadow: inset 0 0 0 1px var(--glass-rim);
}
.ring__n {
  margin: var(--sc-3) 0 0;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.06em; color: var(--sc-ink-soft);
  transition: color var(--sc-d-base) var(--sc-ease-out);
}
.ring__item.is-active .ring__n { color: var(--sc-ink); }
.ring__item.is-active .ring__plate:not(.ring__plate--3d) {
  box-shadow:
    inset 0 0 0 1px color-mix(in oklab, var(--sc-accent) 40%, transparent),
    inset 0 1px 0 var(--glass-rim-hi);
}
.ring__item.is-soon { cursor: default; }

/* The heading sits above the ring, the detail below it. Both are chrome over a
   fixed stage, so both are fixed too. */
.ring-copy {
  position: fixed; left: 0; right: 0; top: calc(var(--bar-h) + var(--sc-6));
  z-index: 3; text-align: center; pointer-events: none;
}
/* The heading is lit, not painted. On a black field a large, medium-weight
   face is antialiased into a grey ghost of the colour it was given — measured
   at 209 of a possible 255 before this — and the fix is not a brighter grey,
   it is a bloom. Three shadows: a tight one that recovers the stroke weight
   antialiasing ate, a mid one that reads as glow, and a wide cool one that
   sits the word in the same blue air as the dome behind it.

   Grayscale smoothing rather than the subpixel default, for the same reason.
   Subpixel rendering is tuned for dark-on-light and visibly thins light type
   on a dark ground. */
.ring-copy__h {
  margin: 0;
  color: #fff;
  font-weight: 700;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* The letterform stays untouched; the light happens OUTSIDE it.
     The last version did the opposite — a text-stroke thickening the glyph and
     white shadows at 2, 4 and 12 pixels — and shadows that tight do not sit
     around a letter, they bleed INTO its counters. The bowl of the e and the
     gap in the a fill with haze, the edges soften, and the word reads foggy
     while measuring brighter. That is the trap: it is more light and less
     legibility.
     The two jobs are separate and want opposite tools. BRIGHT is ink: half a
     pixel of stroke puts back the weight grayscale antialiasing takes off a
     light glyph on a dark ground, and adds no haze because it has no blur.
     LUMINESCENT is air: cool halos starting at 14px, far enough out that they
     never reach a counter. Nothing lives in between, which is where the fog
     was — white at 2 and 4 pixels does not glow, it just softens the edge it
     is trying to light. */
  -webkit-text-stroke: 0.5px #fff;
  text-shadow:
    0 0 16px rgb(214 234 255 / 0.55),
    0 0 46px rgb(150 190 255 / 0.36),
    0 0 96px rgb(110 155 255 / 0.22);
}
.ring-copy__sub {
  display: block; margin-top: var(--sc-2); opacity: 0.6;
  text-shadow: 0 0 12px rgb(206 224 255 / 0.35);
}
.ring-fallback { display: none; }

/* No plate. The caption is type on the dome and nothing else — no panel, no
   glass, no rim — so the object above it is never sharing the frame with a box.
   It is a label under an exhibit, not a card. */
/* Held one step further off the floor than it used to be. The caption is the
   only thing on this page that is READ rather than looked at, and sitting it
   right down on the bottom edge made it feel like a status bar under the ring
   instead of the label on it. ring.js reads this number back to place the
   ring's floor, so raising it lifts the caption and the object together —
   which is why it is one value per breakpoint and not a nudge on each. */
/* ══════════════════════════════════════════ THE LINE, A LITTLE LARGER ═════
   One number scales the whole page, and it has to be one number or the
   proportions drift.

   The type scale is clamp(rem + vw), so raising the root font size would grow
   the rem half and leave the vw half where it was — everything would get
   bigger by a slightly different amount and the relationships between the
   heading, the caption and the object would quietly come apart. Multiplying
   the finished value keeps both halves in step, so the page is the same design
   at a larger size rather than a redrawn one.

   The object on the ring is sized in JavaScript from the viewport, so ring.js
   reads this same number back rather than carrying its own copy of it. */
.page-ring {
  --ring-up: 1.12;
}
/* Eased where there is no height to spend it in. The ring is a fixed band
   between the heading and the caption, so scaling both ends of that sandwich
   by 12% eats the daylight in the middle — on a 700px window the object ended
   up 7px from the caption, and the page needs at least 24 to read as an object
   with a label rather than as a collision. Phones (844) and desktops (900)
   keep the full step; short laptop windows take a smaller one. */
@media (max-height: 800px) {
  .page-ring { --ring-up: 1.04; }
}
.page-ring .ring-copy,
.page-ring .ring-stage,
.page-ring .ring-detail {
  --sc-t-xs:   calc(clamp(0.75rem, 0.72rem + 0.15vw, 0.82rem)   * var(--ring-up));
  --sc-t-sm:   calc(clamp(0.875rem, 0.84rem + 0.18vw, 0.95rem)  * var(--ring-up));
  --sc-t-base: calc(clamp(1rem, 0.96rem + 0.22vw, 1.125rem)     * var(--ring-up));
  --sc-t-lg:   calc(clamp(1.2rem, 1.1rem + 0.5vw, 1.5rem)       * var(--ring-up));
  --sc-t-xl:   calc(clamp(1.6rem, 1.35rem + 1.2vw, 2.25rem)     * var(--ring-up));
}

.ring-detail {
  /* A FIXED FRAME, anchored from the TOP, with a fixed height.

     It used to be anchored from the BOTTOM with its height left to the
     content, and that is why the description moved: a product with an Add
     button is 48px taller than one that only says "not in stock yet", and a
     two-line name is taller than a one-line one — so the block grew UPWARD
     and every product put its subtitle somewhere different. Measured on a
     phone the top edge landed at 600px for one product and 628px for the
     next; on a laptop, 617 and 666.

     Reserving the space instead means every row lands in the same place for
     every product in the line, whatever is in it. An empty slot costs a few
     pixels of dome. A caption that jumps as you turn the ring costs the
     reader their place, every single time. */
  position: fixed; left: 50%;

  /* --ring-cap-drop is written by ring.js AFTER the ring has settled, and it
     exists because "where the caption starts" is two different questions.

     The stylesheet's answer, --ring-cap-top, is the one the ring is built
     from: ring.js derives its floor from it, so it has to stay put or the
     products resize and move. The drop is the second answer — where the
     caption should SIT once the product has landed — and it is measured, not
     declared, because the right value depends on how tall the front item
     rendered and that changes with the viewport.

     Andre asked for the description "centred between the bottom of the
     product and the bottom of the screen". Measured before this existed, the
     gaps were 50/73 on a laptop and 32/165 on a phone: the caption was
     hugging the product with a void under it, badly on a phone. ring.js now
     solves for the midpoint. See the settle pass in fit(). */
  top: calc(var(--ring-cap-top, calc(60 * var(--rvh, 1vh))) + var(--ring-cap-drop, 0px));
  /* The drop is solved once, when the ring stops. Gliding the last few pixels
     rather than cutting to them is the difference between the block arriving
     and the block flinching. Short enough that it is never something you wait
     for. */
  transition: top 200ms var(--sc-ease-out);
  height: calc(var(--ring-cap-h, 18rem) * var(--ring-up, 1));
  translate: -50% 0;
  z-index: 3;
  width: min(38rem, calc(100vw - var(--sc-6)));
  text-align: center;
  background: none;
  padding: 0;

  /* One row per part, each with its own reserved height. `auto` anywhere in
     here would hand the shifting back. */
  display: grid;
  grid-template-rows:
    calc(var(--cap-sub,   1.15rem) * var(--ring-up, 1))  /* subtitle, one line */
    calc(var(--cap-name,  2.6rem)  * var(--ring-up, 1))  /* name, room for two */
    calc(var(--cap-blurb, 3.2rem)  * var(--ring-up, 1))  /* blurb, clamped */
    calc(var(--cap-econ,  2.8rem)  * var(--ring-up, 1))  /* price / resale / keep */
    /* The buttons — and above them the size chips, where a product has
       sizes. Reserved for the tallest case so a bracelet and a nameplate put
       their Add button on the same line of the screen. */
    calc(var(--cap-act, 8.2rem) * var(--ring-up, 1));
  align-items: start;
  justify-items: center;
}

/* EVERY CHILD PINNED TO ITS OWN ROW.

   Without this, `display: none` on one of them does not leave a gap — it
   removes the item from the grid, and everything below slides UP a row. The
   blurb is hidden on a phone, so the economics moved into the blurb's slot and
   the size chips moved into the economics' slot and were drawn straight over
   the price. Auto-placement is fine until a row can vanish, and here one can. */
.ring-detail__sub   { grid-row: 1; }
.ring-detail__n     { grid-row: 2; }
.ring-detail__blurb { grid-row: 3; }
.ring-detail__econ  { grid-row: 4; }
.ring-detail__act   { grid-row: 5; }
.ring-detail__sub { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-accent); margin: 0; }
.ring-detail__n {
  font-family: var(--sc-font-display); font-size: var(--sc-t-lg); font-weight: 600;
  margin: 0.15rem 0 0; letter-spacing: var(--sc-track-snug);
  line-height: 1.15;
  /* "Moissanite Tennis Bracelet" is two lines where "Nameplate" is one, and
     the slot is sized for two either way. */
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}
.ring-detail__blurb {
  font-size: var(--sc-t-xs); color: var(--sc-ink-soft);
  margin: var(--sc-2) auto 0; max-width: 52ch; line-height: 1.55;
  /* Two lines, whatever the copy length. A blurb that runs to three lines on
     one product and one on the next is the other half of the jumping. */
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}
/* Andre 2026-10-11: the grey description under each product on the line page
   goes. The full copy is still on each product's own page. */
.ring-detail__blurb { display: none; }
.ring-detail { --cap-blurb: 0rem; --ring-cap-h: 14.8rem; }  /* its row goes too, so no hole where it was */
/* grid-auto-flow: column with two explicit rows. A <dl> laid out on plain
   three-column grid fills ACROSS — dt, dd, dt on row one — so every term ends
   up over someone else's value. Filling down keeps each pair together. */
/* EQUIPMENT, NOT STOCK. The tester is the one thing here a reseller buys to
   use rather than to flip, so it carries this line where every other product
   carries a resale figure and a margin. Quiet on purpose: it is a note about
   what the thing is for, not a second price competing with the real one. */
.pdp__forUse, .card__forUse {
  font-family: var(--sc-font-mono);
  font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft);
  margin: 0;
}
.card__forUse { margin-top: auto; padding-top: var(--sc-2); }

/* The rows the tester hides are dt/dd with no display rule of their own, so
   [hidden] works - but this file has now twice been bitten by an author
   display quietly beating the UA one, so it is stated rather than assumed. */
.ring-detail__econ [hidden] { display: none; }

/* Centred, however many figures there are. It was three equal columns, so
   once the resale and margin cells were hidden the price sat alone in the
   left third. Columns now size to their content and the row centres. */
.ring-detail__econ {
  display: grid;
  grid-template-rows: auto auto;
  grid-auto-flow: column;
  grid-auto-columns: max-content;
  justify-content: center;
  text-align: center;
  gap: 0.2rem var(--sc-5); margin: var(--sc-3) 0 0;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
}
.ring-detail__econ dt { color: var(--sc-ink-soft); }
.ring-detail__econ dd { margin: 0; color: var(--sc-ink); }
.ring-detail__econ .win { color: var(--sc-accent); }
.ring-detail__act {
  display: flex; gap: var(--sc-3); justify-content: center; align-items: center;
  flex-wrap: wrap; margin-top: var(--sc-3);
}
/* The size picker inside the ring is the browse view of it: the chips and
   nothing else. The "LENGTH" label and the "measure the wrist" note are
   guidance for somebody who has decided to buy, and they belong on the product
   page where there is room to read them — here they were 38px of the caption
   frame and pushed the Add button off the bottom of a laptop screen. */
/* The chips come FIRST, above Open and Add. In the DOM the Open link is
   written before the buy control that carries them, so without this the row
   reads Open / chips / Add — a button, then the choice that button depends on,
   then another button. You pick the size and then you act on it. */
/* The chips sit BETWEEN the economics and the Open button, so the space above
   and below them should match. It did not: measured on a 1440x900 laptop the
   gap above was -1px — the chips were touching the "Typical resale" line — and
   the gap below was 20px, because the flex gap (12) and the chip block's own
   bottom margin (8) both fell underneath and nothing at all fell above.

   Moving the margin from the bottom to the top fixes both halves with one
   change: it opens the gap above and closes the one below, rather than pushing
   the whole block down and taking the Open button with it. 0.7rem lands the
   laptop on 10/12. The phone runs a different chip layout — they claim a full
   line of their own there — and needs almost none. */
.ring-detail__act .pick { order: -1; flex: 1 0 100%; margin: 1.15rem 0 0; }
/* THE PRODUCTS WITH NOTHING TO PICK.

   That 1.15rem above the chips is what holds the buy row off the economics
   line, and it lives on the chips — so a product that has no chips never gets
   it. The Cuban bracelet and the diamond tester have no size and no metal, and
   their Open and Add buttons sat hard under "Typical resale" while every other
   product had a comfortable gap. Same space, taken from the same place,
   whether or not there is anything to choose. */
.ring-detail__act:not(:has(.pick)) { margin-top: calc(var(--sc-3) + 1.15rem); }
.ring-detail__act .pick__l,
.ring-detail__act .pick__h { display: none; }
.ring-detail__act .pick__row { justify-content: center; }
.ring-detail__soon { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-ink-soft); }

.ring-track { pointer-events: none; }

@media (max-width: 700px) {
  /* No blurb on a small screen, so its slot is removed rather than left
     empty — an empty reserved row is dead space, and the whole point of
     reserving is that the rows below it do not move. */
  .ring-detail { --cap-blurb: 0rem; --ring-cap-h: 12rem; }
  .ring-detail__blurb { display: none; }
}

/* A laptop is a WIDE screen and a SHORT one, and the ring is a vertical
   problem: heading, object, caption, stacked, on 700 usable pixels. So the
   chrome at both ends gives ground rather than the product — the heading comes
   down a size and the blurb goes, because on this page the caption's job is
   which product am I looking at and what does it cost, and the sentence about
   it is one click away on its own page. */
@media (max-height: 820px) {
  /* The heading steps down here, so the reservation does too. See .ring-stage. */
  .ring-stage { inset: var(--ring-top, 191px) 0 var(--ring-floor, calc(40 * var(--rvh, 1vh) + 30px)) 0; }

  /* Below the scrim's reach, not inside it. */
  .ring-copy { top: calc(var(--bar-h) + var(--sc-5)); }
  .ring-copy__h { font-size: var(--sc-t-xl); }
  .ring-copy__sub { margin-top: var(--sc-1); }
  .ring-detail { --cap-blurb: 0rem; --ring-cap-h: 12rem; }
  .ring-detail__blurb { display: none; }
  .ring-detail__econ { margin-top: var(--sc-2); }
  .ring-detail__act { margin-top: var(--sc-2); }
}

.ring-copy__sub--still { display: none; }

/* No ring without motion: the whole device is rotation. Fall back to the plain
   list, which is the same products in the same order. */
@media (prefers-reduced-motion: reduce) {
  .ring-stage, .ring-detail { display: none; }
  .ring-copy__sub--turn { display: none; }
  .ring-copy__sub--still { display: block; }
  .ring-copy { position: static; padding-top: calc(var(--bar-h) + var(--sc-6)); pointer-events: auto; }
  .ring-fallback {
    display: grid; gap: var(--sc-2);
    max-width: 34rem; margin: var(--sc-6) auto; padding: 0 var(--sc-gutter);
    list-style: none; text-align: left;
  }
  .ring-fallback li {
    display: flex; justify-content: space-between; gap: var(--sc-4);
    padding: var(--sc-4); border-radius: var(--glass-r);
    background: var(--glass); box-shadow: inset 0 0 0 1px var(--glass-rim);
  }
  .ring-fallback span { font-family: var(--sc-font-mono); color: var(--sc-ink-soft); }
}

/* The detail panel already names the selected product in full, so the item's
   own caption would be the same word twice, and it collides with the panel. */
.ring__item.is-active .ring__n { opacity: 0; }

/* ══════════════════════════════════════════════════════ a product page ════
   Two columns: the object, and everything said about it. Not a hero and a
   spec sheet stacked — side by side, because the whole argument for this
   product is that you can turn it over and look. */

.page-pdp { min-height: 100%; }

.pdp {
  position: relative; z-index: 1;
  min-height: 100svh;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: center;
  gap: var(--sc-8);
  max-width: 76rem;
  margin-inline: auto;
  padding: calc(var(--bar-h) + var(--sc-8)) var(--sc-gutter) var(--sc-8);
}

.pdp__object { display: grid; place-items: center; }
.pdp__stage {
  position: relative;
  width: min(100%, 30rem);
  aspect-ratio: 1;
  border-radius: var(--glass-r-lg);
  display: grid; place-items: center;
  background:
    radial-gradient(90% 80% at 30% 18%, hsl(var(--hue) 55% 46% / 0.18), transparent 62%),
    var(--glass);
  box-shadow: inset 0 0 0 1px var(--glass-rim), inset 0 1px 0 var(--glass-rim-hi);
}
/* A real object needs no plinth and no box to be clipped by. */
.pdp__stage--3d { background: none; box-shadow: none; }
.pdp__shot { max-width: 82%; max-height: 82%; object-fit: contain; }
.pdp__glyph {
  font-family: var(--sc-font-display); font-weight: 700;
  font-size: clamp(4rem, 10vw, 8rem);
  color: color-mix(in oklab, var(--sc-ink) 12%, transparent);
  letter-spacing: -0.06em;
}

.pdp__panel { max-width: 34rem; }
.pdp__sub { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-accent); margin: 0; letter-spacing: 0.04em; }
.pdp__n {
  font-family: var(--sc-font-display); font-weight: 600;
  font-size: var(--sc-t-2xl); letter-spacing: var(--sc-track-snug);
  margin: var(--sc-2) 0 0; text-wrap: balance;
}
.pdp__blurb { color: var(--sc-ink-soft); margin: var(--sc-4) 0 0; max-width: 46ch; line-height: 1.6; }

.pdp__spec { list-style: none; margin: var(--sc-6) 0 0; padding: 0; display: grid; gap: 0.4rem; }
.pdp__spec li { font-size: var(--sc-t-sm); color: var(--sc-ink-soft); padding-left: 1rem; position: relative; }
.pdp__spec li::before { content: ""; position: absolute; left: 0; top: 0.68em; width: 5px; height: 1px; background: var(--sc-accent); }

.pdp__econ {
  display: grid; grid-template-columns: repeat(3, auto); justify-content: start;
  gap: var(--sc-6); margin: var(--sc-6) 0 0;
  padding-top: var(--sc-5); border-top: 1px solid var(--sc-hairline);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
}
.pdp__econ dt { color: var(--sc-ink-soft); margin: 0; }
.pdp__econ dd { margin: 0.25rem 0 0; color: var(--sc-ink); font-size: var(--sc-t-base); }
.pdp__econ .win { color: var(--sc-accent); }
.pdp__price { font-family: var(--sc-font-display); font-size: var(--sc-t-xl) !important; font-weight: 600; }

.pdp__buy { display: flex; align-items: center; gap: var(--sc-4); margin-top: var(--sc-6); flex-wrap: wrap; }
.pdp__soon { font-family: var(--sc-font-mono); font-size: var(--sc-t-sm); color: var(--sc-ink-soft); }
.pdp__fine { font-size: var(--sc-t-xs); color: var(--sc-ink-soft); margin: var(--sc-5) 0 0; max-width: 44ch; }
.pdp__missing { grid-column: 1 / -1; text-align: center; color: var(--sc-ink-soft); }

@media (max-width: 900px) {
  .pdp { grid-template-columns: 1fr; gap: var(--sc-6); padding-top: calc(var(--bar-h) + var(--sc-6)); }
  .pdp__stage { width: min(100%, 22rem); }
  .pdp__panel { max-width: none; }
  .pdp__econ { gap: var(--sc-5); }
}

/* ── the buy control ─────────────────────────────────────────────────────
   A quantity and a confirm, not a quantity that IS the confirm. */
.buy { display: inline-flex; align-items: center; gap: var(--sc-3); flex-wrap: wrap; }
.buy__in {
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-accent); white-space: nowrap;
}
[data-add].is-added {
  background: var(--sc-accent) !important;
  color: var(--sc-accent-ink) !important;
  box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.4) !important;
}
.line__ctl { display: flex; align-items: center; gap: var(--sc-3); justify-self: end; }
.line { grid-template-columns: minmax(0, 1fr) auto; }
.line__ctl .qty button { width: 1.9rem; height: 1.9rem; }

/* The whole card is a link, except the row you press + and Add in. */
.card { position: relative; }
.card__link { position: absolute; inset: 0 0 5.5rem 0; z-index: 2; border-radius: inherit; }
.card__buy { position: relative; z-index: 3; }
.grid--solo .card__link { inset: 0; right: 56%; }
.card:hover .card__n { color: var(--sc-ink); }
a.ap-card { text-decoration: none; color: inherit; display: grid; }

/* The close headline, assembled per letter off the act's own progress. */
.close__h { text-wrap: balance; }
.close__l {
  display: inline-block;
  opacity: 0;
  will-change: opacity, transform;
}
@media (prefers-reduced-motion: reduce) { .close__l { opacity: 1 !important; transform: none !important; } }

/* ── the explainer clips ─────────────────────────────────────────────────
   Three reels held out like a hand of cards. Poster frame, our own dark
   frame, a play badge, the creator's handle, and the whole card is a link to
   the post. The Instagram embed did the same job but arrived as a white panel
   with its own chrome that cannot be reached from outside the iframe, and
   three of those across a black room read as a mistake. */
.fan {
  flex: 0 0 min(50rem, 92vw);
  display: grid; align-content: center; gap: var(--sc-4);
  padding-inline: var(--sc-5);
}
.fan__lead { text-align: center; }
.fan__deck {
  position: relative;
  height: clamp(22rem, 52vh, 30rem);
  display: grid; place-items: center;
}
.fan__card {
  position: absolute;
  height: 100%;
  aspect-ratio: 9 / 16;
  margin: 0;
  border-radius: var(--glass-r);
  overflow: hidden;
  display: block;
  text-decoration: none;
  background: var(--sc-surface);
  box-shadow: inset 0 0 0 1px var(--glass-rim),
              0 22px 50px -20px hsl(var(--sc-shadow-color) / 0.9);
  /* The fan: offset and rotated by its own --f so the three overlap the way a
     hand of cards does, with the middle one squarest and on top. */
  transform: translateX(calc(var(--f) * 72%)) rotate(calc(var(--f) * 6deg)) translateY(calc(abs(var(--f)) * 3%));
  transition: transform var(--sc-d-slow) var(--sc-ease-out),
              box-shadow var(--sc-d-slow) var(--sc-ease-out);
  z-index: 1;
}
.fan__card[style*="--f:0"] { z-index: 2; }
/* Hovering the deck spreads it so the cards underneath can be reached. */
.fan__deck:hover .fan__card { transform: translateX(calc(var(--f) * 88%)) rotate(calc(var(--f) * 3deg)); }
.fan__card:hover { z-index: 3; box-shadow: inset 0 0 0 1px var(--glass-rim-hi), 0 26px 60px -18px hsl(var(--sc-shadow-color) / 0.95); }

.fan__poster {
  width: 100%; height: 100%;
  object-fit: cover; display: block;
  /* Held back a touch so the badge and the handle stay readable over whatever
     the frame happens to be, and so three different clips sit at one level. */
  filter: brightness(0.82) saturate(0.95);
  transition: filter var(--sc-d-slow) var(--sc-ease-out), transform var(--sc-d-slow) var(--sc-ease-out);
}
.fan__card:hover .fan__poster { filter: brightness(1) saturate(1); transform: scale(1.03); }

/* The play badge. Glass, like every other control on the site. */
.fan__play {
  position: absolute; left: 50%; top: 50%;
  translate: -50% -50%;
  width: 3.1rem; height: 3.1rem;
  border-radius: var(--sc-r-pill);
  background: rgb(255 255 255 / 0.14);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.32);
  pointer-events: none;
}
.fan__play::after {
  content: ""; position: absolute; left: 54%; top: 50%;
  translate: -50% -50%;
  border-left: 0.85rem solid rgb(255 255 255 / 0.92);
  border-top: 0.52rem solid transparent;
  border-bottom: 0.52rem solid transparent;
}

/* The credit, over a gradient so it holds on any frame. Not decoration: it is
   what makes putting someone else's work on a shop page the right way round. */
.fan__by {
  position: absolute; inset: auto 0 0 0;
  padding: 2.2rem var(--sc-4) var(--sc-3);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: rgb(255 255 255 / 0.88);
  background: linear-gradient(to top, rgb(0 0 0 / 0.82), transparent);
  pointer-events: none;
}
.fan__note {
  text-align: center; margin: 0;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft); opacity: 0.7;
}

/* A sideways rail on a 390pt screen was showing a slide and three quarters:
   the next card arrived over the paragraph you were still reading, and at 12%
   opacity that reads as text bleeding through text rather than as a card on
   its way. One slide at a time, with a real gap, so what follows is a sliver
   at the edge — a promise of more rather than something on top of what you
   have. */
@media (max-width: 700px) {
  .rail { gap: var(--sc-6); }
  .rail > * { flex: 0 0 86vw; }
  .fan { flex-basis: 86vw; }
  .fan__deck { height: clamp(18rem, 46vh, 24rem); }
}

/* ═════════════════════════════════════════════════════════ the reveal ═════
   The layered arrival. `.rs-boot` is set by a one-line script in the <head>,
   so a reader without JS never meets an invisible page — the rule simply never
   applies to them — and `.rs-lift` is added once shared.js has numbered the
   targets, which is what lets the delay run off the index.

   The delay is a full wordmark-wave long before the first target moves. That
   is the whole effect: Quaryn resolves on its own, and the page fills in
   underneath it afterwards rather than alongside it. `--rv-lead` is written by
   shared.js and is much shorter on the pages that have no wordmark to wait on.

   No `forwards` beyond the cascade. shared.js takes both classes off at the
   end, because site.js writes .hero__fade's opacity every frame off the scroll
   and it cannot do that while an animation is still holding the property. */

html.rs-boot [data-reveal] { opacity: 0; }
html.rs-boot.rs-lift [data-reveal] {
  animation: reveal-in 620ms var(--sc-ease-out) both;
  animation-delay: calc(var(--rv-lead, 1150ms) + var(--rv, 0) * 70ms);
}
/* Out of the same haze the wordmark condenses from, just lighter and quicker —
   it is the second thing you see, not the first. */
/* Opacity and blur only — no transform. site.js writes a transform on the
   hero copy every frame off the scroll, and an animation that also ends on a
   transform overwrites it for as long as it runs, then lets go: the block
   snapped from flat into its tilt the instant the arrival finished. Two things
   cannot own one property. */
@keyframes reveal-in {
  from { opacity: 0; filter: blur(9px); }
  60%  { opacity: 1; }
  to   { opacity: 1; filter: blur(0); }
}
/* The page is held still while it arrives. Two and a bit seconds, once, on a
   cold load of the home page only — and never when the browser has restored a
   scroll position, because someone returning to where they were should not be
   dragged back to the top and pinned there.

   This is the whole reason the hand-back below still exists: the hold covers
   the fresh arrival, and `rs-scrolled` covers every case the hold declines to
   take — a restored position, reduced motion, a page with no wordmark. */
html.rs-hold, html.rs-hold body { overflow: hidden; }

/* Scroll-driven copy is handed back to whoever drives it the moment the page
   moves. Not hidden — released: site.js writes an inline opacity every frame
   off the scroll and that is the correct answer at any position, including
   full brightness if the reader scrolls back to the top. */
/* !important on purpose. The rule this has to beat is
   `html.rs-boot.rs-lift [data-reveal]`, which is a class heavier, and matching
   it with a longer selector would make the override depend on the arithmetic
   staying true the next time either selector is touched. This one has to win
   outright: it is the difference between text the reader has left flashing
   back in and staying gone. */
html.rs-scrolled .hero__fade,
html.rs-scrolled .hero [data-reveal] { animation: none !important; }

@media (prefers-reduced-motion: reduce) {
  html.rs-boot [data-reveal] { opacity: 1; animation: none; }
}

/* ══════════════════════════════════════════════════════ the rim light ═════
   The dot that walks the card's border. Geometry and motion are in shared.js;
   this only says where the layer sits and how bright it is allowed to be.

   It overlays the card at the card's own radius and takes no pointer events,
   so the whole face stays a link. `mix-blend-mode: screen` is what keeps it
   reading as light ON the edge rather than as a white sticker over it. */

.rim {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  z-index: 4;                   /* over .card__link, under nothing that is hit */
  pointer-events: none;
  overflow: visible;            /* the halo is wider than the border it rides */
  mix-blend-mode: screen;
  transition: opacity 240ms linear;
}
/* A card is overflow:hidden so the plate's lit field can be clipped to the
   card's radius, and that would cut the halo in half on every edge. The light
   is the one thing allowed outside the box.

   Safe to open up here and nowhere else: the only card that carries a rim is
   the one carrying the real 3D object, and .card__plate--3d has no background
   of its own — there is nothing square left inside the card for the rounded
   corners to have been hiding. */
.card:has(> .rim) { overflow: visible; }

/* Hovering brings the frame up a little, so the light reads as belonging to
   the card rather than as an effect parked on top of it. */
.card:hover > .rim { filter: brightness(1.35); }

@media (prefers-reduced-motion: reduce) { .rim { display: none; } }

/* ══════════════════════════════════════════════════════ the governor ══════
   Two rules only; the mechanism is in shared.js.

   `.rs-gov` is set by the governor itself once it has decided it is running
   (mouse or trackpad, motion allowed). It turns scrollcraft's
   scroll-behavior: smooth back off, because the governor already animates
   every jump and two animations on one scroll position fight each other.

   overscroll-behavior stops the rubber band. The page ends where the content
   ends, and pulling past it into a screen of nothing is not a gesture this
   site has any answer for. */

/* The whole site is light type on a black ground, which is the case subpixel
   antialiasing handles worst — it thins strokes and washes the colour out.
   Grayscale smoothing keeps the weight the typeface was drawn with. */
body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

html.rs-gov { scroll-behavior: auto; }

/* And the same thing again, for the case the governor cannot cover.

   The governor bails out on coarse pointers — a phone scrolls itself, and
   animating it on top of that fights the operating system. But it is also
   what adds `.rs-gov`, so a phone is the ONE device where
   `scroll-behavior: smooth` stays on, and that broke the sideways swipe in a
   way that was invisible on every machine it was tested on.

   A swipe is not a jump to a destination. It is the reader moving the page
   themselves, a few pixels every frame, and under smooth behaviour each of
   those becomes a fresh animation that cancels the one before it. A slow drag
   still worked, because each step had time to land. A flick — the actual
   gesture — netted about twenty pixels out of three hundred.

   So the gesture turns it off for as long as it lasts, and hands it back. */
html.rs-drag { scroll-behavior: auto; }
html, body { overscroll-behavior-y: none; }

/* ═══════════════════════════════════════════════════════ the pay sheet ═════
   Stripe's embedded Checkout, in a panel over the shop. The mechanism is in
   pay.js; this decides where it sits and how it is lit.

   The one hard constraint: the mount point must be able to GROW. Stripe's
   iframe resizes itself as the reader picks a payment method — a card form and
   a bank-redirect form are different heights — and a fixed-height container
   either clips the pay button or leaves a hole under it. So the panel is a
   max-height with its own scroll, and the mount is left to size itself. */

.pay { position: fixed; inset: 0; z-index: 120; }   /* over the bag drawer */
.pay[hidden] { display: none; }
html.pay-open { overflow: hidden; }

.pay__scrim {
  position: absolute; inset: 0;
  background: rgb(3 4 7 / 0.72);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  animation: pay-scrim var(--sc-d-base) var(--sc-ease-out);
}
@keyframes pay-scrim { from { opacity: 0; } }

.pay__panel {
  position: absolute; left: 50%; top: 50%;
  translate: -50% -50%;
  width: min(30rem, calc(100vw - var(--sc-4)));
  max-height: min(46rem, calc(100dvh - var(--sc-5)));
  display: flex; flex-direction: column;
  border-radius: var(--glass-r-lg);
  /* Opaque, not glass. A card form is the one panel on this site that must
     never have the shop showing through it — the reader is deciding whether to
     trust the page, and a translucent payment box reads as a skin over
     something rather than as the thing itself. */
  background: var(--sc-surface);
  box-shadow: var(--sc-e3), inset 0 0 0 1px var(--glass-rim), inset 0 1px 0 var(--glass-rim-hi);
  overflow: hidden;
  animation: pay-in var(--sc-d-slow) var(--sc-ease-drawer);
}
@keyframes pay-in { from { opacity: 0; transform: translateY(1.2rem) scale(0.985); } }

.pay__head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sc-4);
  padding: var(--sc-4) var(--sc-5);
  border-bottom: 1px solid var(--sc-hairline);
}
.pay__h {
  margin: 0;
  font-family: var(--sc-font-display); font-weight: 600;
  font-size: var(--sc-t-lg); letter-spacing: var(--sc-track-snug);
}
.pay__x {
  border: 0; background: none; cursor: pointer;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--sc-ink-soft); padding: 0.4rem 0.2rem;
}
.pay__x:hover { color: var(--sc-ink); }

.pay__body { overflow-y: auto; overscroll-behavior: contain; padding: var(--sc-4) var(--sc-5); }
/* Stripe sizes this itself. Do not give it a height. */
.pay__mount { min-height: 0; }

/* [hidden] is display:none from the UA stylesheet, and ANY author display
   beats it — so `.hidden = true` on a flex element does nothing at all. This
   is why the spinner sat on top of a loaded card form through several
   attempts to fix the timing: the timing was already right. */
.pay__wait[hidden] { display: none; }
.pay__wait {
  display: flex; align-items: center; gap: var(--sc-3);
  padding: var(--sc-7) 0;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--sc-ink-soft);
}
.pay__spin {
  width: 0.85rem; height: 0.85rem; flex: none;
  border-radius: 50%;
  border: 1.5px solid color-mix(in oklab, var(--sc-accent) 30%, transparent);
  border-top-color: var(--sc-accent);
  animation: pay-spin 720ms linear infinite;
}
@keyframes pay-spin { to { transform: rotate(1turn); } }
@media (prefers-reduced-motion: reduce) {
  .pay__spin { animation: none; }
  .pay__panel, .pay__scrim { animation: none; }
}

.pay__err {
  margin: var(--sc-4) 0 0;
  font-size: var(--sc-t-sm);
  color: #ff8a7a;
}
.pay__fine {
  margin: 0;
  padding: var(--sc-3) var(--sc-5) var(--sc-4);
  border-top: 1px solid var(--sc-hairline);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft);
}

@media (max-width: 560px) {
  /* A sheet from the bottom, the way a payment sheet behaves on a phone. */
  .pay__panel {
    top: auto; bottom: 0; left: 0;
    translate: none;
    width: 100%;
    max-height: 92dvh;
    border-radius: var(--glass-r-lg) var(--glass-r-lg) 0 0;
    animation: pay-up var(--sc-d-slow) var(--sc-ease-drawer);
  }
  @keyframes pay-up { from { transform: translateY(100%); } }
}

/* ══════════════════════════════════════════════════════ the return page ════
   Where Stripe lands the reader after a payment. Same dome, same type — the
   whole point of embedding the form was that this never feels like a different
   website, and this is the one page that could give that away. */

.page-done { min-height: 100%; }
.done {
  position: relative; z-index: 1;
  min-height: 100svh;
  display: grid; place-items: center;
  padding: calc(var(--bar-h) + var(--sc-8)) var(--sc-gutter) var(--sc-8);
}
.done__inner { max-width: 34rem; text-align: center; display: grid; gap: var(--sc-3); }
.done__eyebrow { margin: 0; }
.done__h { margin: 0; text-wrap: balance; }
.done__body { margin: 0; color: var(--sc-ink-soft); text-wrap: pretty; }

.done__lines {
  display: grid; grid-template-columns: 1fr auto;
  gap: 0.35rem var(--sc-5);
  margin: var(--sc-5) 0 0; padding-top: var(--sc-4);
  border-top: 1px solid var(--sc-hairline);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  text-align: left;
}
.done__lines dt { color: var(--sc-ink-soft); margin: 0; }
.done__lines dd { margin: 0; color: var(--sc-ink); font-variant-numeric: tabular-nums; }
.done__lines .is-total { color: var(--sc-ink); padding-top: 0.4rem; border-top: 1px solid var(--sc-hairline); }
.done__acts { margin-top: var(--sc-5); display: flex; justify-content: center; }

/* ═══════════════════════════════════════════ the product page, the block ═══
   Price, then the button, then the reassurance, then the reading. The panel is
   a column of four groups and the spacing does the grouping — no cards, no
   rules between every pair, because a page that boxes each thing separately
   reads as a form to fill in rather than a thing to buy. */

.pdp__panel { display: grid; align-content: start; gap: 0; }
.pdp__sub { order: 1; }
.pdp__n   { order: 2; margin-top: var(--sc-2); }

/* The price and its argument. The figure is display-size because it is the
   single most-read thing on the page; the two beside it are mono and small,
   set as a pair so they read as one supporting statement. */
.pdp__price-row {
  order: 3;
  display: flex; align-items: baseline; flex-wrap: wrap;
  gap: 0.4rem var(--sc-5);
  margin-top: var(--sc-5);
}
.pdp__price {
  font-family: var(--sc-font-display); font-weight: 600;
  font-size: var(--sc-t-2xl); letter-spacing: var(--sc-track-snug);
  line-height: 1;
}
.pdp__each { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-ink-soft); }
.pdp__gain {
  display: flex; gap: var(--sc-5); margin: 0;
  padding-left: var(--sc-5); border-left: 1px solid var(--sc-hairline);
}
.pdp__gain div { display: grid; gap: 0.1rem; }
.pdp__gain dt {
  font-family: var(--sc-font-mono); font-size: 0.68rem;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--sc-ink-soft);
}
.pdp__gain dd {
  margin: 0; font-family: var(--sc-font-mono); font-size: var(--sc-t-sm);
  color: var(--sc-ink); font-variant-numeric: tabular-nums;
}
.pdp__gain .win { color: var(--sc-accent); }

/* Add is the primary act and now looks like it. It used to be a small ghost
   button beside a big blue one that only opened the bag — the loud control did
   the trivial thing and the quiet one did the sale. */
.pdp__buy {
  order: 4;
  display: flex; align-items: center; gap: var(--sc-3); flex-wrap: wrap;
  margin-top: var(--sc-5);
}
.pdp__buy .buy { display: flex; align-items: center; gap: var(--sc-3); flex: 1 1 auto; }
.pdp__buy .buy .btn {
  /* Wide enough to be unmistakably the primary act, capped so it does not
     stretch into a banner on a wide panel. */
  flex: 1 1 auto; min-width: 9rem; max-width: 15rem;
  background: var(--sc-accent); color: var(--sc-accent-ink);
  border-color: transparent;
  font-size: var(--sc-t-base);
  padding: 0.85rem var(--sc-6);
}
.pdp__buy .buy .btn:hover { filter: brightness(1.08); }
.pdp__view {
  border: 0; background: none; cursor: pointer;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--sc-ink-soft); padding: 0.6rem 0.2rem;
}
.pdp__view:hover { color: var(--sc-ink); }

/* The three questions a buyer asks before committing, answered in the place
   they ask them. Hairlines above and below rather than a box: it is a note on
   the page, not another card on it. */
.pdp__trust {
  order: 5;
  list-style: none; margin: var(--sc-6) 0 0; padding: var(--sc-4) 0;
  border-top: 1px solid var(--sc-hairline);
  border-bottom: 1px solid var(--sc-hairline);
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sc-4);
}
.pdp__trust li { display: grid; gap: 0.15rem; }
.pdp__trust b { font-weight: 500; font-size: var(--sc-t-sm); color: var(--sc-ink); }
.pdp__trust span { font-size: var(--sc-t-xs); color: var(--sc-ink-soft); line-height: 1.4; }

/* The delivered total, directly under the three promises it is about.

   It had no `order` here at all — only in the stacked media query below — and
   an unnumbered flex/grid child does not stay where the DOM put it, it sorts
   to 0 and goes FIRST. So on a laptop the price of the thing appeared above
   its own subtitle and name, spanning the panel, hundreds of pixels from the
   price it explains. Exactly the same mistake as the gallery, in the exact
   same file, caught on one breakpoint and not the other. Every child of an
   ordered grid needs a number or none of them do. */
.pdp__allin { order: 6; }
.pdp__more { order: 7; display: grid; gap: var(--sc-4); margin-top: var(--sc-6); }
.pdp__more .pdp__blurb { margin: 0; }
.pdp__more .pdp__spec { margin: 0; }
/* The panel is an explicitly ordered grid, so anything added to it has to say
   where it goes. Without an order it lands at 0 — in front of the product's
   own name, which is exactly where the supplier block first appeared. */
.pdp__panel .mkr  { order: 8; }
.pdp__panel .revs { order: 9; }

@media (max-width: 560px) {
  /* Three columns of trust on a phone is three columns of two words. */
  .pdp__trust { grid-template-columns: 1fr; gap: var(--sc-3); }
  .pdp__gain { padding-left: 0; border-left: 0; width: 100%; }
  .pdp__buy .buy { width: 100%; }
}

/* ══════════════════════════════════ the product page, below the fold ═══════
   The hero row is the object and the pitch. Everything under it is evidence,
   and it stays in the column it belongs to: the clips sit under the object
   because they are the object in someone's hand, the reviews sit under the
   pitch because they are the argument continued by other people.

   The grid was centred on a single viewport when the page held nothing else.
   With two columns that now grow at different rates, centring would float the
   short one against the middle of the tall one, so both start at the top. */

.pdp { align-items: start; min-height: auto; }
.pdp__object { align-content: start; gap: var(--sc-7); }
/* The object keeps its full-height presence at the top of the page even though
   the column below it now scrolls. */
.pdp__stage { margin-top: var(--sc-6); }

/* ── the supplier's photographs ────────────────────────────────────────────
   A quiet strip under the object, not a carousel and not a hero. The object
   above is the argument; these are the evidence behind it, and evidence is
   allowed to be small. Scrolls horizontally rather than wrapping, so the row
   never becomes a second grid competing with the one it sits inside. */

.gal { display: grid; gap: var(--sc-3); width: min(100%, 30rem); }
.gal__h { margin: 0; }
.gal__row {
  display: flex; gap: var(--sc-2);
  overflow-x: auto;
  scroll-snap-type: x proximity;
  /* The scrollbar is chrome the page has no use for; the overflow is obvious
     from the half-visible cell at the edge, which is the better signal. */
  scrollbar-width: none;
  padding-bottom: 2px;
}
.gal__row::-webkit-scrollbar { display: none; }
.gal__cell {
  flex: 0 0 auto;
  width: 5.5rem; height: 5.5rem;
  border-radius: var(--glass-r);
  overflow: hidden;
  scroll-snap-align: start;
  background: var(--glass);
  box-shadow: inset 0 0 0 1px var(--glass-rim), inset 0 1px 0 var(--glass-rim-hi);
  transition: transform var(--sc-d-base) var(--sc-ease-out),
              box-shadow var(--sc-d-base) var(--sc-ease-out);
}
.gal__cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.gal__cell:hover, .gal__cell:focus-visible {
  transform: translateY(-2px);
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.22), 0 10px 22px rgb(0 0 0 / 0.4);
}

/* ── who makes it ──────────────────────────────────────────────────────────
   The manufacturer's own record, set as a spec table rather than as prose,
   because every line of it is a number somebody published and prose would
   invite rounding one. */

.mkr { display: grid; gap: var(--sc-3); margin-top: var(--sc-7); }
.mkr__h { margin: 0; }
.mkr__n {
  margin: 0;
  font-family: var(--sc-font-display); font-weight: 600;
  font-size: var(--sc-t-md); color: var(--sc-ink);
}
.mkr__facts {
  display: grid; gap: 0; margin: 0;
  grid-template-columns: 1fr;
}
.mkr__facts > div {
  display: flex; justify-content: space-between; gap: var(--sc-4);
  padding: 0.55rem 0;
  border-bottom: 1px solid rgb(255 255 255 / 0.06);
}
.mkr__facts dt { color: var(--sc-ink-soft); font-size: var(--sc-t-xs); }
.mkr__facts dd {
  margin: 0; font-family: var(--sc-font-mono);
  font-size: var(--sc-t-xs); color: var(--sc-ink);
}
.mkr__src {
  margin: 0; font-size: 0.72rem; line-height: 1.5;
  color: var(--sc-ink-faint, var(--sc-ink-soft));
}

/* ── how people sell it ─────────────────────────────────────────────────── */

.clips { display: grid; gap: var(--sc-3); width: min(100%, 30rem); }
.clips__h { margin: 0; }
/* Dealt, not tiled. Three equal rectangles in a row is a contact sheet; a
   slight rotation and a stagger off the baseline makes them read as three
   cards someone put down — which is what they are, three clips from three
   different people. The angles alternate and the middle one sits proudest, so
   the group has a centre instead of a direction. */
.clips__row {
  display: grid; grid-template-columns: repeat(3, 1fr);
  gap: var(--sc-3);
  padding: 0.4rem 0 1.1rem;
}
.clips__row .clip {
  --tilt: 0deg; --drop: 0px;
  transform: rotate(var(--tilt)) translateY(var(--drop));
  transform-origin: 50% 100%;
}
.clips__row .clip:nth-child(1) { --tilt: -2.4deg; --drop: 10px; z-index: 1; }
.clips__row .clip:nth-child(2) { --tilt: 0deg;    --drop: 0px;  z-index: 3; }
.clips__row .clip:nth-child(3) { --tilt: 2.4deg;  --drop: 10px; z-index: 2; }
/* Lifting one straightens it and brings it out of the deck. */
.clips__row .clip:hover, .clips__row .clip:focus-visible {
  --tilt: 0deg; --drop: -6px;
  z-index: 4;
}

.clip {
  position: relative; display: block;
  aspect-ratio: 9 / 16;
  border-radius: var(--sc-r-md);
  overflow: hidden;
  text-decoration: none;
  background: var(--sc-surface);
  box-shadow: inset 0 0 0 1px var(--glass-rim);
  transition: transform var(--sc-d-base) var(--sc-ease-out),
              box-shadow var(--sc-d-base) var(--sc-ease-out);
}
.clip:hover, .clip:focus-visible {
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--sc-accent) 50%, transparent),
              0 12px 30px -12px rgb(0 0 0 / 0.7);
}
/* Held back at rest, full strength on hover. These are somebody else's meme
   thumbnails — yellow outlines, green numbers — and at full saturation three
   of them shout over a page that is otherwise black and quiet. Dimming them
   also stops the burned-in captions competing with our own label. */
.clip__poster {
  position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover;
  filter: saturate(0.88) brightness(0.8);
  transition: filter var(--sc-d-base) var(--sc-ease-out);
}
.clip:hover .clip__poster, .clip:focus-visible .clip__poster { filter: none; }

/* A triangle drawn with borders — no asset, no SVG to keep in step with the
   theme — on a dark disc, because half these frames have a white phone or a
   bright caption exactly where the play mark lands. */
.clip__play {
  position: absolute; left: 50%; top: 44%;
  translate: -50% -50%;
  width: 2rem; height: 2rem;
  border-radius: 50%;
  background: rgb(8 9 12 / 0.55);
  backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.28);
}
.clip__play::after {
  content: "";
  position: absolute; left: 50%; top: 50%;
  translate: -35% -50%;
  border-left: 0.62rem solid rgb(255 255 255 / 0.96);
  border-top: 0.4rem solid transparent;
  border-bottom: 0.4rem solid transparent;
}

/* The label has to win against whatever is behind it, and on these frames that
   can be white text on a white phone. A gradient alone is not enough, so the
   strip gets a floor of its own under it. */
.clip__meta {
  position: absolute; inset: auto 0 0 0;
  display: grid; gap: 0.1rem;
  padding: 2.4rem 0.6rem 0.6rem;
  background:
    linear-gradient(to top,
      rgb(4 5 8 / 0.95) 0%,
      rgb(4 5 8 / 0.88) 34%,
      rgb(4 5 8 / 0.45) 68%,
      transparent 100%);
}
.clip__what {
  font-size: var(--sc-t-xs); color: rgb(255 255 255 / 0.96);
  line-height: 1.25; text-shadow: 0 1px 3px rgb(0 0 0 / 0.8);
}
.clip__by {
  font-family: var(--sc-font-mono); font-size: 0.62rem;
  color: rgb(255 255 255 / 0.7);
  text-shadow: 0 1px 3px rgb(0 0 0 / 0.8);
}
.clips__note {
  margin: 0; font-family: var(--sc-font-mono);
  font-size: var(--sc-t-xs); color: var(--sc-ink-soft); opacity: 0.72;
}

/* ── what buyers said ───────────────────────────────────────────────────── */

.revs {
  margin-top: var(--sc-7);
  padding-top: var(--sc-6);
  border-top: 1px solid var(--sc-hairline);
  display: grid; gap: var(--sc-4);
}
.revs__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sc-4); flex-wrap: wrap; }
.revs__h { margin: 0; }
.revs__score { margin: 0; display: flex; align-items: center; gap: var(--sc-2); }
.revs__num {
  font-family: var(--sc-font-display); font-weight: 600;
  font-size: var(--sc-t-lg); letter-spacing: -0.02em;
}
.revs__stars { display: inline-flex; gap: 0.12rem; }
.revs__n { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-ink-soft); }

/* A star per span, clipped to a five-pointed path. The half state is the same
   shape with a hard-stop gradient, so a 4.6 is drawn as a 4.5 rather than
   rounded up to a number the supplier did not earn. */
.star {
  width: 0.62rem; height: 0.62rem;
  background: color-mix(in oklab, var(--sc-ink) 18%, transparent);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.star.is-on { background: var(--sc-accent); }
.star.is-half {
  background: linear-gradient(90deg,
    var(--sc-accent) 0 50%,
    color-mix(in oklab, var(--sc-ink) 18%, transparent) 50% 100%);
}

.revs__list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sc-4); }
.rev[hidden] { display: none; }
.rev {
  display: grid; gap: 0.3rem;
  padding-left: var(--sc-4);
  border-left: 1px solid var(--sc-hairline);
}
.rev__who {
  margin: 0; display: flex; gap: var(--sc-3); align-items: baseline; flex-wrap: wrap;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); color: var(--sc-ink-soft);
}
.rev__who b { color: var(--sc-ink); font-weight: 500; }
.rev__t { margin: 0; font-size: var(--sc-t-sm); color: var(--sc-ink); line-height: 1.55; text-wrap: pretty; }
.rev__t--none { color: var(--sc-ink-soft); font-style: italic; }
.rev__v { margin: 0; font-family: var(--sc-font-mono); font-size: 0.68rem; color: var(--sc-ink-soft); opacity: 0.75; }

.revs__more {
  justify-self: start;
  border: 1px solid var(--sc-hairline);
  background: none; cursor: pointer;
  border-radius: var(--sc-r-pill);
  padding: 0.5rem var(--sc-4);
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--sc-ink-soft);
  transition: color var(--sc-d-base) var(--sc-ease-out),
              border-color var(--sc-d-base) var(--sc-ease-out);
}
.revs__more:hover { color: var(--sc-ink); border-color: color-mix(in oklab, var(--sc-accent) 45%, transparent); }

/* Whose reviews these are. Not fine print to be shrunk away — it is the line
   that keeps the block honest, so it stays legible. */
.revs__src {
  margin: 0; max-width: 46ch;
  font-size: var(--sc-t-xs); color: var(--sc-ink-soft); line-height: 1.5;
}

/* Stacked, the two columns become one and the reading order has to be
   rebuilt — not just flattened. Flattened, the column order wins and you get
   the object, then three reels of strangers selling it, and only then the name
   and the price. Nobody watches a sales video for a product they have not been
   told the name of yet.

   `display: contents` dissolves both wrapper divs so all seven blocks become
   direct children of the one grid, which is the only way to interleave things
   that live in different columns on a wide screen. */
@media (max-width: 900px) {
  .pdp__object, .pdp__panel { display: contents; }

  /* Every block ordered explicitly. Two wrappers dissolve into one grid here,
     so nine items from two different parents compete on one scale — leaving
     any of them to inherit a shared number means DOM order silently decides,
     and DOM order is the desktop's two columns, not this page.

     The reels sit directly under the button because that is where the page had
     a hole: the price block only fills the left half of a 390pt screen, so
     everything from the title down to Add had dead space beside it. Proof of
     people selling it is the right thing to put in the gap right after you
     have asked someone to buy it. */
  /* Two columns, and almost everything spans both. The exception is the pair
     that shares a line: the price on the left, the reels beside it on the
     right. That gap existed because the price block only fills half the width
     of a phone — so rather than push the reels into a section of their own
     further down the page, they go in the hole, level with the number they are
     evidence for. */
  .pdp { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  /* NOT `.pdp > *`. display:contents makes these children participate in the
     grid, but the child combinator still walks the DOM, where they are
     children of the wrappers — so `.pdp > *` matched only the two wrappers and
     every real block quietly fell into column one. */
  .pdp__object > *, .pdp__panel > *, .clips > * { grid-column: 1 / -1; }

  /* Every block gets an order, including the two that used to go without.
     `order` defaults to 0, so an unnumbered child does not stay where the DOM
     put it — it jumps ahead of everything numbered. The gallery had no number
     and landed ABOVE the object it belongs to; the delivered-total line had
     none either and rose to the top of the page, hundreds of pixels away from
     the price it is explaining. Neither was a layout decision, both were the
     absence of one. */
  .pdp__stage     { order: 1; margin-top: 0; justify-self: center; }
  /* The photographs sit under the object, which is where the reader is
     already looking and which keeps all the imagery of the thing together. */
  .gal            { order: 2; }
  .pdp__sub       { order: 3; }
  .pdp__n         { order: 4; }
  .pdp__price-row { order: 5; grid-column: 1; align-content: center; }
  .clips          { order: 6; grid-column: 2; margin: 0; align-self: center; }
  .pdp__buy       { order: 8; }
  /* On a phone, straight under Add and the quantity - Andre's call,
     2026-09-27. Desktop keeps it under the photographs. SAME BUCKET AS THE
     TRUST LIST rather than a number of its own: items sharing an `order` fall
     back to document order, and .pdp__batch lives in .pdp__object, which comes
     before .pdp__panel in the DOM - so it lands after .pdp__buy and before
     .pdp__trust without renumbering anything below. */
  .pdp__batch     { order: 9; }
  .pdp__trust     { order: 9; }
  /* Directly under the three promises, because it is the number those
     promises are about, and above "who makes it". */
  .pdp__allin     { order: 10; }
  .pdp__more      { order: 11; }
  .pdp__panel .mkr { order: 12; }
  .pdp__panel .revs, .revs { order: 13; margin-top: var(--sc-5); }

  /* Beside a price there is no room for a heading, and none needed — three
     stills of people holding the thing explain themselves. The credit stays,
     because it is the line that keeps someone else's video honestly theirs;
     it drops below the pair and runs the full width. */
  .clips { display: contents; }
  .clips__h { display: none; }
  .clips__row {
    order: 6; grid-column: 2; align-self: center;
    gap: 0; padding: 0;
    /* Overlapped, not spread. At a third of half a screen each card is 55px
       wide, and three 55px slivers in a row read as a broken image. Dealt into
       a stack they read as a deck of clips. */
    grid-template-columns: repeat(3, 1fr);
  }
  .clips__row .clip:nth-child(1) { --tilt: -5deg; --drop: 6px; margin-right: -22%; }
  .clips__row .clip:nth-child(2) { --tilt: 0deg;  --drop: 0px; }
  .clips__row .clip:nth-child(3) { --tilt: 5deg;  --drop: 6px; margin-left: -22%; }
  /* No captions at 67px. Two words will not fit the card they belong to, and
     what happens instead is that they run over the neighbour — the label of
     one clip sitting on the still of another. The stills carry themselves
     here; the credit below carries the attribution. */
  .clips__row .clip__meta { display: none; }
  .clips__row .clip__play { width: 1.3rem; height: 1.3rem; }
  .clips__row .clip__play::after {
    border-left-width: 0.4rem; border-top-width: 0.26rem; border-bottom-width: 0.26rem;
  }
  .clips__note {
    order: 7; grid-column: 1 / -1;
    margin-top: var(--sc-3); font-size: 0.66rem; line-height: 1.45;
  }

  /* The stage was a third of a wide page; here it is the whole width, so it
     no longer needs a square of its own to sit in. */
  .pdp__stage { width: min(100%, 20rem); min-height: 0; }
  .clips__row { gap: var(--sc-2); }
}

/* ═══════════════════════════════════════════════ search, on The Line ═══════
   In the header beside Home, and only on this page. It does not navigate — it
   turns the ring — so it is styled as a control on the bar rather than as a
   field in a form: no filled box, just a rule under it that lights when it has
   the focus. */

.bar__nav { display: flex; align-items: center; gap: var(--sc-5); }

.search { position: relative; }

/* Same glass as the bag button beside it and the cards below: a pill of blur
   with a rim and a lit top lip. The rim is what makes it an object — a blurred
   panel with no edge reads as a smudge on the page rather than a piece of
   glass laid on it. */
.search__box {
  display: flex; align-items: center; gap: var(--sc-2);
  padding: 0.42rem 0.85rem;
  border-radius: var(--sc-r-pill);
  background: var(--glass);
  backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: inset 0 0 0 1px var(--glass-rim), inset 0 1px 0 var(--glass-rim-hi);
  transition: background var(--sc-d-base) var(--sc-ease-out),
              box-shadow var(--sc-d-base) var(--sc-ease-out);
}
.search__box:hover { background: var(--glass-strong); }
.search__box:focus-within,
.search.is-hit .search__box {
  background: var(--glass-strong);
  box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--sc-accent) 55%, transparent),
              inset 0 1px 0 var(--glass-rim-hi);
}

/* The glass, drawn rather than fetched: a ring and a handle. */
.search__i {
  width: 0.72rem; height: 0.72rem; flex: none;
  border: 1.4px solid var(--sc-ink-soft);
  border-radius: 50%;
  position: relative;
}
/* No handle. At 11px the stem is a 5px diagonal hair hanging off a circle,
   and at that size it does not read as a magnifying glass — it reads as a
   rendering fault. The ring alone is unambiguous. */
.search__box:focus-within .search__i { border-color: var(--sc-accent); }

.search input {
  width: clamp(7rem, 15vw, 11rem);
  border: 0; background: none; outline: none;
  /* The same type as the Bag button on the other end of the bar. They are the
     two controls on the header and they were set differently, which read as
     two different pieces of furniture rather than as a pair. */
  font-family: var(--sc-font-text);
  font-weight: 350;
  letter-spacing: 0.004em;
  /* NEVER below 16px. Safari on iOS zooms the whole page in when a field it is
     focusing has type smaller than that, and it does not zoom back out — so
     tapping Search threw the reader into a magnified page they then had to
     pinch their way out of, every single time. 16px is the threshold, not a
     preference, which is why it is a max() and not a media query: the design
     keeps the smaller size wherever it is safe to have it. */
  font-size: max(16px, var(--sc-t-base));
  color: var(--sc-ink);
  padding: 0;
}
.search input::placeholder { color: var(--sc-ink-soft); }
/* WebKit draws its own clear button and it belongs to a different design. */
.search input::-webkit-search-cancel-button { -webkit-appearance: none; }

/* The suggestions are the same material, one step heavier — it is a panel you
   read through rather than a control you press, so it needs to hold text
   against whatever is turning behind it. */
.search__list {
  position: absolute; left: 0; top: calc(100% + 0.5rem);
  z-index: 40;
  min-width: 15rem; max-width: 22rem;
  margin: 0; padding: 0.35rem; list-style: none;
  border-radius: var(--glass-r);
  background: var(--glass-strong);
  backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: var(--sc-e3),
              inset 0 0 0 1px var(--glass-rim),
              inset 0 1px 0 var(--glass-rim-hi);
  animation: search-in var(--sc-d-base) var(--sc-ease-out);
}
@keyframes search-in { from { opacity: 0; transform: translateY(-4px); } }
@media (prefers-reduced-motion: reduce) { .search__list { animation: none; } }
.search__list[hidden] { display: none; }
.search__list button {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sc-4);
  width: 100%; padding: 0.5rem 0.6rem;
  border: 0; border-radius: var(--sc-r-sm);
  background: none; cursor: pointer; text-align: left;
  color: var(--sc-ink-soft);
}
.search__list button:hover,
.search__list button.is-on { background: var(--glass-hover); color: var(--sc-ink); }
.sg__n { font-size: var(--sc-t-sm); }
.sg__n b { color: var(--sc-accent); font-weight: 500; }
.sg__m {
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft); white-space: nowrap;
}
.sg--none {
  padding: 0.6rem; font-size: var(--sc-t-xs);
  font-family: var(--sc-font-mono); color: var(--sc-ink-soft);
}

/* ── the bar on a phone ───────────────────────────────────────────────────
   390 points, three columns, and the middle one is reserved for a wordmark
   that is centred on the VIEWPORT rather than on the bar. So each side column
   is about 130px, and everything below is the arithmetic of living in that.

   The rule this replaces was `.bar__nav { display: flex }` written for the
   search box, which landed after the phone rule that hides the nav and
   silently switched it back on — putting "The Line" and "How it works" back
   on a 390px bar, straight through the wordmark. */
@media (max-width: 700px) {
  /* The home page keeps The Line and nothing else. Two links do not fit a
     130pt column — they ran through the wordmark — but one does, and it is the
     one that goes to the shop. "How it works" is a scroll away in the page. */
  .bar:not(.bar--static) .bar__nav { display: flex; min-width: 0; }
  .bar:not(.bar--static) .bar__nav > a + a { display: none; }

  /* A page you arrived at from somewhere keeps its way back — losing the back
     link is worse than a tight bar. */
  .bar--static .bar__nav { display: flex; gap: var(--sc-3); min-width: 0; }

  /* ...unless it also carries the search, which needs the whole column. The
     wordmark beside it is a link home, so nothing is lost. */
  .bar__nav:has(.search) > a { display: none; }

  /* The pill fills what is left and the field shrinks with it. Without
     min-width:0 the input's own clamp() wins, the pill overflows its column,
     and it runs under the wordmark. */
  .search, .search__box { min-width: 0; flex: 1; }
  .search input { width: 100%; min-width: 0; }
  .search__box { padding: 0.4rem 0.7rem; }
  .search__list { min-width: min(17rem, calc(100vw - 2 * var(--sc-gutter))); }
}

/* The ring is a thing you take hold of, so it says so. */
.ring-stage { cursor: grab; pointer-events: auto; }
html.is-turning, html.is-turning .ring-stage, html.is-turning .ring__item { cursor: grabbing; }



/* ── the pay sheet's own footer ─────────────────────────────────────────────
   In elements mode Stripe renders only the fields; the button is ours, so it
   has to look like every other primary action on the site rather than like
   something a payment processor put there. */
.pay__foot {
  border-top: 1px solid var(--sc-hairline);
  padding: var(--sc-4) var(--sc-5);
  display: grid; gap: var(--sc-3);
}
.pay__foot[hidden] { display: none; }
.pay__go {
  width: 100%;
  border: 0; border-radius: var(--sc-r-pill);
  padding: 0.9rem var(--sc-5);
  background: var(--sc-accent); color: var(--sc-accent-ink);
  font-family: var(--sc-font-text); font-size: var(--sc-t-base); font-weight: 500;
  cursor: pointer;
  transition: filter var(--sc-d-base) var(--sc-ease-out), opacity var(--sc-d-base) var(--sc-ease-out);
}
.pay__go:hover:not(:disabled) { filter: brightness(1.08); }
/* Disabled until Stripe says the form can be confirmed — a pay button that is
   pressable before the card is complete only ever produces an error. */
.pay__go:disabled { opacity: 0.4; cursor: default; }
.pay__foot .pay__fine { padding: 0; border: 0; margin: 0; }

/* ═══════════════════════════════════════════════════════════ tracking ══════
   One column, one job. It is the page someone opens anxious, so the state is
   the biggest thing on it and everything else is subordinate to that. */

.page-track { min-height: 100%; }
.track {
  position: relative; z-index: 1;
  min-height: 100svh;
  padding: calc(var(--bar-h) + var(--sc-8)) var(--sc-gutter) var(--sc-8);
  display: grid; justify-items: center; align-content: start;
}
.track__inner { width: min(34rem, 100%); display: grid; gap: var(--sc-3); }
.track__eyebrow { margin: 0; }
.track__h { margin: 0; }
.track__lede { margin: 0 0 var(--sc-4); color: var(--sc-ink-soft); }
.track__eg {
  font-family: var(--sc-font-mono); color: var(--sc-ink);
  background: var(--glass); padding: 0.1em 0.4em; border-radius: var(--sc-r-sm);
}
.track__form { display: grid; gap: var(--sc-4); }
.track__go { justify-self: start; }
.track__err { margin: 0; font-size: var(--sc-t-sm); color: #ff8a7a; }

/* ── the order ──────────────────────────────────────────────────────────── */
.order {
  margin-top: var(--sc-7); padding-top: var(--sc-6);
  border-top: 1px solid var(--sc-hairline);
  display: grid; gap: var(--sc-5);
}
.order[hidden] { display: none; }
.order__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sc-4); flex-wrap: wrap; }
.order__ref {
  margin: 0; font-family: var(--sc-font-mono); font-size: var(--sc-t-sm);
  letter-spacing: 0.1em; color: var(--sc-ink-soft);
}
.order__state {
  margin: 0; font-family: var(--sc-font-display); font-weight: 600;
  font-size: var(--sc-t-lg); letter-spacing: var(--sc-track-snug);
}

/* The rail. `.steps` was already taken — the home page's how-it-works cards
   use it with auto-fit columns, and inheriting that quietly laid these four
   out two-by-two. Renamed rather than out-specified: a class that means two
   different things in one stylesheet is a bug waiting for the next person.

   A step that has not happened is dim and hollow; the one happening
   now is lit and ringed. Nothing here is ever drawn as done on a guess — the
   fourth step waits on a real tracking number from the supplier. */
.trail { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
.trail__i {
  display: grid; grid-template-columns: 1.4rem 1fr; column-gap: var(--sc-4);
  padding-bottom: var(--sc-5); position: relative;
}
.trail__i:last-child { padding-bottom: 0; }
/* The line joining the dots, drawn from each step except the last. */
.trail__i:not(:last-child)::before {
  content: ""; position: absolute;
  left: 0.6rem; top: 1rem; bottom: 0;
  width: 1px; background: var(--sc-hairline);
}
.trail__i.is-done:not(:last-child)::before { background: color-mix(in oklab, var(--sc-accent) 45%, transparent); }
.trail__dot {
  width: 0.6rem; height: 0.6rem; margin-top: 0.42rem;
  border-radius: 50%; justify-self: center;
  background: none;
  box-shadow: inset 0 0 0 1.5px var(--sc-hairline-strong);
  position: relative; z-index: 1;
}
.trail__i.is-done .trail__dot { background: var(--sc-accent); box-shadow: none; }
.trail__i.is-now .trail__dot {
  background: var(--sc-accent);
  box-shadow: 0 0 0 4px color-mix(in oklab, var(--sc-accent) 22%, transparent);
}
.trail__n { font-size: var(--sc-t-base); color: var(--sc-ink-soft); }
.trail__i.is-done .trail__n, .trail__i.is-now .trail__n { color: var(--sc-ink); }
.trail__i.is-now .trail__n { font-weight: 500; }
.trail__note { grid-column: 2; font-size: var(--sc-t-xs); color: var(--sc-ink-soft); opacity: 0.8; }

.order__track {
  margin: 0; padding: var(--sc-4);
  border-radius: var(--sc-r-md);
  background: var(--glass);
  box-shadow: inset 0 0 0 1px var(--glass-rim);
  font-size: var(--sc-t-sm); color: var(--sc-ink-soft);
}
.order__track span { font-family: var(--sc-font-mono); font-size: var(--sc-t-xs); text-transform: uppercase; letter-spacing: 0.1em; }
.order__track b { font-family: var(--sc-font-mono); color: var(--sc-ink); font-weight: 500; }
.order__track em { font-style: normal; opacity: 0.7; }
.order__track--none { background: none; box-shadow: none; padding: 0; }

.order__lines { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; font-size: var(--sc-t-sm); }
.order__lines li { display: flex; justify-content: space-between; gap: var(--sc-4); color: var(--sc-ink-soft); }
.order__lines .is-total {
  padding-top: var(--sc-3); margin-top: var(--sc-2);
  border-top: 1px solid var(--sc-hairline); color: var(--sc-ink);
}
.order__amt { font-family: var(--sc-font-mono); font-variant-numeric: tabular-nums; }

.order__meta {
  display: flex; flex-wrap: wrap; gap: var(--sc-3) var(--sc-6); margin: 0;
  padding-top: var(--sc-4); border-top: 1px solid var(--sc-hairline);
}
.order__meta div { display: grid; gap: 0.1rem; }
.order__meta dt {
  font-family: var(--sc-font-mono); font-size: 0.68rem;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--sc-ink-soft);
}
.order__meta dd { margin: 0; font-size: var(--sc-t-sm); color: var(--sc-ink); }

/* The order number on the confirmation page: the one thing the reader has to
   keep, so it is set to be read off a screen and typed on a phone. */
.done__ref {
  margin: var(--sc-5) 0 0;
  display: inline-flex; align-items: baseline; gap: var(--sc-3);
  justify-self: center;
  padding: 0.6rem var(--sc-5);
  border-radius: var(--sc-r-pill);
  background: var(--glass);
  box-shadow: inset 0 0 0 1px var(--glass-rim), inset 0 1px 0 var(--glass-rim-hi);
}
.done__ref span {
  font-family: var(--sc-font-mono); font-size: var(--sc-t-xs);
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--sc-ink-soft);
}
.done__ref b {
  font-variant-numeric: tabular-nums;
  font-family: var(--sc-font-mono); font-size: var(--sc-t-base);
  font-weight: 500; letter-spacing: 0.06em; color: var(--sc-ink);
}
.done__acts { gap: var(--sc-3); flex-wrap: wrap; }
.foot__link {
  color: var(--sc-ink-soft); text-decoration: none;
  font-size: var(--sc-t-xs); font-family: var(--sc-font-mono);
  letter-spacing: 0.08em; text-transform: uppercase;
}
.foot__link:hover { color: var(--sc-ink); }

/* ═════════════════════════════════════════ the phone ══════════════════════
   Everything here is a phone-only correction to something that is right on a
   desktop. */

@media (max-width: 700px) {
  /* CARDS LIE DOWN ON A PHONE.

     Two columns of portrait cards on a 390px screen gave each card 169px of
     width against 438 of height, so the object, the name, the price and the
     size picker all queued up in a gutter. One landscape card per row fits
     three on a screen instead of one.

     Written with a FIXED image column and explicit caps, after three attempts
     with percentages failed in three different ways: min-height:100% resolved
     against an auto-height parent and collapsed the plate to 98px inside a
     305px card; align-self:stretch then filled the height but let the image
     take its natural width at that height, 393px inside a 350px card, and it
     spilled across the text. A fixed rem column and a clipped plate cannot do
     either of those things. */
  .grid:not(.grid--solo) {
    grid-template-columns: 1fr;
    gap: var(--sc-3);
  }
  .grid:not(.grid--solo) .card {
    display: grid;
    grid-template-columns: 7.5rem minmax(0, 1fr);
    grid-template-rows: auto auto;
    min-height: 0;
  }
  .grid:not(.grid--solo) .card > * { min-width: 0; }

  .grid:not(.grid--solo) .card__plate {
    grid-column: 1; grid-row: 1 / 3;
    aspect-ratio: auto;
    min-height: 0; height: auto;
    /* NOT align-self: stretch. Tried twice; both times the plate stretched
       vertically and then took 406px of width inside a 350px card, spilling
       over the text. The cost of leaving it auto is only that the divider
       stops under the blurb instead of running the full height - a cosmetic
       nit, against a layout that visibly breaks. Leave it. */
    /* The clip is the guarantee. Whatever the image does, it cannot leave. */
    overflow: hidden;
    padding: var(--sc-2);
    display: grid; place-items: center;
    border-bottom: 0;
    border-right: 1px solid var(--sc-hairline);
  }
  /* Static, so the plate's own grid centres it, and capped on BOTH axes so it
     can never drive the column wider than the column is. */
  .grid:not(.grid--solo) .card__shot {
    position: static; inset: auto; margin: 0;
    width: auto; height: auto;
    max-width: 100%; max-height: 7.5rem;
    object-fit: contain;
  }
  .grid:not(.grid--solo) .card__glyph { display: none; }

  .grid:not(.grid--solo) .card__body {
    grid-column: 2; grid-row: 1;
    padding: var(--sc-3) var(--sc-3) var(--sc-1);
  }
  .grid:not(.grid--solo) .card__buy {
    grid-column: 2; grid-row: 2;
    padding: 0 var(--sc-3) var(--sc-3);
    gap: var(--sc-2);
    border-top: 0;
  }

  /* THEY WRAP AGAIN, and the reason the first decision was right no longer
     holds.

     That decision - one row that scrolls sideways - was taken when the chips
     wrapped onto THREE rows and tripled the card's height. A sideways scroller
     was genuinely the better trade then.

     It stopped being the better trade once the chips got smaller (0.7rem,
     0.3rem padding, further down this file). Measured at 390px: wrapping now
     costs TWO rows, not three, on exactly two of the five cards - the Tennis
     Bracelet 302px -> 336px and the Silver Starry Ring 287px -> 320px. Thirty
     four pixels.

     And the scroller was not free. Its scrollbar is hidden, so the Tennis
     Bracelet's fourth size was simply sliced off at the card's edge with
     nothing to say it could be pushed - measured clipped:true, and Andre read
     it as broken rather than scrollable, which is the correct reading. A
     control you cannot see and cannot discover is not a control.

     Ten sizes cannot fit one 350px row at a thumb-sized target anyway. Two
     rows of whole chips beats one row of sliced ones. */
  .grid:not(.grid--solo) .pick__row { flex-wrap: wrap; }
  .grid:not(.grid--solo) .pick__b { flex: 0 0 auto; }

  /* iOS zooms the page in when it focuses a field whose text is under 16px,
     and it does not zoom back out. Every field, not just the search box —
     meeting it for the first time halfway through typing an address is worse
     than meeting it on the search. */
  input, textarea, select { font-size: max(16px, 1rem); }

  /* The Line's heading. The three halos are tuned for a 27-inch screen two
     feet away; on a phone held at ten inches the same light is a bloom that
     washes out the letters it is supposed to be lighting. Cut roughly in half
     and pulled in — the effect survives, the haze does not. */
  .ring-copy__h {
    text-shadow:
      0 0 10px rgb(214 234 255 / 0.30),
      0 0 28px rgb(150 190 255 / 0.18),
      0 0 60px rgb(110 155 255 / 0.10);
  }

  /* "The supplier's photographs" is a label for a row that, on a phone, sits
     directly under the object it plainly belongs to. The heading is doing no
     work there and it costs a line of vertical space on the smallest screen.
     Kept for screen readers, which is the half that was actually useful. */
  .gal__h {
    position: absolute; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; border: 0;
  }

  /* The search box on The Line, which was ending 8px short of the wordmark —
     close enough that the two read as one crowded object rather than as a
     control beside a name. The field is the part that gives: 15vw of a phone
     is under the 7rem floor, so the clamp was pinned to its minimum and the
     box could not shrink no matter how narrow the screen got. A lower floor
     lets it. The font size does NOT come down with it — under 16px iOS zooms
     the page in on focus and does not zoom back out. */
  .search input { width: clamp(4.5rem, 20vw, 7rem); }

  /* Top sellers, four to the area they had. One card a row meant a 350px box
     747px tall, so a phone showed one product and a scroll bar's worth of
     hope; two to a row is a quarter of the area each and puts four products
     on a screen. The type inside has to come down with the box or the name
     wraps to three lines in a card that is now half as wide. */
  .grid:not(.grid--solo) {
    /* ONE column, not two. Two columns of portrait cards on a 390px screen
       gives each card about 169px of width - narrower than it is tall by a
       factor of nearly three, so the object, the name, the price and the size
       picker all queue up in a gutter. One landscape card per row is wider
       than the pair and taller than neither. */
    grid-template-columns: 1fr;
    gap: var(--sc-3);
  }
  .grid:not(.grid--solo) .card__body { padding: var(--sc-3); }
  .grid:not(.grid--solo) .card__n { font-size: var(--sc-t-sm); }
  .grid:not(.grid--solo) .card__sub,
  .grid:not(.grid--solo) .card__blurb { font-size: 0.7rem; line-height: 1.4; }
  .grid:not(.grid--solo) .card__buy { padding: var(--sc-3); gap: var(--sc-2); }
  .grid:not(.grid--solo) .card__price { font-size: var(--sc-t-base); }
  /* A stepper, an Add button and four size chips do not fit across 170px.
     Stacked they do, and the chips stay legible. */
  .grid:not(.grid--solo) .pick__row { gap: 0.3rem; }
  .grid:not(.grid--solo) .pick__b { padding: 0.34rem 0.5rem; font-size: 0.76rem; }
  .grid:not(.grid--solo) .pick__l,
  .grid:not(.grid--solo) .pick__h { display: none; }
  /* Two to a row only works if a card stops trying to be the product page.
     The blurb and the spec list are 330px of the 1045 and both are repeated
     verbatim two taps away, so at this size they are cost without content.
     What has to survive is the four things somebody chooses between on:
     the object, the name, the price, and what they keep. */
  .grid:not(.grid--solo) .card__blurb,
  .grid:not(.grid--solo) .card__spec { display: none; }
  /* "Typical resale" as well as "You keep" is two rows explaining one idea.
     The margin is the row that sells; the other is on the product page. */
  /* One line, not a two-column table. At 145px of content the table put "You"
     over "keep" and "$47 a" over "unit" — four lines to say one thing. */
  .grid:not(.grid--solo) .card__econ {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.3rem;
    margin-top: var(--sc-2); font-size: 0.72rem;
  }
  .grid:not(.grid--solo) .card__econ dt:first-of-type,
  .grid:not(.grid--solo) .card__econ dd:first-of-type { display: none; }
  .grid:not(.grid--solo) .card__econ dt,
  .grid:not(.grid--solo) .card__econ dd { margin: 0; white-space: nowrap; }

  /* Wrapping, per the note above - a 3+1 wrap reads as a mistake, but a chip
     sliced in half at the card edge reads as broken, and that is what nowrap
     was actually producing here. */
  .grid:not(.grid--solo) .pick__row { flex-wrap: wrap; gap: 0.22rem; }
  .grid:not(.grid--solo) .pick__b { padding: 0.3rem 0.34rem; font-size: 0.7rem; }

  /* The stepper and Add on one row. Stacked they were a third of the card's
     height for two controls that belong together. */
  .grid:not(.grid--solo) .card__buy .buy { flex-wrap: nowrap; gap: var(--sc-2); width: 100%; }
  .grid:not(.grid--solo) .card__buy .qty button { width: 1.6rem; height: 1.6rem; font-size: 0.9rem; }
  .grid:not(.grid--solo) .card__buy .qty output { min-width: 1.1rem; font-size: 0.78rem; }
  .grid:not(.grid--solo) .card__buy .btn { padding: 0.4rem 0.7rem; font-size: 0.78rem; }
  .grid:not(.grid--solo) .card__buy > :last-child { margin-left: 0; }

  /* The object was sized for a card twice this wide. */
  .grid:not(.grid--solo) .card__plate { min-height: 0; aspect-ratio: 1.35; }

  /* ── the phone's frame budget ───────────────────────────────────────────
     Everything below is here because a phone was dropping frames on a fast
     scroll and a desktop was not. None of it changes what the site IS; it
     changes what the GPU is asked to do per frame while the page is moving.

     BACKDROP-FILTER is the expensive one, and it is expensive in a way that
     does not show up in element counts: every blurred pane makes the browser
     re-sample everything behind it, every frame, at device pixel ratio — and
     a phone is running 3x on a fraction of the memory bandwidth. Eighteen of
     them were live. The glass is a look, and the look survives a flat tinted
     pane with the same rim; what does not survive a phone is sampling the
     whole page eighteen times a frame while it is being flung. */
  .bar__cart, .search__box, .card, .tile, .glass,
  .drawer, .drawer__body, .drawer__panel, .drawer__scrim, .drawer__x,
  .pay, .pay__body, .clip, .clip__play, .fan__play,
  .btn, .qty, .search__list {
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
  }
  /* Without the blur the panes need their own body, or they read as outlines
     drawn on the sky. */
  .bar__cart, .search__box { background: color-mix(in oklab, var(--sc-canvas) 72%, transparent); }
  .card, .tile { background: color-mix(in oklab, var(--sc-canvas) 78%, transparent); }
  .drawer, .pay, .drawer__panel { background: color-mix(in oklab, var(--sc-canvas) 96%, transparent); }
  .drawer__scrim { background: color-mix(in oklab, #000 62%, transparent); }
  .search__list { background: color-mix(in oklab, var(--sc-canvas) 94%, transparent); }
  .btn--ghost, .qty { background: color-mix(in oklab, var(--sc-canvas) 60%, transparent); }
  .clip__play, .fan__play { background: color-mix(in oklab, #000 45%, transparent); }

  /* A shadow that has to be recomputed from a live alpha channel every frame
     is the second most expensive thing here, and at a third of the size it is
     a smudge nobody can see. */
  .obj3d__frame, .obj3d__cut, .plate3d__face { filter: none; }

  /* The product page, tightened. The stage was a 320px square holding a 210px
     object, so a fifth of the first screen was reserved for the object to
     levitate in — room a desktop has and a phone does not. The name came in at
     113px for two lines on top of that, which pushed the price below the fold
     on the one screen where the price is the point. */
  .pdp__stage { width: min(100%, 17.5rem); }
  .pdp__n { font-size: clamp(2rem, 10.5vw, 2.6rem); line-height: 1.04; }
  .pdp__blurb { font-size: 0.95rem; line-height: 1.55; }

  /* The Line's caption block — subtitle, name, the economics and the Open
     button — was held 16px off the bottom of the screen, which on a phone puts
     it under the browser chrome and the home indicator. Raising it lifts the
     ring's floor with it, because ring.js reads this gap back rather than
     restating it, so the object and its caption move together and neither
     lands on the other. */
  /* A little higher than the middle of the lower half, which is where it was
     and where Andre wants it — but expressed as a TOP now, so it is the same
     place for every product instead of the place the tallest one happened to
     end up. */
  .ring-detail {
    --ring-cap-top: calc(64 * var(--rvh, 1vh));
    --cap-sub: 1.1rem;
    --cap-name: 2.5rem;
    --cap-blurb: 0rem;
    --cap-econ: 2.9rem;
    /* Room for the size chips AND the buttons under them. A bracelet has four
       lengths to choose from and a plate has none, and both get the same
       frame. */
    --cap-act: 6rem;
  }

  /* The buy row on The Line. Four size chips, a stepper and two buttons do not
     sit on ONE line at 390px — but they sit on two: the chips claim a full
     line of their own, and Open, the stepper and Add share the next. Stacking
     all three was 140px in a 102px slot. */
  .ring-detail__act { gap: var(--sc-2) var(--sc-3); width: 100%; }
  /* 0.5rem here left the chips 13px under the economics line while the
     products with NO chips — the Cuban and the tester — were getting 23 from
     the act row's own margin. Both are now the same distance from the text,
     which is the point: the gap should be a property of the layout, not of
     whether this particular product happens to have something to choose. */
  .ring-detail__act .pick { order: -1; flex: 1 0 100%; margin: 0.85rem 0 0; }
  /* At 390px the metal chips, Open, the stepper and Add came to 380px inside a
     358px row, so Add wrapped to a third line and pushed the caption down.
     The swatch already says which metal it is, so the word can go and the
     padding can come in — 126px of chips becomes about 80 and everything sits
     on one line. */
  .ring-detail__act .pick--metal { order: 0; flex: 0 0 auto; margin: 0; }
  .ring-detail__act .pick--metal .pick__b { padding: 0.38rem 0.5rem; font-size: 0; }
  .ring-detail__act .pick--metal .pick__b::before { margin-right: 0; width: 13px; height: 13px; }
  .ring-detail__act .pick__row { justify-content: center; flex-wrap: nowrap; gap: 0.3rem; }
  .ring-detail__act .pick__b { padding: 0.32rem 0.5rem; font-size: 0.74rem; }
  .ring-detail__act .pick__l, .ring-detail__act .pick__h { display: none; }
  /* Open, the stepper and Add on one line under the chips. */
  .ring-detail__act .btn,
  .ring-detail__act .buy { margin: 0; }
  .ring-detail__act .buy { display: flex; gap: var(--sc-2); align-items: center; }
  .ring-detail__act .buy .qty button { width: 1.7rem; height: 1.7rem; }
  .ring-detail__act .btn--solid { padding: 0.44rem 1.1rem; }
}

/* ═══════════════════════════════════════════════════ the legal page ═══════
   Set to be READ, which is the whole point of it. Most policy pages are laid
   out to be scrolled past — grey 12px type, full window width, no hierarchy —
   and a page nobody can read is not a disclosure however complete it is. A
   measure of about 68 characters, real headings, and the same type as the rest
   of the shop. */
.legal { position: relative; z-index: 1; padding-top: calc(var(--bar-h) + var(--sc-7)); }

/* THE FOOTER HAS TO FLOW ON A DOCUMENT PAGE.

   `.foot` is `position: absolute; inset: auto 0 0 0`, which pins it to the
   bottom of its containing block. On the home page that block is a sized
   section and the result is correct. On the legal page the footer sits inside
   `main.legal`, which is `position: relative` and as tall as the whole
   document - so the footer parked itself over the last 116px of the privacy
   text and the terms ran underneath it.

   Measured on a 390px screen: last section 361-796, footer 680-796.

   Static here, absolute everywhere else. Scoped to .page-legal because
   index.html is the only other page with a .foot and its version is right. */
.page-legal .foot {
  position: static;
  inset: auto;
  margin-top: var(--sc-7);
}
.legal__inner { width: min(100% - var(--sc-gutter) * 2, 46rem); margin-inline: auto; }
.legal__eyebrow { color: var(--sc-accent); }
.legal__h { margin: var(--sc-2) 0 var(--sc-4); }
.legal__lede { color: var(--sc-ink-soft); margin: 0 0 var(--sc-6); }

.legal__toc {
  display: flex; flex-wrap: wrap; gap: var(--sc-2) var(--sc-4);
  padding: var(--sc-4) 0; margin-bottom: var(--sc-6);
  border-block: 1px solid var(--sc-hairline);
}
.legal__toc a {
  color: var(--sc-ink-soft); text-decoration: none;
  font-size: var(--sc-t-sm);
}
.legal__toc a:hover { color: var(--sc-accent); }

.legal__s { margin-bottom: var(--sc-7); scroll-margin-top: calc(var(--bar-h) + 1rem); }
.legal__s h2 {
  font-family: var(--sc-font-display); font-size: var(--sc-t-lg); font-weight: 600;
  margin: 0 0 var(--sc-4); letter-spacing: var(--sc-track-snug);
}
.legal__s h3 {
  font-size: var(--sc-t-base); font-weight: 600;
  margin: var(--sc-5) 0 var(--sc-2); color: var(--sc-ink);
}
.legal__s p, .legal__s li {
  color: var(--sc-ink-soft); line-height: 1.65;
  font-size: var(--sc-t-sm); margin: 0 0 var(--sc-3);
}
.legal__s strong { color: var(--sc-ink); font-weight: 600; }
.legal__s a { color: var(--sc-accent); }
.legal__s ul { margin: 0 0 var(--sc-3); padding-left: 1.1rem; }
.legal__s li { margin-bottom: var(--sc-2); }

.legal__dl { display: grid; grid-template-columns: auto 1fr; gap: var(--sc-2) var(--sc-4); margin: 0 0 var(--sc-3); }
.legal__dl dt { color: var(--sc-ink); font-weight: 600; font-size: var(--sc-t-sm); }
.legal__dl dd { margin: 0; color: var(--sc-ink-soft); font-size: var(--sc-t-sm); }

/* The asides that explain WHY a rule is the way it is. Quieter, because they
   are not the rule. */
.legal__note {
  border-left: 2px solid var(--sc-hairline-strong);
  padding-left: var(--sc-4);
  font-size: var(--sc-t-xs) !important;
}

/* Reachable from the bag, on every page, because that is where the money is. */
.drawer__legal {
  margin: var(--sc-5) 0 0;
  font-size: var(--sc-t-xs);
  color: var(--sc-ink-soft);
}
.drawer__legal a { color: var(--sc-ink-soft); text-decoration: none; }
.drawer__legal a:hover { color: var(--sc-accent); text-decoration: underline; }
.pay__terms { margin-top: var(--sc-2); }
.pay__terms a { color: var(--sc-ink-soft); text-decoration: underline; text-underline-offset: 2px; }
.pay__terms a:hover { color: var(--sc-accent); }



/* ══════════════════════════════════════════════ the real 3D object ════════
   A WebGL canvas laid over the CSS plate. The flat one is built first and
   stays underneath as the fallback — a phone with no WebGL, a blocked module
   or a bad .glb still gets exactly the page it always got — so this only ever
   hides it once there is something better to show. */
.obj3d-gl {
  position: absolute; inset: 0;
  z-index: 4;
  cursor: grab;
  touch-action: pan-y;          /* a vertical drag still scrolls the page */
}
.obj3d-gl:active { cursor: grabbing; }
/* Explicitly transparent, explicitly dark behind it. A canvas is transparent
   by default and a container with no background shows whatever is under it,
   which is right — but "by default" is exactly what a driver gets to decide
   when it reclaims a context, and Andre's answer to that was that he would
   rather see nothing than see white. So both are stated rather than assumed. */
.obj3d-gl { background: transparent; }
.obj3d-gl canvas { width: 100%; height: 100%; display: block; background: transparent; }
/* Cross-fade, not a cut. The canvas starts invisible and is revealed by
   `has-gl` — which viewer.js sets from inside the render loop, after the first
   frame — so the fade always starts against a finished picture. */
.obj3d-gl { opacity: 0; transition: opacity 240ms var(--sc-ease-out); }
.has-gl .obj3d-gl { opacity: 1; }

/* WAITING FOR THE REAL OBJECT.
   RS.want3D sets this at build time on any product that has a .glb, when the
   browser can run WebGL — so the flat stand-in is never painted at all on the
   machines that are about to replace it. No transition: this is the state the
   element is born in, not something it animates into. A browser without WebGL
   never gets the class and keeps the stand-in it has always had. */
.is-awaiting-gl .plate3d__body,
.is-awaiting-gl .obj3d__body,
.is-awaiting-gl .pdp__shot,
.is-awaiting-gl .ring__shot,
.is-awaiting-gl .plate3d__shadow,
.is-awaiting-gl .plate3d__hint { opacity: 0; }

/* Once the real object is up, the stand-in goes. Not removed — a resize or a
   context loss can hand the page back to it. */
.has-gl .plate3d__body,
.has-gl .obj3d__body,
.has-gl .pdp__shot,
.has-gl .ring__shot { opacity: 0; transition: opacity 240ms var(--sc-ease-out); }
.has-gl .plate3d__hint { opacity: 0; }

/* ─── the metal row ────────────────────────────────────────────────────────
   Deliberately almost no CSS. It is built from `pick`, `pick__row` and
   `pick__b` — the size picker's own markup — so the chips are the same height,
   padding and radius as every other chip by construction rather than by two
   numbers that happen to agree. The first version had its own pill container
   and rendered as a bar half the screen wide above a row of neat little size
   buttons.

   All that is left is the swatch, which is the one thing a size chip has no
   use for: a dot of the actual metal, so the finish reads before the word. */
.pick__b[data-tone]::before {
  content: ''; display: inline-block; vertical-align: -1px;
  width: 9px; height: 9px; margin-right: 0.4rem; border-radius: 50%;
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.4);
}
.pick__b[data-tone="silver"]::before {
  background: linear-gradient(145deg, #ffffff, #b9bec7 60%, #8d939d);
}
.pick__b[data-tone="gold"]::before {
  background: linear-gradient(145deg, #fff3d4, #e8c072 55%, #b8893a);
}
/* TWO ROWS OF CONTROLS, NOT THREE.

   Metal had a full-width line of its own above the sizes, which with the
   buttons underneath made three stacked rows — and on the bracelet, which has
   four lengths, that pushed the caption off the bottom of the screen.

   Metal is only ever two short chips, so it rides WITH the buttons instead:
   sizes claim their own line, then Metal, Open, the stepper and Add share the
   next. Same two chips, same height as everything beside them, one row less
   of it. */
.ring-detail__act .pick--metal {
  order: 0; flex: 0 0 auto; margin: 0;
}
.ring-detail__act .pick--metal .pick__l { display: none; }
.ring-detail__act .pick--metal .pick__row { gap: 0.3rem; }
.pdp__panel .pick--metal { margin: 0 0 var(--sc-3); }

/* ─── a photograph on the stage ────────────────────────────────────────────
   The supplier's pictures used to be links that opened the raw file in a new
   tab. Every shop anybody has used puts the photograph where the object was
   and leaves you on the page, so that is what these do now. */
.stage-shot {
  position: absolute; inset: 0; z-index: 6;
  display: flex; align-items: center; justify-content: center;
  background: var(--sc-bg);
  border-radius: inherit;
  animation: stageShot 220ms var(--sc-ease-out);
}
/* [hidden] is display:none from the UA stylesheet, and ANY author display
   beats it — the same trap .pay__wait documents at line 2308. Without this the
   panel above is permanently on the stage: an empty <img> over the object and
   a "Back to 3D" button offering to dismiss a photograph nobody opened. */
.stage-shot[hidden] { display: none; }
.stage-shot img,
.stage-shot video { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
/* And the SAME trap again, one level down. The rule directly above sets
   display:block on both children, which beats [hidden]'s display:none exactly
   as it does for the parent - so the <video> was laid out even while hidden.
   .stage-shot is a flexbox, so an empty 300x150 video player sat BESIDE the
   photograph and shoved it out of its own frame: on a 1440 desktop the picture
   started 150px to the left of its container, and on a phone it began at
   x=-85, half off the screen. The player was visible over the product title,
   controls and all. Hiding a child needs its own rule every time. */
.stage-shot img[hidden],
.stage-shot video[hidden] { display: none; }
/* The video cell reads as a video: the frame fills it like the photographs do,
   with a play mark over it so it is not mistaken for another still. */
.gal__cell--vid { position: relative; }
.gal__cell--vid video { width: 100%; height: 100%; object-fit: cover; display: block; }
.gal__play {
  position: absolute; inset: 50% auto auto 50%; translate: -50% -50%;
  width: 1.6rem; height: 1.6rem; border-radius: 50%;
  background: rgb(0 0 0 / 0.55); box-shadow: 0 0 0 1px rgb(255 255 255 / 0.5);
}
.gal__play::after {
  content: ''; position: absolute; inset: 50% auto auto 54%; translate: -50% -50%;
  border-style: solid; border-width: 0.32rem 0 0.32rem 0.52rem;
  border-color: transparent transparent transparent #fff;
}
.stage-shot__x {
  /* Above the photograph, always. It had no stacking context of its own, so on
     a tall image it ended up behind the picture it was meant to dismiss —
     which left the only way back to the object being the Escape key. */
  position: absolute; z-index: 2; top: 0.7rem; right: 0.7rem;
  padding: 0.4rem 0.8rem; border-radius: var(--sc-r-pill);
  border: 1px solid var(--sc-hairline-strong);
  background: var(--sc-bg); color: var(--sc-ink);
  font: inherit; font-size: var(--sc-t-xs); cursor: pointer;
}
.stage-shot__x:hover { border-color: var(--sc-ink-soft); }
/* The object underneath stops turning while a photograph is over it — a
   WebGL loop rendering behind an opaque panel is a flat battery for nothing. */
.pdp__stage.is-showing-shot .obj3d-gl { visibility: hidden; }
.gal__cell { border: 0; padding: 0; background: none; cursor: pointer; }
@keyframes stageShot { from { opacity: 0 } to { opacity: 1 } }
@media (prefers-reduced-motion: reduce) { .stage-shot { animation: none; } }

/* The pre-break unit price beside the stepped one, struck through. Only drawn
   when the bag has actually earned the lower price. */
.line__break{ text-decoration:line-through; opacity:.5; margin-left:4px; }
/* A per-unit figure in the batch table that is below the headline price. */
.pdp__batch-t .is-stepped{ font-weight:600; }

/* GOES WITH THIS. The bottom of the left column, under the object. Quiet on
   purpose: a picture, a name, a price, one action. No badge, no counts. */
.together { width: 100%; margin: var(--sc-4, 1rem) 0 0; }
.together__h { margin: 0 0 var(--sc-4, 1rem); }
.together__row { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sc-4, 1rem); grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 901px) { .together__row { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.together__card { display: grid; gap: var(--sc-3, .75rem); align-content: start; }
.together__a { display: grid; gap: .35rem; color: inherit; text-decoration: none; }
.together__img { width: 100%; height: auto; aspect-ratio: 1; object-fit: cover; border-radius: 4px; background: #101216; }
.together__n { font-size: var(--sc-t-sm, .9rem); color: var(--sc-ink, #fff); }
.together__p { font-size: .85rem; color: var(--sc-dim, #8b8f9a); }
.together__pick { font-size: .85rem; color: var(--sc-ink-2, #c9ccd4); text-decoration: underline; text-underline-offset: 3px; }
/* On a phone .pdp__object is display:contents, so the block takes part in the
   page's ordered grid: last, spanning both columns. */
@media (max-width: 900px) { .pdp > .together, .pdp__object > .together { order: 14; grid-column: 1 / -1; } }
.together__plate { position: relative; display: block; width: 100%; aspect-ratio: 1; border-radius: 4px;
  background: radial-gradient(90% 80% at 30% 18%, hsl(var(--hue) 62% 46% / 0.30), transparent 62%), #0c0e12; overflow: hidden; }
.together__plate .plate3d { cursor: default; inset: 4%; }
.together__plate .tester-cap { display: none !important; }
.together__plate .together__img { position: absolute; inset: 6%; width: 88%; height: 88%; aspect-ratio: auto; object-fit: contain; background: none; }
.together__plate:has(canvas) .together__img { display: none; }
