/* ============================================================
   Pure Amino — Global Styles
   Light theme — white background, near-black accent.
   Primary buttons use a refined near-black ink; the only colour
   accents are the per-product washes in the catalog and the green
   "batch-tested" badges.
   ============================================================ */

/* ============================================================
   BREAKPOINTS — the five tiers, and nothing else
   ------------------------------------------------------------
   Every media query in this file uses one of these widths. If you are
   adding a rule and none of them is quite right, widen the nearest tier
   rather than introducing a sixth — the point of the set is that a
   reader can hold it in their head.

     max-width:  420px   xs   small phones (iPhone SE and narrower)
     max-width:  640px   sm   phones
     max-width:  900px   md   large phones, tablets in portrait
     max-width: 1024px   lg   tablets in landscape, small laptops
     max-width: 1585px   xl   below this the catalog grid drops a column
     min-width: 1586px   ——   the pair for the line above; the two are
                              complementary and must always move together
                              or a band of widths falls through both

   ── Why literals and not variables ──
   Custom properties do not work in media query conditions —
   `@media (max-width: var(--bp-sm))` is invalid CSS and is silently
   ignored, which fails by quietly applying the rule at every width. The
   numbers therefore have to be repeated, and this block is the single
   place they are explained.

   ── How this set was arrived at ──
   There were eighteen distinct values, each added where one specific
   component stopped fitting. Consolidating them, every value was rounded
   UP to its tier, never down: a rule that starts applying on a slightly
   wider screen is harmless, whereas one that stops applying leaves the
   element in a desktop layout at a width where it had previously
   reflowed — which is a broken page rather than a slightly early one.
   ============================================================ */


:root {
  --bg-primary:    #ffffff;
  --bg-secondary:  #f4f4f7;
  --bg-card:       #f7f7fa;
  --bg-card-hover: #eeeef3;
  --border:        #e4e4ea;
  --border-accent: rgba(0,0,0,0.18);

  --accent-dim:    #b8b8be;   /* muted grey for thin lines / scrollbar */
  --accent-bright: #0a0a0a;   /* accent reads near-black on white */
  --accent-light:  #0a0a0a;

  /* Primary interactive ink — used for the catalog / contact CTA and the
     add-to-cart buttons. A rich near-black that reads premium and keeps the
     palette monochrome (replaces the former blue). */
  --cta:           #111114;
  --cta-bright:    #2b2b31;

  /* Success accent for "batch-tested" badges. */
  --ok:            #15803d;
  --ok-bg:         rgba(34,197,94,0.12);
  --ok-border:     rgba(34,197,94,0.28);

  /* Keyboard focus ring. */
  --focus-ring:    #0a0a0a;

  --text-primary:  #0a0a0a;
  --text-secondary:#52525b;
  --text-muted:    #8a8a93;
  --text-tertiary: #9a9da3;   /* eyebrow / overline labels */

  --font-sans: 'Helvetica', 'Helvetica Neue', Arial, sans-serif;
  --font-mono: 'JetBrains Mono', 'Fira Code', monospace;

  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 16px;

  /* Elevation — a layered, low-contrast shadow system. Kept subtle so
     the monochrome palette stays calm; larger steps add a tighter
     contact shadow plus a soft ambient one. */
  --shadow-xs: 0 1px 2px rgba(16,17,20,0.05);
  --shadow-sm: 0 1px 2px rgba(16,17,20,0.04), 0 2px 6px rgba(16,17,20,0.05);
  --shadow-md: 0 2px 4px rgba(16,17,20,0.04), 0 8px 20px rgba(16,17,20,0.07);
  --shadow-lg: 0 4px 8px rgba(16,17,20,0.05), 0 18px 44px rgba(16,17,20,0.11);

  /* Hairline border tuned a touch cooler/softer than the old grey. */
  --border-soft:   #ececf0;

  --transition: 0.2s ease;
  --nav-transition: 0.067s ease;

  /* Height of the black standing-offer strip above the nav (js/partials.js
     mounts it on every page). Everything that has to clear the top of the
     window — the nav's own `top`, .page's padding, the hero's — adds this
     rather than a second hard-coded number, so the strip's height is
     changed in exactly one place. */
  --promo-h: 40px;

  /* The one fluid value every mega-heading and its paired disclaimer
     derive their font-size from, so resizing the window moves them in
     lockstep — which is what replaced a resize listener that used to
     measure one line and rewrite the other's font-size to match.

     On :root rather than on .research-banner--capped, where it started:
     .op-heading consumes it too and sits outside that wrapper, and a
     variable read from three places should not be scoped to one of them.
     Floor is 15px so the disclaimer, which is a fraction of this, stays
     readable at the small end. */
  --op-heading-unit: clamp(15px, 1.35vw, 20px);

  /* Catalog grid spacing — matches the horizontal padding Enhanced uses
     around the product image inside each card (≈24px). Gap and
     window-edge spacing share this one value. */
  --grid-gap:  12px;
  --grid-edge: 12px;

  /* Total border-box width of the 4-card catalog row (4 cards + 3 gaps
     + edge padding). Shared by .products-grid and .catalog-toolbar so
     the toolbar's left edge always lines up with the first card's. */
  --catalog-grid-w: calc(4 * ((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3) + 3 * var(--grid-gap) + 2 * var(--grid-edge));

  /* The measure the nav's contents span: logo pinned to its left end, cart
     to its right. Same line the home page's order-process copy sits on —
     .op is that measure plus its own --grid-edge padding — so the nav
     agrees with that section without measuring it. Deriving it here as a
     constant is the whole point: it's a plain number every page can use,
     where the old runtime measurement only ever worked on the one page
     that had the section to measure. */
  --nav-measure: calc(var(--catalog-grid-w) * 0.9 * 0.9 - 2 * var(--grid-edge));

  /* The nav pill's own end-padding and border, hoisted out of .nav so
     --nav-total-w below (and anything else that wants to size itself off
     the actual rendered nav) can share the identical clamp rather than
     duplicating it and risking the two drifting apart. See .nav in the
     NAVIGATION section for what each one does. */
  --nav-gutter: 16px;
  --nav-border-w: 1px;
  --nav-pad-x: clamp(
    28px,
    calc((100vw - var(--nav-measure) - 2 * var(--nav-gutter) - 2 * var(--nav-border-w)) / 2),
    56px
  );

  /* The nav bar's actual rendered width — same formula as .nav's own
     max-width. Anything that needs to size itself relative to the visible
     bar (rather than to --nav-measure, which is only its CONTENTS) reads
     this instead of re-deriving it. */
  --nav-total-w: calc(var(--nav-measure) + 2 * var(--nav-pad-x) + 2 * var(--nav-border-w));

}

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

/* Scrollbars hidden everywhere on the site — the page, the cart drawer,
   the account menu, any overflow:auto panel — without touching whether
   any of them actually scroll. scrollbar-width is Firefox's own
   property for this; -ms-overflow-style is old Edge/IE. Neither has a
   pseudo-element, so WebKit needs the separate rule below as well. */
* {
  scrollbar-width: none;
  -ms-overflow-style: none;
}
*::-webkit-scrollbar {
  display: none;
  width: 0;
  height: 0;
}

html { scroll-behavior: smooth; }

/* ── One axis, everywhere ────────────────────────────────────
   The page scrolls down and only down. Nothing here is meant to be read
   sideways, so a horizontal offset of any size is a fault — and a few
   stray pixels are worse than a lot, because they read as the layout
   wobbling rather than as something to scroll to.

   ── clip, not hidden ──
   `overflow-x: hidden` makes an element a scroll container. On <html>
   that would break every `position: sticky` descendant on the site — the
   cart summary, the account nav, the checkout rail — because sticky
   resolves against its nearest scroll container, and that container
   would suddenly be one that never scrolls. `clip` performs the same
   clipping WITHOUT establishing a scroll container, so sticky is
   untouched. It also pairs legally with `overflow-y: visible`, where
   `hidden` would force the Y axis to compute to `auto`.

   Set on <html> as well as <body>, deliberately. Body's overflow used to
   carry this alone, which works only through the propagation rule — when
   <html> is `visible`, the body's overflow is handed to the viewport and
   the body itself becomes visible. That is one indirection too many to
   rely on, and it is the rule iOS Safari has historically been loosest
   about. Stating it on both means the clip does not depend on which
   element the browser decides is in charge.

   ── overscroll-behavior-x ──
   Clipping stops the page from scrolling sideways; it does not stop the
   BROWSER from acting on a sideways gesture. A two-finger horizontal
   swipe still rubber-bands, and on most setups still triggers
   back/forward navigation — which is the other half of "it moves when I
   scroll down", and the more annoying half, since it can navigate away
   from a page mid-read. `none` refuses both. */
html,
body {
  overflow-x: clip;
  overscroll-behavior-x: none;
}

/* ── The rubber-band strip above/below the page ──
   Overscrolling past the top or bottom on a touch device exposes bare
   canvas, not any element on the page — and by default that canvas paints
   with body's own background (white), because body's background-color
   propagates up to the canvas whenever html doesn't specify one of its
   own. The page's actual top and bottom are both black (.promo-bar and
   .footer, both #0a0a0a), so a white flash there reads as a gap rather
   than as more of the page. Giving html its own black background stops
   the propagation — body keeps painting its normal background for its
   own box, and only the canvas beyond it, top and bottom, goes black. */
html {
  background: #0a0a0a;
}

/* Pre-2022 engines (Safari < 16) do not know `clip` and drop the
   declaration above, which would leave them scrolling sideways. They get
   what the site had before: `hidden` on the body alone, propagated to the
   viewport, with <html> left visible so sticky still resolves correctly. */
@supports not (overflow: clip) {
  body { overflow-x: hidden; }
}

/* ── The scrollers that ARE meant to go sideways ──
   A few things genuinely scroll on the X axis and should keep doing so:
   the order-process card strip on a phone, the wide policy tables, the
   admin stock table, the standing-offer strip. The page being locked
   does not stop them — a clipped ancestor still lets a descendant scroll
   container scroll.

   What it does mean is that reaching the END of one of them has nowhere
   to chain to, and the browser's own swipe-to-navigate takes over: swipe
   past the last card in the strip and you leave the page. `contain`
   keeps the gesture inside the element it started in, so the strip
   simply stops at its last card.

   Stated as one list rather than on each rule so the set is visible in
   one place; if another horizontal scroller is added, it belongs here. */
.op-panels,
.scroller,
.stock-card,
.research-banner,
.admin-tabs {
  overscroll-behavior-x: contain;
}

body {
  position: relative;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* ── Page-background square grid (disabled) ──────────────────────────────
   Removed: clean white background without grid overlay. */

a { color: inherit; text-decoration: none; }

/* Prevent selection and dragging of product images */
img {
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
  pointer-events: auto;
}

img { display: block; max-width: 100%; }

/* ── Accessibility ──────────────────────────────────────────────────────
   Visible keyboard focus, a skip-to-content link, a screen-reader-only
   utility, and a global reduced-motion guard. */
:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 3px;
  border-radius: 2px;
}

.skip-link {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translate(-50%, -160%);
  z-index: 1000;
  padding: 10px 20px;
  background: #111114;
  color: #fff;
  font-size: 14px;
  font-weight: 600;
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0,0,0,0.3);
  transition: transform 0.2s ease;
}
.skip-link:focus {
  transform: translate(-50%, 0);
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}


/* ============================================================
   NAVIGATION
   ============================================================ */
/* Floating rounded bar, centred, clear of the viewport edges and hanging a
   little below the top of the viewport (the discount banner that used
   measured height of that banner, kept current by js/nav.js so the gap
   stays even as the banner's own height changes, e.g. wrapping to two
   lines on narrow screens).

   Width is --nav-measure plus this bar's own horizontal padding, so it's
   the CONTENTS — logo at one end, cart at the other — that land on the
   measure, and the pill's rounded edge sits out beyond them by --nav-pad-x.
   Widening the bar is therefore a matter of growing that padding alone: the
   logo and cart don't move, so they stay on the line the home page already
   had them on, and every other page gets the identical bar from the same
   shared constant rather than from js measuring a section only the home
   page has. --nav-gutter takes over below that measure so the bar simply
   insets from the viewport on narrow screens. */
.nav {
  position: fixed;
  /* --nav-gutter / --nav-border-w / --nav-pad-x live on :root now (see
     above), so this bar and the discount popup can both size off the same
     values without drifting apart.

     max-width is recomputed HERE from those vars rather than reading the
     root-level --nav-total-w directly, on purpose: the mobile query further
     down overrides --nav-pad-x/--nav-gutter locally (narrower end-padding
     so the bar keeps fitting its own contents at hamburger widths), and
     that override only reaches a formula evaluated in this selector's own
     scope. Reading the root var here would ignore it and the bar would stop
     narrowing when it needs to most. --nav-total-w stays available as the
     root-level "what the bar looks like by default" for anything that
     isn't itself part of the responsive nav, e.g. the discount popup. */
  /* Flush to the top edge and the full width of the window — no gutter,
     no max-width. The inner row is what stays capped and centred (see
     .nav > * spacing below), so the bar spans the window while its
     CONTENTS still line up with the page's own measure. */
  /* Under the standing-offer strip, not at the window's own top edge. */
  top: var(--promo-h);
  left: 0;
  right: 0;
  width: 100%;
  max-width: none;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  --nav-row-gap: 8px;
  gap: var(--nav-row-gap);
  /* The BAR is full width; its CONTENTS stay on the page's own measure.
     max() centres the row on --nav-measure when there is room and falls
     back to the plain gutter when there isn't, so the logo and the icons
     line up with the content below them at every width instead of
     hugging the window edges. */
  padding-block: 16px;
  padding-inline: max(var(--nav-gutter), calc((100% - var(--nav-measure)) / 2));
  /* One constant bar. It used to be a floating rounded rectangle inset
     from the window edges, which grew a border and a shadow once
     js/nav.js flipped .is-over-content past a few pixels of scroll — so
     it looked like two different components depending on scroll
     position. It is now a plain window-wide bar with a single hairline
     along its bottom edge, identical at the top of the page and
     anywhere else; .is-over-content is still applied by the script but
     no longer changes anything (see the note on that rule below).

     Still translucent + blurred, so content passing underneath is
     softened rather than hidden. */
  border: none;
  border-bottom: 1px solid var(--border);
  border-radius: 0;
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(20px) saturate(1.8);
  -webkit-backdrop-filter: blur(20px) saturate(1.8);
}

/* ── Overlays and the frosted nav ─────────────────────────────
   The nav's backdrop-filter is what makes it a frosted pane over
   scrolling content, and it is worth keeping — but it also gives the nav
   its own compositing layer, and a compositing layer sitting UNDER a
   full-screen dim is the last remaining source of the pale-rectangle
   artifact the scrims above were changed to avoid.

   Behind an open overlay a frosted nav is invisible anyway: it is under
   60–78% of flat black. Turning the filter off while any overlay is open
   costs nothing visually and removes the layer entirely.

   Written with :has() rather than a body class so it holds for every
   overlay without four separate JS files having to remember to set one.
   The same set of selectors also stills hover transitions on the page
   behind — nothing back there is clickable while a scrim covers it, so a
   card lifting under the dim is motion with no meaning. */
/* The offer dock is deliberately NOT in this list. It stopped being a
   full-screen dimming overlay when it moved to the corner, and it is on
   the page permanently as the collapsed pill — matching it here would
   switch the nav's blur off and kill every transition on the site for as
   long as the pill exists, which is always. */
body:has(.cart-drawer-root.is-open) .nav,
body:has(.auth-overlay:not([hidden])) .nav,
body:has(.age-gate:not([hidden])) .nav {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  /* The filter was carrying the nav's opacity; without it the bar needs
     to be solid or the page shows straight through. */
  background: var(--bg-primary);
}

body:has(.cart-drawer-root.is-open) main *,
body:has(.auth-overlay:not([hidden])) main *,
body:has(.age-gate:not([hidden])) main * {
  transition: none !important;
}


/* ── Standing-offer strip ──────────────────────────────────────
   Black band pinned to the very top of the window, above the nav. Fixed
   rather than in-flow so it behaves the same way the nav does: the page
   scrolls under both, and one `top` chain (--promo-h) positions
   everything below it.

   z-index sits one above .nav's 100 — this is the window's top edge, and
   the nav's translucent blur must pass under it rather than over it. */
.promo-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 101;
  height: var(--promo-h);
  box-sizing: border-box;
  padding-inline: 12px;
  background: #0a0a0a;
  color: #ffffff;
  /* Clipped, never scrollable: the short forms below guarantee the row
     fits, so anything overflowing is a bug to fix rather than content to
     let the visitor hunt for sideways. */
  overflow: hidden;
}
.promo-track {
  display: flex;
  align-items: center;
  justify-content: center;
  /* The gap is the breathing room on ONE side of a separator, so the rule
     ends up with this much clear space either side of it. */
  gap: 18px;
  height: 100%;
}
.promo-item {
  white-space: nowrap;
  /* 12px + 20%. The strip grew from 34px to 40px to carry it without the
     text crowding the edges — every `top` on the site reads --promo-h, so
     the whole chain follows. */
  font-size: 14.4px;
  font-weight: 600;
  letter-spacing: 0.01em;
  line-height: 1;
  /* Slightly off pure white: the strong figures below are the full
     white, which is what makes them read as the emphasis. */
  color: rgba(255, 255, 255, 0.82);
}
.promo-item strong { color: #ffffff; font-weight: 800; }

/* A drawn rule, not a character. A middot at 38% opacity was doing the
   job of a separator without being visible enough to read as one — and a
   glyph carries its own side bearings, so the space around it could never
   be set independently of the gap. A 1px column sized in ems tracks the
   type, sits on the flex gap symmetrically, and is actually visible. */
.promo-sep {
  flex: 0 0 auto;
  /* 1.5px, not 1px. At exactly 1px the rule is one device pixel wide at
     100% zoom and DPR 1 — and whether it SURVIVES depends on where its
     left edge lands once the page is scaled, which is the case on any
     fractional browser zoom or fractional-DPI display. The three offers
     are different lengths, so the two separators sit at unrelated x
     positions: one would snap to a device-pixel boundary and paint, the
     other would straddle one and antialias to nothing. That is exactly
     what it looked like — the first rule visible, the second missing.

     Half a pixel more guarantees coverage of at least one device pixel
     wherever it lands, at any scale, while still reading as a hairline. */
  width: 1.5px;
  height: 1.05em;
  /* Grey, not full white — asked for outright. 34% was tried before and
     read as an artifact rather than punctuation, but that was at the old
     1px width; at this rule's own 1.5px, 55% still reads as a deliberate
     divider rather than a stray pixel, while sitting quieter than the
     text either side of it. */
  background: rgba(255, 255, 255, 0.55);
  /* Sets the em the height above is measured in — the element has no text
     of its own, so this is purely the unit source. */
  font-size: 14.4px;
}

/* The two phrasings. Only ever one of each pair is in the document's
   layout AND its accessibility tree, so nothing is read twice. */
.promo-short { display: none; }

/* Below this the three full sentences stop fitting on one line. The short
   forms carry the same three offers in about half the characters, which
   is what keeps the strip one line and one height at every width instead
   of growing a second row that every `top` below it would have to know
   about. */
@media (max-width: 1024px) {
  .promo-full { display: none; }
  .promo-short { display: inline; }
  .promo-track { gap: 14px; }
  .promo-item, .promo-sep { font-size: 13.2px; }
}
@media (max-width: 640px) {
  .promo-item, .promo-sep { font-size: 12px; letter-spacing: 0; }
  .promo-track { gap: 10px; }
}
/* The narrowest phones still in use (320px). Measured, not guessed: at
   the step above, the three short forms come to 318px of content in a
   296px box. Trimming the type, the gaps and the bar's own side padding
   is what brings it back inside — the alternative is a fourth, shorter
   set of phrasings, and there is not much left to cut from "10% off
   welcome". */
@media (max-width: 640px) {
  .promo-bar { padding-inline: 6px; }
  .promo-item, .promo-sep { font-size: 10.8px; }
  .promo-track { gap: 7px; }
}

/* Sticky rather than plain in-flow: it needs to stay on screen through the
   scroll the same way the nav does, sitting visually above it (z-index)
   since the nav is what floats at the top (see `top` above), not the
   other way around. */


/* The bar is frosted at all times now (see .nav above); the outline and
   shadow are what actually switch on here, past the first few pixels of
   scroll — see the comment on .nav's own (transparent/zero) resting values
   for why they're absent at the very top. */
/* Deliberately empty of visual change. js/nav.js still toggles this
   class on scroll, and other things may come to depend on knowing the
   page has moved — but the bar itself must look identical at the top of
   the page and anywhere else, which is the whole point of the flat bar
   above. Left as a hook rather than ripped out of the script. */
.nav.is-over-content { /* no visual difference, by design */ }

/* Vertically centred against the tab group by .nav's own align-items:
   center — the logo is the shortest item in the row, so it centres on the
   tab text rather than sitting on a shared baseline.

   It deliberately carries NO auto right margin. It used to, to shove
   everything else to the far right, but an auto margin swallows all the
   free space before flex-grow gets any — which would leave nothing for the
   tab group to centre itself in. The space is handed down the chain
   instead: .nav-end grows, .nav-right grows, .nav-links centres inside it. */
.nav-logo {
  display: flex;
  align-items: center;
  gap: 12px;
  /* Without this, a tight fit on the row (the tab group's own gap was
     just doubled) lets flex-shrink take space from the image specifically
     — text has an intrinsic min-content floor that protects it the same
     way, but a bare <img> doesn't, and can get crushed to 0 width. */
  flex-shrink: 0;
  transition: opacity var(--nav-transition);
}

/* 45px → 31px (20% down) → 27.9px (a further 10% down). */
.nav-logo-img {
  height: 27.9px;
  width: auto;
}

/* Same greying-out on hover as the tab headers and icons; the mark is a
   flat PNG, so it's dimmed rather than recoloured. */
.nav-logo:hover {
  opacity: 0.55;
}

/* The wordmark is wide for its height, so on small screens it has to come
   down further or it runs into the hamburger and cart beside it. The pill's
   inset tightens with it to buy back a little more room. */
@media (max-width: 1024px) {
  .nav {
    --nav-pad-x: 18px;
    --nav-gutter: 12px;
    padding-block: 14px;
    border-radius: 18px;
  }
  .nav-logo-img {
    height: 22px;
  }
}

/* Flat, not the 18px pill above — asked for outright at the phone tier.
   Later in the file than the 1024px rule so it wins at equal
   specificity; tablet (769–1024px) keeps the rounded bar. */
@media (max-width: 768px) {
  .nav { border-radius: 0; }
}


.nav-toggle {
  display: none;
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  position: relative;
  width: 26px;
  height: 18px;
}

.nav-toggle span {
  position: absolute;
  left: 0;
  width: 26px;
  height: 2px;
  background: var(--text-primary);
  transition: top 0.3s ease, opacity 0.3s ease, transform 0.3s ease;
}

.nav-toggle span:nth-child(1) { top: 0; }
.nav-toggle span:nth-child(2) { top: 8px; }
.nav-toggle span:nth-child(3) { top: 16px; }

/* Outer right-hand cluster: toggle + collapsible menu + the cart icon.
   Cart lives here (not inside .nav-right) so it stays visible even
   when the mobile menu is collapsed. */
.nav-end {
  display: flex;
  align-items: center;
  gap: 6px;
  /* Claims every pixel the logo leaves, then justify-content below pushes
     its own contents (hamburger + icons + cart) to the far edge of that —
     i.e. flush against the bar's right padding, same place they've always
     sat. Needs to grow rather than just sit at its natural width because
     .nav-links no longer contributes anything to this row's flow (it's
     positioned against .nav directly now, to centre on the window instead
     of on this cluster — see .nav-links), so without this .nav-end would
     shrink to fit just its own contents and drift in from the right edge
     rather than staying pinned to it. Still never shrinks below its
     contents. */
  flex-grow: 1;
  flex-shrink: 0;
  justify-content: flex-end;
}

/* Right cluster: icon cluster only now — .nav-links no longer lives in this
   box's flow (see below), so this doesn't need to grow to make room for it
   any more. Shrink-to-fit lets .nav-end's own flex-grow + justify-content:
   flex-end (see .nav-end above) push it flush against the cart instead. */
.nav-right {
  display: flex;
  align-items: center;
  --nav-cluster-gap: 40px;
  gap: var(--nav-cluster-gap);
}

/* Centred on the WINDOW, not on the run between the logo and the icon
   cluster — those two are different things whenever the logo and the
   icon+cart cluster aren't the same width (they aren't: the cluster is
   wider), which used to pull the middle of that run off the window's true
   centre by half the difference. Positioning against .nav itself sidesteps
   that: .nav is centred in the viewport (equal --nav-gutter both sides), so
   centring inside ITS box is centring on the window, independent of
   whatever the logo and cluster each happen to measure.

   Taken out of flow to do it — .nav is already position:fixed, so it's the
   containing block for this without needing its own position:relative. Only
   works because .nav-end/.nav-right no longer need the tab row's width to
   size themselves against (see both above); until that changed, the run
   they defined WAS how the logo/cart stayed put in the old scheme. */
.nav-links {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  align-items: center;
  gap: 37.4px;
  list-style: none;
  white-space: nowrap;
}


.nav-icons {
  display: flex;
  align-items: center;
  gap: 6px;
}

/* Framed the same as .nav-account-pill (border + background), asked
   for outright — this is the cart button always, and the account
   button too whenever nobody's signed in (js/partials.js gives both
   this class; the pill only replaces it once a session resolves). No
   name text ever sits beside either icon, so the same frame reads as
   a circle here rather than the pill's stadium shape. */
.nav-icon {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 42px;
  height: 42px;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: var(--bg-primary);
  color: var(--text-secondary);
  transition: color var(--nav-transition), border-color var(--nav-transition);
}
.nav-icon svg { width: 21px; height: 21px; }
/* The cart glyph's own ink fills its 24-unit-tall viewBox almost edge to
   edge (basket top to wheel bottom, y 1 to 22 — 21 of the 24 units),
   where the account glyph's (head to shoulders, y 4 to 21 — 17 of 24)
   leaves real margin above and below. Same 21px box, so the cart read
   taller than the account icon beside it despite sharing one rule.
   Scaled down to match the account glyph's actual ink height instead of
   its box (21px × 17/21 ≈ 17px) — .nav-icon's own flex centring keeps
   it in the middle of the circle with no extra positioning needed. */
.nav-cart svg { width: 17px; height: 17px; }
/* Same greying-out as the tab headers and the logo, so the whole bar
   responds to the cursor the one way. */
.nav-icon { color: var(--text-primary); }
/* Border only, matching .nav-account-pill:hover exactly — asked for
   outright: the glyph itself shouldn't lighten on hover, just the
   frame around it. */
.nav-icon:hover { border-color: var(--text-muted); }

/* Pushed out to sit ON the frame's rim rather than tucked inside the
   glyph's own corner — asked for outright, once the circle above
   became a visible frame instead of bare icon: a badge sitting inside
   an empty corner read as floating free of it, since a circle
   inscribed in a square never actually reaches that square's corner. */
.nav-cart-badge {
  position: absolute;
  top: -3px;
  right: -3px;
  min-width: 17px;
  height: 17px;
  padding: 0 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--cta);
  color: #fff;
  font-size: 10px;
  font-weight: 700;
  line-height: 1;
  border-radius: 9px;
}
.nav-cart-badge.is-empty { display: none; }

/* ── The profile's address form ──
   The same shape checkout uses: street and apartment full width, then
   suburb / state / postcode sharing a row. Kept in one grid so the three
   short fields line up rather than each finding its own width. */
.addr-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 14px;
}
.addr-grid .field.full { grid-column: 1 / -1; }
.addr-grid .field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.addr-grid .field > span { font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
.addr-grid .field em { font-style: normal; font-weight: 400; color: var(--text-muted); }

@media (max-width: 640px) {
  .addr-grid { grid-template-columns: minmax(0, 1fr); }
}

/* ── The signed-in account pill ───────────────────────────────
   Signed out this wrapper holds the bare 42px person icon and needs no
   styling of its own. Signed in, js/nav.js puts a named pill in it and
   hangs the account menu beneath — position:relative is what the menu
   anchors to, so it belongs on the wrapper rather than on either. */
.nav-account { position: relative; display: inline-flex; }
/* Masks the icon for the brief window before js/nav.js knows whether the
   visitor is signed in — see the is-pending comment in js/partials.js.
   visibility, not display: the icon's own space stays reserved, so the
   cart icon beside it doesn't shift right and back when the class lifts. */
.nav-account.is-pending { visibility: hidden; }

.nav-account-pill {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  /* Same 42px height as the icon it replaces, so the nav row's baseline
     does not shift the moment a session resolves. */
  height: 42px;
  padding: 0 12px 0 9px;
  border: 1px solid var(--border);
  border-radius: 21px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  transition: border-color var(--nav-transition), background var(--nav-transition);
}
.nav-account-pill:hover { border-color: var(--text-muted); }
.nav-account-pill:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }

.nav-account-pill-icon { display: inline-flex; color: var(--text-primary); }
.nav-account-pill-icon svg { width: 19px; height: 19px; display: block; }

/* The name is the one part that can be any length, so it is the one part
   allowed to truncate — the icon and chevron keep their size. */
.nav-account-pill-name {
  max-width: 11ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.nav-account-pill-chev {
  width: 13px;
  height: 13px;
  color: var(--text-muted);
  transition: transform var(--nav-transition);
}
.nav-account.is-open .nav-account-pill-chev { transform: rotate(180deg); }

/* ── The menu ── */
.nav-account-menu {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  z-index: 120;
  width: 244px;
  padding: 6px;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: 0 1px 2px rgba(10, 10, 12, 0.05), 0 18px 44px rgba(10, 10, 12, 0.14);
}
.nav-account-menu[hidden] { display: none; }

/* A link now, not a caption — so it takes the same hover wash the rows
   below it do, or it would be the one tappable thing in the menu that
   gave no sign of being tappable. */
.nav-account-menu-head {
  display: flex;
  flex-direction: column;
  /* Two thirds of the 2px this was, asked for outright: the name and the
     "Tap to view profile" line under it are one label in two registers,
     not two separate rows, and 2px was reading as a gap between items.
     Kept as an expression rather than the 1.33px it resolves to so the
     ratio survives the base value ever changing. */
  gap: calc(2px * 2 / 3);
  padding: 9px 10px 11px;
  border-bottom: 1px solid var(--border);
  border-radius: 8px 8px 0 0;
  text-decoration: none;
  transition: background var(--nav-transition);
}
.nav-account-menu-head:hover { background: var(--bg-secondary, rgba(10, 10, 12, 0.05)); }
.nav-account-menu-head:focus-visible { outline: 2px solid var(--text-primary); outline-offset: -2px; }
.nav-account-menu-name {
  font-size: 14px;
  font-weight: 700;
  color: var(--text-primary);
}
/* An email is long and is nobody's idea of a heading, so it sits under
   the name at a smaller size and truncates rather than wrapping the menu
   into a second shape. */
.nav-account-menu-email {
  font-size: 12px;
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Groups, separated by a rule rather than by extra space alone — the
   destructive item at the bottom needs a visible boundary above it, and
   giving every group the same one keeps that from looking like a
   one-off. */
.nav-account-menu-group {
  display: flex;
  flex-direction: column;
  padding: 5px 0;
  border-bottom: 1px solid var(--border);
}
.nav-account-menu-group:last-child { border-bottom: none; padding-bottom: 1px; }

.nav-account-menu-group a,
.nav-account-menu-group button {
  display: block;
  width: 100%;
  padding: 8px 10px;
  border: none;
  border-radius: 8px;
  background: none;
  color: var(--text-primary);
  font-family: inherit;
  font-size: 13.5px;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
  transition: background var(--nav-transition), color var(--nav-transition);
}
.nav-account-menu-group a:hover,
.nav-account-menu-group button:hover { background: var(--bg-secondary, rgba(10, 10, 12, 0.05)); }
.nav-account-menu-group a:focus-visible,
.nav-account-menu-group button:focus-visible {
  outline: 2px solid var(--text-primary);
  outline-offset: -2px;
}

/* Store credit is a balance, not a destination, so it reads as a figure
   with a label rather than as another menu row. */
.nav-account-credit {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin: 6px 0;
  padding: 9px 10px;
  border-radius: 8px;
  background: var(--bg-secondary, rgba(10, 10, 12, 0.05));
  font-size: 12.5px;
  color: var(--text-secondary);
}
.nav-account-credit strong {
  font-size: 14px;
  font-weight: 700;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

/* Store credit and Admin panel, a hairline apart — asked for outright,
   after sitting fully flush read as one continuous block: hovering
   Admin panel painted its highlight right up against the credit box's
   own background with nothing between them. 4px keeps them reading as
   the same list Your orders/Your reviews do, while leaving just enough
   white space that the hover highlight is visibly its own shape.
   The credit box's own margin:6px 0 and the admin group's padding:5px
   0 were adding up on the one edge that faces each other; this rule
   pair overrides only that one edge to the 4px total instead — both
   still keep their normal spacing on every OTHER edge (above the
   credit box, below the admin group before Your orders). */
.nav-account-credit:has(+ .nav-account-menu-group) { margin-bottom: 0; }
.nav-account-credit + .nav-account-menu-group { padding-top: 4px; }

/* The admin row no longer carries an icon — it reads as the plain word
   every other row in this menu is, so it needs no rules of its own beyond
   the group's. (The .nav-account-admin class is kept on the markup as a
   hook; the flex box and svg sizing it used to need went with the mark.)

   Sign out is the one item that ends the session, so it is the one item
   coloured for it. */
.nav-account-signout { color: var(--danger, #c8322b) !important; font-weight: 600 !important; }
.nav-account-signout:hover { background: rgba(200, 50, 43, 0.09) !important; }

/* The nav collapses into a stacked drawer here, where an absolutely
   positioned 244px menu anchored to the right of a pill would hang off
   the screen. In the drawer it becomes a plain block instead. */
@media (max-width: 1024px) {
  .nav-account-pill-name { max-width: 8ch; }
  .nav-account-menu { right: auto; left: 0; width: min(244px, calc(100vw - 32px)); }
}

/* Persistent floating cart bar — bottom-center, all pages except
   cart/checkout themselves. Corner radius matches the product grid
   cards (--radius-lg). */
.cart-float-bar {
  position: fixed;
  left: 50%;
  bottom: 24px;
  transform: translateX(-50%);
  z-index: 90;
  display: flex;
  align-items: center;
  padding: 14px 22px;
  background: var(--text-primary);
  color: #fff;
  /* A true stadium, not just "rounder than --radius-lg" — asked for
     outright. 20px still left the top and bottom edges visibly flatter
     than the ends on a bar this tall, reading as a rounded rectangle
     rather than a pill. A radius past half the bar's own height always
     wins (the browser clamps it), so a number this far past the bar's
     ~50px height guarantees a full semicircle at both ends regardless of
     how the content (and so the bar's height) changes. */
  border-radius: 999px;
  /* Three layers, not one. The drop shadow is the original — it is what
     lifts the bar off a LIGHT page, and does nothing visible once the
     ground behind it is already dark. The other two are a soft white
     rim added for that case: a bar the same near-black as var(--text-
     primary) floating over a dark section (the footer, a dark product
     shot) has no edge at all without one, and effectively disappears
     into whatever is behind it. Both are white at low opacity, so
     against this site's usual white page they sit inside the page's own
     background and add nothing visible — the glow only reads once
     there is something dark for it to show up against, which is exactly
     the one case it needs to. A hairline ring (0 spread radius) plus a
     soft outer bloom (20px blur) reads as a glow rather than a second
     hard outline. */
  box-shadow:
    0 12px 32px rgba(0, 0, 0, 0.28),
    0 0 0 1px rgba(255, 255, 255, 0.1),
    0 0 20px rgba(255, 255, 255, 0.16);
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  /* Pinned, not inherited — .promo-pill matches this exactly so the two
     bottom-corner chips stand the same height. */
  line-height: 1.6;
  white-space: nowrap;
  transition: transform var(--transition), box-shadow var(--transition);
}
/* The width transition is only armed DURING a face swap. Left on
   permanently it would also animate the bar's ordinary repaints — the
   subtotal ticking up as items are added — which is a different event
   and should just be the new number, not a slide. */
.cart-float-bar.is-swapping {
  transition: transform var(--transition), box-shadow var(--transition), width 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.cart-float-bar:hover {
  transform: translateX(-50%) translateY(-2px);
  /* Same white rim as the resting state (see the base rule's comment) —
     restated rather than inherited because this replaces the whole
     box-shadow list, not just the drop shadow's own numbers. */
  box-shadow:
    0 16px 36px rgba(0, 0, 0, 0.32),
    0 0 0 1px rgba(255, 255, 255, 0.1),
    0 0 20px rgba(255, 255, 255, 0.16);
}
.cart-float-bar[hidden] { display: none; }

/* ── Appearing and going, the same way in reverse ──
   Asked for outright: the bar rises up from the bottom edge into place,
   and leaves by sinking back down through it — one motion, played
   forward to arrive and backward to go, rather than an animated
   entrance paired with an instant, unanimated disappearance.

   56px (up from a token 14px) is enough travel to read as "coming from
   the edge it lives against" rather than a small settle-in; scale still
   rides along so the bar looks like it's approaching, not just
   sliding. Quick on both ends — 0.26s in, 0.2s out — because this is a
   confirmation, not a scene: a human reads "arrived" or "gone" almost
   immediately, and a slower motion here would just be time spent
   waiting on the bar to catch up to a decision already made. Exit is
   the shorter of the two on purpose — leaving reads as quicker than
   arriving does everywhere else in the physical world, a door included,
   and matching that is what keeps this feeling natural rather than
   mechanical. */
@keyframes cartFloatIn {
  from { opacity: 0; transform: translateX(-50%) translateY(56px) scale(0.94); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
@keyframes cartFloatOut {
  from { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
  to   { opacity: 0; transform: translateX(-50%) translateY(56px) scale(0.94); }
}
.cart-float-bar.is-arriving {
  animation: cartFloatIn 0.26s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.cart-float-bar.is-leaving {
  animation: cartFloatOut 0.2s cubic-bezier(0.4, 0, 1, 1) both;
}

/* The two things the bar can be: its normal cart summary, and the "added"
   confirmation it briefly becomes. One is always in flow and sets the
   bar's width; during a swap the outgoing one goes absolute so both are
   painted at once while the bar resizes underneath them. */
.cart-float-face {
  display: flex;
  align-items: center;
  /* Matches the promo strip's spacing logic: the gap is the clear space
     on one side of the rule, so it reads the same either side. */
  gap: 14px;
  transition: opacity 0.26s ease;
}
.cart-float-face[hidden] { display: none; }
.cart-float-face.is-out {
  position: absolute;
  left: 22px;
  top: 50%;
  transform: translateY(-50%);
  opacity: 0;
  pointer-events: none;
}
.cart-float-face.is-in { opacity: 0; }

/* A crease, not a ruled line — asked for outright ("folds in a piece of
   paper... rather than hard divisions"). Two changes from the offer
   strip's own separator, which this used to copy exactly:

   1. `align-self: stretch` in place of the fixed 1.05em height. The face
      it sits in (.cart-float-face) is itself a flex row, so stretching
      the separator runs it the full cross-size of that row — edge to
      edge of the bar's own content, top to bottom of the pill, rather
      than pinned to one line's own text height.

   2. A vertical gradient in place of a flat fill. Full opacity in the
      middle, fading to nothing at both ends, is what a crease actually
      looks like — it catches the light where the fold is deepest and
      disappears into the flat paper at the edges. A hard-edged line at
      any opacity still reads as a rule someone drew; this one has no
      edge to read. */
.cart-float-sep {
  flex: 0 0 auto;
  align-self: stretch;
  width: 1px;
  background: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.4) 50%,
    rgba(255, 255, 255, 0) 100%
  );
  /* The element still carries a middot in the markup for anything that
     ignores CSS; the text is hidden here rather than removed so the
     spacing does not depend on the glyph. */
  color: transparent;
  overflow: hidden;
}
/* The same colour as the count and the call to action beside it. At 85%
   it read as a dimmed, secondary figure — but it is the one number on the
   bar anybody is actually reading. */
.cart-float-total { color: inherit; }
.cart-float-cta { font-weight: 700; }
/* (A green tick used to sit before the "added" text here. Removed — the
   sentence already says what happened, and the mark was the only piece of
   non-text chrome in a bar that is otherwise all words.) */

@media (max-width: 640px) {
  .cart-float-bar { left: 16px; right: 16px; bottom: 16px; transform: none; justify-content: center; }
  .cart-float-bar:hover { transform: translateY(-2px); }
  /* Full-bleed at this width, so the bar's width is fixed by the viewport
     and there is nothing to animate — the faces just cross-fade in place,
     centred like everything else in the bar. */
  .cart-float-bar.is-swapping { transition: transform var(--transition), box-shadow var(--transition); }
  .cart-float-face.is-out { left: 0; right: 0; justify-content: center; }
  /* The bar is full-bleed here and carries no centring translate, so the
     entrance and exit cannot keep the -50% the desktop keyframes
     re-state — it would shove the bar half a screen left for the length
     of the animation. */
  @keyframes cartFloatIn {
    from { opacity: 0; transform: translateY(56px) scale(0.94); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
  }
  @keyframes cartFloatOut {
    from { opacity: 1; transform: translateY(0) scale(1); }
    to   { opacity: 0; transform: translateY(56px) scale(0.94); }
  }
}

@media (prefers-reduced-motion: reduce) {
  .cart-float-bar.is-swapping { transition: none; }
  .cart-float-face { transition: none; }
  /* Still appears and disappears, just without the travel. */
  .cart-float-bar.is-arriving,
  .cart-float-bar.is-leaving { animation: none; }
}

/* Add-to-home-screen install prompt — sits below the floating cart bar
   when both are visible. (Used to sit above a cookie consent banner too;
   that banner is removed for now — see the comment in js/nav.js.) */
.install-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 94;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 12px 32px;
  background: var(--bg-secondary);
  border-top: 1px solid var(--border);
  font-size: 13px;
  color: var(--text-primary);
}
.install-bar[hidden] { display: none; }
.install-bar-text { max-width: 560px; }
.install-bar-actions { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.install-bar-add {
  padding: 7px 16px;
  border: none;
  border-radius: var(--radius-md);
  background: var(--text-primary);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
}
.install-bar-dismiss {
  background: none;
  border: none;
  color: var(--text-muted);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  padding: 0 4px;
}
.install-bar-dismiss:hover { color: var(--text-primary); }

@media (max-width: 640px) {
  .install-bar { flex-direction: column; gap: 8px; padding: 12px 20px; text-align: center; }
}

.desktop-break {
  display: none;
}

/* The mobile-only account rows js/nav.js's syncMobileNavLinks weaves
   into .nav-links when signed in (My Profile, My Orders, Admin Panel,
   Sign Out) — hidden by default so they don't show up as dead entries
   in the desktop tab row, where .nav-links is a horizontal row of
   pills rather than the vertical list they're written for; the
   ≤768px rule (further down this file) is what actually shows them. */
[data-mobile-account-link] {
  display: none;
}

/* Tab headers: black, greying on hover, with no underline in any state —
   the active tab is distinguished by weight alone. */
/* The <button> shares this rule rather than restating it. Sign Out is
   the one row in the mobile drawer that is not an <a>, and a form
   control inherits none of this on its own — Chrome's UA sheet resets
   font, line-height and letter-spacing on every button. Listing it here
   is what makes it the same row as the links by construction instead of
   by a second set of values that has to be kept in step.

   Only `color` does not apply to it: .nav-account-signout overrides that
   with !important, which is what keeps Sign Out red. */
.nav-links a,
.nav-links button {
  font-family: 'Inter', sans-serif;
  font-size: 13.2px;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--text-primary);
  border: none;
  background: none;
  cursor: pointer;
  transition: color var(--nav-transition);
}
/* The one thing the shared rule above cannot supply, because the links
   are not given it either — they INHERIT their leading from an ancestor,
   and a <button> does not: the UA form-control reset pins it to
   `normal`. Restoring the inheritance is what makes the row the same
   height as the others (66px, not 59) rather than a value copied here
   that would drift the moment that ancestor changed. */
.nav-links button { line-height: inherit; }

/* The tabs are all bold now, so the current page can't be marked by simply
   being the bold one any more — it steps up one further weight instead.
   Both are loaded (Inter 400–800), so this is a real face, not a synthesised
   one. */
.nav-links a.active {
  color: var(--text-primary);
  font-weight: 800;
}

.nav-links a:hover {
  color: var(--text-muted);
}

/* Collapse to the hamburger here, and no earlier: the tab row plus the icon
   cluster needs ~863px of measure, which the bar can still give it down to
   about 953px of viewport. (This sat at 1240px while the labels were 15px
   with 68px gaps and the row needed ~1112px — shrinking and tightening them
   bought back nearly 250px, so the full row fits far narrower again.) Below
   this the row would outgrow the bar and push the cart past the edge. */
@media (max-width: 1024px) {
  .nav-toggle {
    display: flex;
  }

  .nav-toggle.active span:nth-child(1) {
    top: 7px;
    transform: rotate(45deg);
  }

  .nav-toggle.active span:nth-child(2) {
    opacity: 0;
  }

  .nav-toggle.active span:nth-child(3) {
    top: 7px;
    transform: rotate(-45deg);
  }

  .nav-right {
    display: none;
    position: absolute;
    top: calc(100% + 8px);
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    background: #ffffff;
    padding: 0;
    /* Matches the floating pill above it rather than running to a hard
       square edge. */
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    overflow: hidden;
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
    z-index: 99;
  }

  .nav-right.active {
    display: flex;
  }

  .nav-links {
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    /* Back into flow — the window-centring in the desktop rule is a
       desktop-only device (see .nav-links above); in the dropdown the
       links are a plain full-width stack instead. */
    position: static;
    transform: none;
    white-space: normal;
  }

  .nav-links li {
    list-style: none;
    margin: 0;
    border-bottom: 2px solid rgba(0, 0, 0, 0.12);
  }
  .nav-links li:first-child {
    border-top: 2px solid rgba(0, 0, 0, 0.12);
  }

  .nav-links a {
    display: block;
    padding: 16px 24px;
    font-size: 20px;
    text-align: left;
    border: none !important;
    color: var(--text-primary);
  }

  .nav-links a:hover {
    background: rgba(0, 0, 0, 0.04);
  }

  .nav-links a.active {
    color: var(--text-primary);
    border-bottom: none;
  }

  /* Sign Out is the one row in this list that's a <button>, not an
     <a> — .nav-links a's own rules above don't reach it on tag alone,
     so its layout is restated here.

     A <button> gets none of the type on its own: Chrome's UA sheet
     resets font, line-height and letter-spacing on every form control,
     which left this row at 57px against the links' 66px with its
     letters set tighter and its weight a step heavier — measurably a
     different row in a list of identical ones.

     The fix is upstream: .nav-links button now shares the base type
     rule and the ≤1024px weight rule with .nav-links a, so family,
     size, weight, tracking and leading all arrive from the same place
     for both. What is left here is only what genuinely differs — the
     block layout a <button> does not get, and the 20px this tier sets.

     Colour is NOT inherited here: .nav-account-signout's own rule
     (unconditional, elsewhere) sets it red with !important, which is
     what makes this row red while every other row reads
     var(--text-primary). */
  .nav-links button {
    display: block;
    width: 100%;
    padding: 16px 24px;
    font-size: 20px;
    text-align: left;
  }
  /* The weight, wrestled back off an !important. .nav-account-signout
     sets 600 for the desktop dropdown, where the row IS meant to stand
     out from the two links above it; in this list it made Sign Out the
     one bold entry among six. `inherit` would resolve against the <li>
     (400), not against the 500 the links are given by the #primary-nav
     rule further down, so the number is stated. Two classes beat one,
     and !important against !important is decided on specificity. */
  .nav-links .nav-account-signout {
    font-weight: 500 !important;
  }
  .nav-links button:hover {
    background: rgba(0, 0, 0, 0.04);
  }

  /* ── Signed in: the account rows are woven into THIS list ──
     data-mobile-account-link is what js/nav.js's syncMobileNavLinks
     inserts — My Profile, then Admin Panel for an admin, then Sign Out,
     all at the end after Contact Us. (My Orders used to be here too;
     see that function for why it is not.) They go in the SAME list as
     the site links rather than a second, separate block below them —
     which is what the dropdown-folded-into-the-drawer treatment further
     down still does for anyone without JS.

     Those inserted <li>s need nothing of their own: they're plain
     .nav-links li>a/button, which every rule above already reaches —
     this only has to make them visible at all, since the base
     (unconditional) rule hides them for desktop, where they'd otherwise
     show up as dead entries in the horizontal tab row rather than in a
     list that doesn't exist at that width. */
  [data-mobile-account-link] {
    display: list-item;
  }

  /* The old floating pill + dropdown is what data-mobile-account-link
     replaces on this tier — showing both at once would say "signed in
     as ___" twice. :has(.nav-account-pill) is what scopes this to the
     SIGNED-IN render specifically: the signed-out icon-link render
     never puts that element inside .nav-account, so this rule simply
     doesn't match then, and the existing "Account" row (its own fix,
     above) is untouched. */
  #primary-nav .nav-account:has(.nav-account-pill) {
    display: none;
  }

  /* Account becomes a plain text row in the mobile menu, styled just like
     the other links — no icon, no circle treatment. The cart icon now
     lives outside #primary-nav (in .nav-end) so it's unaffected here and
     stays visible at all times. */
  .nav-icons {
    flex-direction: column;
    align-items: stretch;
    gap: 0;
  }
  #primary-nav .nav-icon {
    width: auto;
    height: auto;
    display: block;
    padding: 16px 24px;
    /* .nav-links a sets this explicitly (its own base rule); nothing
       here did, so "Account" was inheriting the page's plain fallback
       stack instead — a visibly different typeface from the rest of
       the menu next to it, which is what "styled the same as the
       other ones" was actually asking to fix. Set here rather than
       only on ::after below so it also covers the element itself
       (::after already inherits it from its host either way). */
    font-family: 'Inter', sans-serif;
    font-size: 20px;
    text-align: left;
    /* Strips the desktop circle frame (border + background, above) back
       off — this row reads as a plain drawer link, not a button. */
    border: none;
    border-radius: 0;
    border-bottom: 2px solid rgba(0, 0, 0, 0.12);
    background: none;
    color: var(--text-primary);
  }
  #primary-nav .nav-icon svg {
    display: none;
  }
  #primary-nav .nav-icon::after {
    content: attr(aria-label);
    font-weight: 500;
    letter-spacing: 0.04em;
  }
  #primary-nav .nav-icon:hover {
    background: rgba(0, 0, 0, 0.04);
    color: var(--text-primary);
  }
}

/* No right-side CTA buttons */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 24px;
  border-radius: var(--radius-sm);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  cursor: pointer;
  border: none;
  transition: all var(--transition);
}

/* Catalog / contact CTA — refined near-black ink */
.btn-primary {
  background: var(--cta);
  color: #fff;
}

.btn-primary:hover {
  background: var(--cta-bright);
  transform: translateY(-1px);
  box-shadow: 0 10px 24px -8px rgba(0,0,0,0.5);
}

.btn-primary:active {
  transform: translateY(0);
}

/* ============================================================
   HERO
   ============================================================ */
/* ── The fold: hero + Research Use Only band, one large screen tall ──
   The wrapper is what fixes where the page's second section begins. Its
   height is 100lvh, so the top of Your Order Process lands on the 100lvh
   line exactly, rather than wherever the hero's height and the band's
   height happened to sum to.

   ── Two different viewport units, on purpose ──
   svh is the viewport with the browser's chrome SHOWING — the smallest it
   ever gets, and therefore the only height guaranteed to be visible when
   the page loads. lvh is the viewport with the chrome retracted, the
   largest it gets.

   The hero's basis is 100svh because the hero is what a visitor sees
   before scrolling; sizing it in lvh (which is what it used to be, and
   what plain vh resolves to on iOS) meant its last row of content sat
   underneath Safari's bottom bar until you scrolled — the buttons were
   there and not reachable.

   The wrapper is 100lvh because nothing about "where does the next
   section start" is decided before scrolling, and by the time it matters
   the chrome is gone.

   The gap between the two units is exactly the band of screen the chrome
   covers, and the research banner is what occupies it — off-screen at
   load, revealed by the first scroll as the bar retracts. That is the
   band's whole job, and it now gets it by construction rather than by the
   hero happening to be exactly one viewport tall.

   ── Grid, not flex ──
   Two attempts with flexbox failed for the same underlying reason and
   are worth recording so neither is tried again.

   `min-height: 100lvh` on a column flex container does not shrink
   anything: flexbox only takes space back from an item when the
   container's size is DEFINITE and smaller than its contents. With a
   minimum, the box just grew to hero + band (877 on an 800px screen) and
   flex-shrink never ran.

   Switching to `height: 100lvh` fixed the shrink and broke the opposite
   case. The obvious guard, `min-height: min-content`, cancelled the whole
   thing — Chrome resolves a column flex container's min-content height to
   the sum of its items' HYPOTHETICAL main sizes, the hero's full basis
   included, so the floor came out taller than the height again. Dropping
   the guard then let the hero overlap the next section at widths where
   its own content genuinely needs more than one screen (900px wide: 913
   of content in an 800 box).

   Grid does both halves without the conflict. `grid-template-rows: 1fr
   auto` gives the band exactly its content height and the hero
   everything left over; the row's own floor is the hero's min-content, so
   the track grows past `1fr` when the copy needs it and the container's
   `min-height` yields to it. Shrink and grow, one declaration, no
   definite height to fight with. */
.home-fold {
  display: grid;
  grid-template-rows: 1fr auto;
  /* Plain vh first for anything that does not know the newer units. On
     iOS vh IS the large viewport, so the two already agree there. */
  min-height: 100vh;
  min-height: 100lvh;
}
/* Releases the base rule's `min-height: 100vh` so the row can size it.
   `auto` on a grid item is the automatic minimum — min-content — which is
   the floor that stops the hero being compressed into its own copy. */
.home-fold > .hero {
  min-height: auto;
}

.hero {
  position: relative;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: center;
  gap: 6vw;
  text-align: left;
  min-height: 100vh;
  padding: clamp(20px, calc(12svh - 60px), 80px) 24px 24px 24px;
  overflow: hidden;
  box-sizing: border-box;
  /* Transparent — body's white is the ground. */
  background: transparent;
}

/* ── FIRST-VISIT DISCOUNT POPUP ────────────────────────────
   What used to be an inline card between the hero and the order-process
   band is now this: a dismissible overlay shown once per browser (see the
   script at the bottom of index.html). align-items:flex-start rather than
   center is what makes it "show up at the top" — the panel sits just under
   the nav rather than in the middle of the screen — and the backdrop still
   covers the full viewport so the rest of the page is inert behind it. */
/* ── The offer dock ──
   A fixed anchor at the bottom-right corner. Zero-sized: it exists only
   to be a corner, so it never intercepts a click of its own.

   No backdrop and no scroll lock. A corner panel that dims the whole page
   and freezes scrolling is a modal wearing a corner's clothes; this one
   lets the visitor keep reading with the offer where they left it. */
.promo-dock {
  position: fixed;
  right: var(--promo-dock-gap);
  bottom: var(--promo-dock-gap);
  z-index: 1000;
  --promo-dock-gap: 24px;
  width: 0;
  height: 0;
}
.promo-dock[hidden] { display: none; }

/* ── The card, which IS the pill ──
   One element with two faces and one attribute deciding between them.
   The card and the pill were two sibling boxes shown and hidden in turn,
   which is what put both on screen together on a reload — each carried
   its own flag, set from a different code path.

   Anchored by its bottom-right corner (right:0; bottom:0 against a dock
   that is itself a corner), so growing and shrinking the box needs no
   transform and no translate: the corner simply cannot move. */
.promo-card {
  position: absolute;
  right: 0;
  bottom: 0;
  overflow: hidden;
  box-sizing: border-box;

  --promo-pad: 30px;
  /* Measured, not estimated. The placeholder
     "researcher@institution.edu.au" sets 200px of ink at the field's own
     15px; plus its 16px padding either side that is a 248px field, and
     the submit button beside it measures 175px with a 10px gap between —
     460px, with a little room to spare over the 433px minimum. */
  --promo-content: 460px;

  color: #fff;
  text-align: left;
  /* Dark base under the artwork: this colour paints to the border-box
     edge, so the partially covered pixels along the rounded corners blend
     it with whatever is behind — a pale value there reads as a white
     fringe picking out each corner. */
  background-color: #14141a;
  /* ── One gradient, and it is the photograph ──
     There used to be a stack of CSS radial-gradients sitting UNDER the
     photo here as a no-network fallback, on the reasoning that a
     computed gradient exists on the first frame and a fetched image does
     not. What that actually produced was two different gradients in
     sequence: the CSS one painted, the webp landed on top of it, and the
     card visibly changed appearance — a flash of the wrong artwork,
     which is a worse thing to see than the half-beat of dark plate it
     was preventing.

     The layers are gone. The card is the flat ground plus the photo plus
     the darkening overlay that makes white type readable on it, and
     nothing paints that the visitor is not meant to keep looking at.

     The half-beat is dealt with where it actually lives — in time, not
     in paint. Every page carries <link rel="preload" as="image"
     fetchpriority="high"> for this file in <head>, so the fetch starts
     with the document; js/promo.js additionally holds the whole offer off
     screen until the image reports decoded (PROMO_ART / paintReady), so
     there is no frame in which a visitor sees this card without its
     artwork on it.

     Order still matters: CSS paints the FIRST-listed background-image on
     top, so the darkening overlay stays above the photo. */
  background-image:
    linear-gradient(rgba(8, 8, 12, 0.62), rgba(8, 8, 12, 0.46)),
    url('main_card_gradient_bg.webp');
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.38);

  /* The morph. Width and height carry it; the corner radius travels with
     them so the box is a pill by the time it is pill-sized. */
  transition: width 0.34s cubic-bezier(0.22, 0.61, 0.36, 1),
              height 0.34s cubic-bezier(0.22, 0.61, 0.36, 1),
              border-radius 0.34s cubic-bezier(0.22, 0.61, 0.36, 1),
              padding 0.34s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* ── Open ──
   Equal padding on all four sides. An earlier pass added 220px of dead
   space to the right; it is gone, so the copy now sits the same distance
   from every edge. */
.promo-card[data-state="open"] {
  /* Padding SNAPS on the way up while the box still animates. It is the
     other thing that moved the content inside the frame: animating it
     0 → 24px walks the copy 24px down and right over the course of the
     expansion, which is exactly the drift the content is not supposed to
     have. Jumping it means the copy's offset from the frame's own edges
     is its final offset from the first frame onward.

     Restated in full rather than added to, because setting `transition`
     here replaces the base rule's list outright. The base list still
     applies on the way DOWN, where padding easing back to 0 is part of
     the card folding shut. */
  transition: width 0.34s cubic-bezier(0.22, 0.61, 0.36, 1),
              height 0.34s cubic-bezier(0.22, 0.61, 0.36, 1),
              border-radius 0.34s cubic-bezier(0.22, 0.61, 0.36, 1);
  width: min(
    calc(var(--promo-content) + 2 * var(--promo-pad)),
    calc(100vw - 2 * var(--promo-dock-gap))
  );
  /* --promo-open-h is measured and pinned by js/promo.js. `auto` cannot
     be transitioned, so without an explicit pixel value the minimise
     would snap rather than move. The fallback is only for the first frame
     before that measurement lands. */
  height: var(--promo-open-h, auto);
  max-height: calc(100vh - 2 * var(--promo-dock-gap));
  padding: var(--promo-pad);
  border-radius: 24px;
}

/* Used for one synchronous measurement only (js/promo.js pinOpenHeight).
   The height has to be measured with the card at its OPEN width — read
   mid-transition it is still at the pill's 152px, the copy wraps into a
   far taller column, and the pinned height comes out hundreds of pixels
   too big. That over-measurement is what left dead space under the fine
   print. Transitions are off for the same reason: nothing here should be
   animated, it is a ruler. */
.promo-card.is-measuring {
  transition: none !important;
  width: min(
    calc(var(--promo-content) + 2 * var(--promo-pad)),
    calc(100vw - 2 * var(--promo-dock-gap))
  ) !important;
  height: auto !important;
  padding: var(--promo-pad) !important;
}

/* ── Collapsed ──
   The same element at pill size. The dimensions are stated rather than
   derived from the label so the transition has a fixed target to run
   toward — a content-sized box would be `auto` again. */
.promo-card[data-state="pill"] {
  width: 152px;
  height: 48px;
  padding: 0;
  /* Half the height, not a fixed 20px — a true stadium shape has fully
     semicircular ends, which only holds at exactly height/2. 20px against
     a 48px box left the corners visibly short of that. */
  border-radius: 24px;
  cursor: pointer;
}
.promo-card[data-state="pill"]:hover { box-shadow: 0 14px 38px rgba(0, 0, 0, 0.42); }

/* ── The two faces ──
   Cross-faded, and each taken out of the pointer and tab order when it is
   not the visible one. `visibility` rather than `display` so both keep a
   box for the height measurement and neither reflows mid-transition.

   Both also SCALE about the card's bottom-right corner. The box already
   grows and shrinks from that corner, but the content inside it was not
   moving with it — the copy sat still at full size while the frame closed
   over it, which read as a shutter rather than as one object collapsing.
   Scaling the content about the same fixed point is what makes the card
   and everything in it converge on the corner together.

   The factor is the pill's width over the card's (152/520 ≈ 0.29), so the
   content is travelling at the same rate the frame is. */
.promo-card-full,
.promo-card-pill {
  transform-origin: 100% 100%;
  /* The transform runs on exactly the frame's duration and curve, so the
     content is always where the frame is rather than catching up to it.

     The opacity is the part that was wrong on the way UP: at 0.2s ease it
     was still fading in after the frame had finished, which read as the
     copy filling a box that was already there. On expand it is now a fast
     fade that completes early — the content is solid almost immediately
     and then travels with the frame. On collapse the longer fade is kept,
     because there the copy SHOULD dissolve rather than be yanked. */
  transition: opacity 0.34s cubic-bezier(0.22, 0.61, 0.36, 1),
              transform 0.34s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* ── Expanding ──
   The content does NOT travel on the way up. It is placed at its final
   size and its final position the instant the card opens, and the frame
   then grows past it — so what the eye sees is a box uncovering copy that
   was already there, with nothing moving inside it.

   Scaling it up alongside the frame (which is what the collapse still
   does, in reverse) meant the copy was zooming while the box was growing:
   two simultaneous movements at the same rate, which reads as the content
   inflating into the frame rather than the frame revealing the content.
   Snapping the transform is the whole fix — opacity still fades, quickly,
   so nothing appears abruptly.

   Collapse keeps the scale, from the base rule above: closing SHOULD
   converge on the corner, because there the card is being put away and
   the content going with it is the point. */
.promo-card[data-state="open"] .promo-card-full {
  transform: scale(1);
  transition: opacity 0.12s ease-out;
}
.promo-card[data-state="pill"] .promo-card-full { transform: scale(0.29); }

/* The pill label does the reverse: at rest it is full size, and while the
   card is open it is scaled up and faded out, so expanding reads as the
   label growing INTO the card rather than being swapped for it. */
.promo-card[data-state="pill"] .promo-card-pill { transform: scale(1); }
.promo-card[data-state="open"] .promo-card-pill { transform: scale(2.4); }
.promo-card[data-state="pill"] .promo-card-full,
.promo-card[data-state="open"] .promo-card-pill {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}
.promo-card[data-state="open"] .promo-card-full,
.promo-card[data-state="pill"] .promo-card-pill {
  opacity: 1;
  visibility: visible;
}

/* ── Suppressed while something is over the page ──
   The dock sits at z-index 1000, above the cart drawer (120), so it has
   to be taken off screen whenever the drawer, the auth modal or the age
   gate is up. js/cart-drawer.js was already setting visibility:hidden on
   the dock — but visibility is INHERITED and a descendant can override
   it, and the rule directly above does exactly that on whichever face is
   showing. So the frame vanished and its text kept painting, over the
   drawer.

   Hidden here at a specificity that beats that rule (four classes to its
   three) rather than with !important, and via a class rather than an
   inline style so the two rules live next to each other. visibility, not
   display: the card's height is measured on the way in and out, and
   display:none would take that box away mid-measurement. */
.promo-dock.is-suppressed { visibility: hidden; }
.promo-dock.is-suppressed .promo-card .promo-card-full,
.promo-dock.is-suppressed .promo-card .promo-card-pill {
  visibility: hidden;
  pointer-events: none;
}

/* The full face keeps its own width while the box shrinks around it, so
   the copy does not reflow into an ever-narrower column on the way down —
   it simply gets clipped by the card's overflow:hidden, which reads as
   the card closing over it. */
.promo-card-full {
  width: var(--promo-content);
  max-width: 100%;
}

/* The pill face fills the collapsed box exactly. */
.promo-card-pill {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  padding: 0;
  color: #fff;
  font-family: inherit;
  cursor: pointer;
}
.promo-card-pill:focus-visible { outline: 2px solid #fff; outline-offset: -4px; }

.promo-pill-text {
  font-size: 14px;
  font-weight: 600;
  letter-spacing: -0.01em;
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .promo-card, .promo-card-full, .promo-card-pill { transition: none; }
}

/* Replaces the old corner "×" icon button — a tappable line of text
   instead, sized and coloured to read as one more line of the offer's
   own copy (the same muted white the input's placeholder and the fine
   print below use) rather than as a control bolted onto the card.
   Static in flow, not absolutely positioned in a corner: it sits right
   under the form, which is where a "no thanks" belongs relative to the
   "yes" button right above it. */
.promo-popup-decline {
  /* PHONE ONLY. Above 768px the way down is the minus in the card's
     top-right corner (.promo-minimise, below) — asked for outright — and
     an open card must never offer both. Turned back on by the ≤768px
     block further down, where the sheet is full-screen and the corner is
     nowhere near a thumb. */
  display: none;
  margin-top: 14px;
  padding: 0;
  border: none;
  background: none;
  font-family: var(--font-sans);
  font-size: 13px;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.62);
  text-decoration: underline;
  text-decoration-color: rgba(255, 255, 255, 0.35);
  text-underline-offset: 3px;
  cursor: pointer;
  transition: color var(--transition), text-decoration-color var(--transition);
}
.promo-popup-decline:hover {
  color: #fff;
  text-decoration-color: #fff;
}

/* Two phrasings, same device .promo-pill-full/.promo-pill-short already
   use — full by default, short (and restyled into an actual button) once
   the phone tier below turns the popup into the full-screen sheet. */
.promo-decline-short { display: none; }

.promo-popup-heading {
  font-size: clamp(28px, 4vw, 44px);
  font-weight: 800;
  letter-spacing: -0.03em;
  line-height: 1.1;
  margin: 0 0 24px;
  max-width: 480px;
}

.promo-popup-form {
  display: flex;
  gap: 10px;
  /* The content column, not an arbitrary cap — the card's width is
     derived from this, so the two cannot disagree. */
  max-width: var(--promo-content);
}

/* "Glossy transparent" — a translucent glass pane rather than a solid
   field, so the card's own background shows through it. The backdrop-blur
   is what makes it read as glass instead of plain see-through: without it
   this would just look like a hole in the card. The inset highlight along
   the top edge is the "glossy" part — a thin bright line standing in for a
   light source catching a curved glass surface. */
.promo-popup-input {
  /* flex-basis, not flex:1 from zero. At `flex: 1` the field was sized by
     whatever was left after the button, which was narrower than the
     placeholder — so "researcher@institution.edu.au" was cut off in the
     one field whose whole job is to look like an email address. The basis
     is the placeholder's own measured width plus room to spare, and it
     still shrinks below that on a narrow card. */
  flex: 1 1 248px;
  min-width: 0;
  padding: 14px 16px;
  border: 1px solid rgba(255, 255, 255, 0.4);
  border-radius: var(--radius-md);
  background: rgba(255, 255, 255, 0.16);
  backdrop-filter: blur(12px) saturate(1.4);
  -webkit-backdrop-filter: blur(12px) saturate(1.4);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 4px 14px rgba(0, 0, 0, 0.15);
  color: #fff;
  font-family: var(--font-sans);
  font-size: 15px;
  transition: border-color var(--transition), box-shadow var(--transition);
}
.promo-popup-input::placeholder { color: rgba(255, 255, 255, 0.7); }
.promo-popup-input:hover { border-color: rgba(255, 255, 255, 0.6); }
.promo-popup-input:focus {
  outline: none;
  border-color: #fff;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 0 0 3px rgba(255, 255, 255, 0.25);
}

.promo-popup-submit {
  flex-shrink: 0;
  padding: 0 22px;
  border: none;
  border-radius: var(--radius-md);
  background: #fff;
  color: var(--text-primary);
  /* Stated, not inherited. A <button> does NOT inherit font-family from
     its ancestors — the UA stylesheet sets its own (Arial here), which
     is why this one CTA was the only text on the card not set in the
     site's face. The input beside it already declares --font-sans for
     the same reason, and so does the decline button below it; without
     this the two buttons measured 1px different in height purely
     because "normal" line-height resolves differently per family. */
  font-family: var(--font-sans);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  cursor: pointer;
  transition: opacity var(--transition);
}
.promo-popup-submit:hover { opacity: 0.85; }

/* Sits directly under the form. Amber rather than red: this is "check
   that again", not a failure of anything the visitor is owed, and red on
   this artwork reads far more alarming than a mistyped address warrants. */
.promo-popup-error {
  max-width: 440px;
  margin-top: 12px;
  font-size: 14px;
  font-weight: 600;
  line-height: 1.4;
  color: #ffd27a;
}
.promo-popup-error[hidden] { display: none; }
.promo-popup-input.is-invalid {
  border-color: #ffd27a;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 0 0 3px rgba(255, 210, 122, 0.22);
}

/* The fine print itself. †, not a religious glyph — see the comment on the
   markup in index.html for why that's the right character here. One
   condition per line rather than one run-on sentence, so each reads as
   its own fact. A plain <ul> with its own bullet suppressed, since the †
   already marks each line — a second bullet glyph would be redundant. */
.promo-popup-disclaimers {
  list-style: none;
  margin: 20px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.promo-popup-disclaimers li {
  font-size: 12px;
  /* Halved from 1.5. These are three one-line facts, not a paragraph —
     at 1.5 they read as three separate notes rather than one block of
     fine print. */
  line-height: 1.25;
  color: rgba(255, 255, 255, 0.72);
  text-align: left;
}

@media (max-width: 1024px) {
  .promo-card {
    --promo-pad: 22px;
    /* The content column gives up its fixed width — at this size the card
       is already as wide as the viewport allows and the form stacks. */
    --promo-content: 100%;
  }
  .promo-card[data-state="open"] { border-radius: 20px; }

  /* The card gets tall and narrow here while the artwork is a fixed 16:9
     landscape photo. `cover` picks whichever scale makes the image at
     least as big as the box in BOTH dimensions and then centres it — once
     the box is this much taller than wide, that is always the height, so
     the image's full top-to-bottom sweep is shown with no room to pan.
     Scaling past cover's own minimum (auto height rather than a bare
     cover) gives the crop real vertical slack.

     Scoped to the OPEN state only — the base rule's plain `cover`/`center`
     (further up this file) is what the pill face keeps. This tuning is
     solved for the tall near-square shape the card takes at this width
     when it's open; applied to the pill's own 48px-tall box as well (as
     an unscoped `.promo-card` selector did before) it panned the same
     crop meant for a much taller box, which showed a strip of the image's
     dark top edge — visible as an unstyled black patch — instead of the
     gradient. */
  .promo-card[data-state="open"] {
    background-size: auto 175%;
    background-position: center 38%;
  }
  .promo-popup-form {
    flex-direction: column;
    max-width: none;
  }
  .promo-popup-submit { padding: 14px; }
}

/* Research-use disclaimer — the immediate next sibling of .hero, whose
   own min-height:100vh means this banner's top edge lands exactly on
   the fold: off-screen at load, but the first pixel of scroll reveals
   it since it's sitting right there. Text styling matches .hero-subtitle
   (the homepage's main description) but stays white on this dark bar;
   padding is tuned so forcing it to one line keeps the bar's overall
   height the same as it was when the sentence wrapped to two. */
.research-banner {
  background: var(--text-primary);
  padding: 25px 24px;
  text-align: center;
  overflow-x: auto;
}

.research-banner-text {
  /* Matched to .hero-subtitle — the hero's own main description
     paragraph — rather than picked on its own. This rule only actually
     renders on the fold banner under the hero now; the other instance
     (.research-banner--capped) overrides font-size itself. */
  font-size: 15.81px;
  line-height: 1.7;
  color: rgba(255, 255, 255, 0.75);
  margin: 0;
  white-space: nowrap;
}

.research-banner-text strong {
  font-weight: 800;
}

/* ── Matched to the standing-offer strip's own type (.promo-item,
   .promo-bar above) — asked for outright, so the two black bars read as
   one typographic treatment rather than two. Only the type: this band
   keeps its own padding, line-height and in-flow position, since it is
   still the taller under-hero band the promo strip is not. */
.research-banner--fold .research-banner-text {
  font-size: 14.4px;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: rgba(255, 255, 255, 0.82);
}
.research-banner--fold .research-banner-text strong {
  color: #ffffff;
}

.research-banner-highlight {
  background-color: #0a0a0a;
  color: #ffffff;
  padding: 2px 4px;
  border-radius: 2px;
  display: inline;
}

@media (max-width: 640px) {
  .research-banner { padding: 18px 20px; }
  .research-banner-text { font-size: 15px; }
}

/* ── Disclaimer merged with the order-process title ──
   The banner runs on past the disclaimer to carry the section title, ending
   on a flat edge. Ordinary flow — no sticky, no scroll-snap — so it just
   scrolls away with the rest of the page like anything else here. Its own
   horizontal padding is dropped here since it now sits inside .op-bleed,
   which already supplies that inset — keeping both would double it up. */
.research-banner--capped {
  /* Overrides the shared .research-banner's dark bar for this instance
     only — the other (repeated further down the page) stays as is. */
  background: var(--bg-primary);
  padding-inline: 0;
  /* The shared .research-banner's own padding still supplies the top —
     only the bottom (the space between the title and the step content
     below) is zeroed here. */
  padding-bottom: 0;
  /* The one fluid value both the disclaimer and the title derive their
     font-size from (see each rule below). Resizing the window changes
     this ONE number, and both lines scale off it together — that's the
     entire mechanism for "the two stay in lockstep at every size,"
     replacing what used to be a resize-listener script measuring one
     line and rewriting the other's font-size and margin to match. */
  /* The value itself now lives on :root — see the note there. It was
     scoped to this block while these two lines were its only consumers;
     the order-process title (.op-heading) sits outside this wrapper and
     is a third. */
}

.research-banner--capped .research-banner-text {
  color: var(--text-secondary);
  font-weight: 600;
  /* Sized so this line renders JUST NARROWER than the title above it,
     rather than to a font-size picked in isolation.

     Because both lines scale off the same unit, their rendered widths
     scale together and the ratio between them is fixed — so one
     multiplier holds at every viewport, with no measuring script. The
     number comes from measuring the real single-line ink width of both
     strings: at unit = 17.28px the title ran 510px and this line ran
     Re-measured against the current pair. At 1440px the title
     ("Most Requested Compounds") sets 853px of ink; 0.69 put this line at
     562px, which read as a caption rather than as the title's companion.
     0.98 brought it to ~800px, a little under the title's own width.

     Scaled up again when the title itself grew to match "Tested &
     Delivered": 12.85px against 44.71px at 1440px before that resize,
     0.28741 either way — expressed here against the title's own new size
     formula so the RATIO survives, not just the two old numbers.

     The title's own × 0.8 (a since-reverted 20% shrink) briefly sat on
     this line too and has been removed again — the title is now pegged
     to match .op-heading exactly, and this line's whole reason for being
     a multiplier of the title's formula, rather than its own font-size,
     is so it keeps riding along with whatever the title does next
     without a second edit here. */
  font-size: 14.4px;
  /* The base .research-banner-text is nowrap, which existed only to keep
     this line's LEFT EDGE stable for the title-alignment trick that's
     gone now (both lines are simply centred). Normal here so a narrow
     viewport, where neither line fits on one line and the width
     relationship above stops meaning anything anyway, wraps instead of
     quietly overflowing .research-banner's own overflow-x:auto. */
  letter-spacing: 0.01em;
  line-height: 1;
  white-space: normal;
}

/* Plain centred block — no shrink-wrap, no script. Both children are
   ordinary full-width elements with centred text, so the browser centres
   each one on its own without needing the two to match widths first. */
.research-banner-inner {
  width: 100%;
  max-width: 100%;
  text-align: center;
}

.research-banner--capped .process-mega-heading {
  width: 100%;
  max-width: 100%;
  padding-inline: 0;
  /* In em, not px: 3px was 0.0671 of this element's own OLD 44.71px
     font-size at 1440px, and `em` on an element's margin is relative to
     that same element's font-size — so writing the ratio directly in em
     keeps the gap exactly that fraction of the title's size at every
     viewport, through the resize above and after it. */
  margin: 0.0671em 0 0;
  color: var(--text-primary);
  font-weight: 800;
  letter-spacing: -0.04em;
  line-height: 1.1;
  /* Matched to the hero's "Tested & Delivered", same expression
     .op-heading uses — see the comment there. Was --op-heading-unit * 2.3
     (before that, * 3.41), which put this and .op-heading at sizes with
     no relationship to the rest of the page's type.

     Now IDENTICAL to .op-heading's own formula, deliberately — sized to
     match "Your Order Process" exactly, the same way it was once matched
     to the hero title. A since-reverted × 0.8 briefly shrunk this 20%
     against .op-heading, which never moved; the disclaimer above scales
     off this line's formula rather than its own, so undoing it here
     brought both back in step without a second edit there. */
  font-size: calc(clamp(32px, 5vw, 72px) * 0.9 * 1.15 * 0.82);
}

/* The section's own description, under the title. Same size, colour and
   measure as the order-process card's sub — the two are the same kind of
   sentence doing the same job in two sibling sections, so they read as
   one treatment rather than two. */
.research-banner--capped .process-mega-sub {
  /* Centred under the title rather than ragged against the left edge: the
     heading above it is the section's own centred mark, and a description
     hanging off one end of it read as a caption for something else.
     margin-inline:auto is what centres the 62ch measure itself; text-align
     centres the lines inside it. */
  margin: 14px auto 0;
  text-align: center;
  max-width: 62ch;
  font-size: calc(clamp(32px, 5vw, 72px) * 0.9 * 1.15 * 0.82 * 0.661 / 2.3);
  line-height: 1.65;
  color: var(--text-secondary);
}

/* Circular scrolling product mosaic, ringed by rotating curved text — the
   right column of the hero. .hero-orb-wrap is the outer box: it holds the
   text ring (an SVG textPath, rotated as a whole via CSS so the already-
   curved text sweeps around continuously) and the product circle nested
   inside it, both centred on the same point. Content is built by the
   inline script at the bottom of index.html once Store is available. */
.hero-orb-wrap {
  position: relative;
  flex-shrink: 0;

  /* +15% on the whole assembly. Everything inside is expressed against
     --hero-orb-size, so this one number moves the cascade, the text ring
     and the white backing disc together and keeps their relationship
     exact. The SVG ring scales with it for free — it has a fixed viewBox,
     so its type and radius follow the box. */
  --hero-orb-size: 547.4px;    /* 560 × 1.15 × 0.85 */
  width: var(--hero-orb-size);
  height: var(--hero-orb-size);
  z-index: 2;
}

/* ── The white backing disc ──
   A solid white circle behind the cascade and the text ring, so the ring
   always reads against flat white rather than against whatever the page
   background is doing underneath it (now a grid).

   Its radius is derived, not chosen. Working in the SVG's own user units,
   where the ring's baseline path is r=283 inside a 630-unit viewBox:

     cascade outer edge     240 / 280  of the wrap radius   → 0.85714 r
     ring inner ink         283 / 315                       → 0.89841 r
     ring outer ink         297.7 / 315                     → 0.94508 r

   (283 is the baseline the glyphs sit on and the text is all uppercase,
   so nothing descends below it; 297.7 is the measured outer extent of the
   group's ink — the macron on the Ō in PURE·AMINŌ is what sets it.)

   The gap the brief asks to mirror is ring-inner minus cascade-outer:
   0.89841 − 0.85714 = 0.04127 r. Adding that outside the ring's outer ink
   gives 0.98635 r — the +2px added on top of that in an earlier pass has
   since been taken back off, landing the radius back on the bare
   derivation with nothing added or subtracted. */
.hero-orb-disc {
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(var(--hero-orb-size) * 0.98635);
  height: calc(var(--hero-orb-size) * 0.98635);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: #ffffff;
  /* Behind the ring and the cascade, in front of the page's grid. */
  z-index: 0;
  pointer-events: none;
}

/* Decorative only. pointer-events:none already keeps the cursor from
   reacting to it; user-select:none is what stops the phrases from being
   swept up in a drag-selection that started elsewhere on the page. */
.hero-orb-ring {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 1;                 /* over .hero-orb-disc */
  animation: heroRingSpin 120s linear infinite;
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
  cursor: default;
}

.hero-orb-ring-text,
.hero-orb-ring text {
  user-select: none;
  -webkit-user-select: none;
  pointer-events: none;
  cursor: default;
}

@keyframes heroRingSpin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.hero-orb-ring-text {
  font-family: var(--font-sans);
  font-size: 16px;
  font-weight: 700;
  letter-spacing: 0.12em;
  fill: var(--text-secondary);
}

/* The brand-name separator repeated between each pair of benefit phrases —
   lighter than the phrases themselves so it reads as a divider, not a fifth
   claim competing with the other four. */
.hero-orb-ring-text--brand {
  /* A shade darker than the original #ececf2 — asked for outright.
     Still well short of the phrases' own --text-secondary, which is
     the whole point: it reads as a slightly more visible divider, not
     as a fifth claim competing with the four benefit phrases. */
  fill: #d8d8e0;
}

/* ── Official-site mark ──
   Sits above the hero headline, in the normal flow of the copy column —
   it used to be pinned to the ring's top-right, where it read as a label
   on the artwork rather than as the page's opening statement. */


/* The full sentence is long for a phone. Below the hero's column break it
   keeps the mark but lets the label wrap rather than forcing the pill
   wider than the screen. */

/* The product circle itself, centred inside .hero-orb-wrap with room to
   spare around it for the text ring. */
.hero-orb {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  /* 480/560 of the wrap — stated as the ratio so the +15% above carries
     through instead of being applied twice by hand. */
  width: calc(var(--hero-orb-size) * 0.857143);
  height: calc(var(--hero-orb-size) * 0.857143);
  z-index: 1;                 /* over .hero-orb-disc */
  border-radius: 50%;
  overflow: hidden;
  background: var(--bg-secondary);
  box-shadow: var(--shadow-sm);
}

.hero-orb-cols {
  display: flex;
  align-items: flex-start;
  justify-content: center;
  width: 100%;
  height: 100%;
  /* +15%, matching the orb assembly around it (then -15% again to match
     the assembly's later shrink). The bottles were left at their old size
     when the ring grew, which is what left the cascade looking small
     inside its own circle. */
  /* 33.15 / 547.4. Ratios, not px: these were tuned by hand against a
     547.4px assembly, so the moment --hero-orb-size moved (the phone
     scales it up 25%) the bottles kept their old size inside a bigger
     ring and the cascade stopped being in proportion to the type
     around it. Expressed against the variable, the whole mosaic scales
     as one object and every value below still resolves to exactly what
     it was at 547.4px. */
  gap: calc(var(--hero-orb-size) * 0.060559);
}

/* align-items:flex-start above stops the row from stretching each column
   to the orb's height — the column's real content (the doubled tile
   sequence, for a seamless loop) is much taller than the orb and is meant
   to overflow it; the circle's own overflow:hidden does the clipping.
   Without this, flex's default stretch would squeeze/shrink the tiles to
   fit, instead of just scrolling a tall strip past a round window.
   align-items:center (cross-axis, i.e. horizontal) lets each column size
   to its own tiles' natural width instead of stretching to a fixed one —
   tiles no longer have a uniform square box, so their widths vary slightly
   with each bottle photo's own aspect ratio. */
.hero-orb-col {
  display: flex;
  flex-direction: column;
  align-items: center;
  flex-shrink: 0;
  gap: calc(var(--hero-orb-size) * 0.048269);   /* 26.4225 / 547.4 */
}

.hero-orb-col--offset {
  margin-top: calc(var(--hero-orb-size) * 0.271428);  /* 148.58 / 547.4 */
}

/* Half the previous speed again — the mosaic now drifts noticeably slower
   than the text sweeping around it. */
.hero-orb-col--up {
  animation: heroOrbScroll 240s linear infinite;
}

.hero-orb-col--down {
  animation: heroOrbScroll 240s linear infinite reverse;
}

@keyframes heroOrbScroll {
  from { transform: translateY(0); }
  to { transform: translateY(-50%); }
}

/* Just the product photos — no card, no background, no square box behind
   them: the tile is sized to exactly the image it wraps (fixed height,
   auto width) so there's no invisible padding to pack around. Darkened
   with the same brightness(0.88) filter used on catalog product cards
   when hovered. Whether the darken (and the column-pause below) triggers
   at all is gated on hovering an opaque bottle pixel specifically — see
   the canvas alpha-sampling in index.html's mosaic script, which toggles
   .hero-orb-tile--hot only when the cursor is over a non-transparent
   region of the PNG. */
.hero-orb-tile {
  position: relative;
  display: block;
  flex-shrink: 0;
}

.hero-orb-tile img {
  display: block;
  /* 207 × 1.15, then a little more on top — the brief asked for the
     bottles themselves to read bigger, not merely to keep pace. Then that
     whole 252px × 0.85 for the cascade's later 15% shrink. */
  height: calc(var(--hero-orb-size) * 0.391304);      /* 214.2 / 547.4 */
  width: auto;
  transition: filter 0.2s ease;
}

.hero-orb-tile--hot img {
  filter: brightness(0.88);
}

/* Hidden products still fill out the ring for variety. The tile leads
   nowhere, but it's indistinguishable from a live one at a glance — same
   hover darken, same pointer cursor — clicking it is simply a no-op since
   it's a plain <div> with no href. */
.hero-orb-tile--inert {
  cursor: pointer;
}


@media (max-width: 1024px) {
  .hero-orb-wrap { display: none; }
}

/* Left column of the hero: title, subtitle, actions — all centred
   on one another rather than sharing a left edge. */
.hero-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* 560px − 10%. A further −25% was applied on top of this and then
     undone again, so this is back to being the one active reduction.
     The block stays vertically centred against the orb because .hero
     centres it — shrinking it changes its height, not its alignment, so
     the centre line does not move regardless of how many times this
     gets adjusted further. */
  width: 504px;
  max-width: 100%;
  flex-shrink: 1;
  text-align: center;
}

/* Green, not the site's usual near-black ink — deliberately the one spot
   of colour breaking the monochrome palette here, reusing the same
   --ok/--ok-bg/--ok-border trio the "batch-tested" badges use elsewhere,
   so "trustworthy" reads as one consistent colour language across the
   site rather than introducing a second green. */

.hero-title {
  /* × 0.9 × 1.15 × 0.85 — the 0.9 is the long-standing reduction; the
     1.15 matches the +15% once applied to the orb assembly, and the 0.85
     matches that assembly's later 15% shrink, so the two halves of the
     hero keep moving together. */
  font-size: calc(clamp(32px, 5vw, 72px) * 0.9 * 1.15 * 0.85);
  font-weight: 800;
  letter-spacing: -0.04em;
  line-height: 1.1;
  margin-bottom: 21.08px;  /* 21.6 × 1.15 × 0.85 */
  /* Was 20px to clear the "official site" badge that sat above. With the
     badge gone this only offsets the whole copy block downward, breaking
     the vertical centring against the orb that .hero's align-items:center
     otherwise gives for free. */
  margin-top: 0;
  max-width: 1000px;
  white-space: normal;
}

.hero-title-peptides {
  font-size: 1.6em;
}

.hero-title-subtitle {
  font-size: 0.82em;
}

/* "Tested" / "Delivered" are set plain — no underline. The class earns its
   keep as the hook the ::selection rules below need to turn those two words
   white when they're highlighted. */

/* Painted as plain text, NOT as a background-clip gradient. It used to be
   a linear-gradient clipped to the glyphs with a transparent text fill —
   but both stops (--accent-bright / --accent-light) are the same #0a0a0a,
   so the gradient never showed anything a flat colour doesn't. It did have
   one real side effect: an element whose background is clipped to its text
   paints that background OVER the selection highlight, and its transparent
   fill ignores ::selection's `color` entirely, so "Peptides" could never
   go white when selected. Dropping it fixes the highlight outright instead
   of fighting it with -webkit-text-fill-color overrides. */
.hero-title em {
  font-style: normal;
  color: var(--accent-bright);
}

.hero-subtitle {
  font-size: 15.81px;      /* 16.2 × 1.15 × 0.85 */
  color: var(--text-secondary);
  max-width: 493px;        /* 504 × 1.15 × 0.85 */
  margin: 0 auto 21.08px;  /* 21.6 × 1.15 × 0.85 */
  line-height: 1.7;
}
/* Full sentence everywhere down to the phone tier, where
   .hero-subtitle-short takes over (see the ≤768px block). */
.hero-subtitle-short { display: none; }

/* Custom text-selection highlight for the hero title + subtitle (not the
   CTA buttons below) — grey instead of the browser's default light blue,
   with the selected text turned white to stay readable against it.

   Every text-bearing element gets its own rule rather than relying on
   `.hero-title ::selection` (descendant combinator) or on ::selection
   inheriting into children — support for both is inconsistent across
   browsers, which is what made this land in some and not others. */
.hero-title::selection,
.hero-title em::selection,
.hero-title-peptides::selection,
.hero-title-subtitle::selection,
.hero-title-word::selection,
.hero-subtitle::selection {
  background: #71717a;
  color: #ffffff;
  /* Belt and braces: if any ancestor ever re-introduces a text-fill
     override, the selected glyphs still paint white. */
  -webkit-text-fill-color: #ffffff;
}

.hero-title::-moz-selection,
.hero-title em::-moz-selection,
.hero-title-peptides::-moz-selection,
.hero-title-subtitle::-moz-selection,
.hero-title-word::-moz-selection,
.hero-subtitle::-moz-selection {
  background: #71717a;
  color: #ffffff;
}

.hero-actions {
  display: flex;
  align-items: center;
  gap: 14.4px;             /* 16px − 10% */
  margin-top: 19.44px;     /* 24px − 10%, − 10% again */
}

/* Shared layout for both hero buttons so they're always the same width and
   height regardless of label length: flex:1 1 0 splits .hero-actions'
   width evenly between them (row layout on desktop), and box-sizing keeps
   .hero-btn-secondary's border from adding extra height over the
   border-less primary button.

   Base sizing here is the ORIGINAL, unreduced figures — .hero-btn is a
   shared class, not hero-only: catalog.html's "Suggest a peptide here"
   button uses it too, styled to match. Every "shrink the hero CTAs by
   N%" request in this project's history has actually meant the hero's
   own two buttons, not every button anywhere that happens to reuse this
   class, so the reductions live in the scoped .hero-actions .hero-btn
   rule below instead of here. (An earlier −10% pass DID land directly on
   this base rule, which meant that button on catalog.html was 10%
   smaller than intended too, unnoticed until this request compounded a
   further −25% on top of it and made the scoping bug worth fixing
   outright rather than compounding a third time.) */
.hero-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 1 1 0;
  height: 60px;            /* 52 × 1.15 */
  padding: 0 32px;         /* 28 × 1.15 */
  box-sizing: border-box;
  font-size: 16px;
  font-weight: 600;
  border-radius: var(--radius-md);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
}

/* The actual "shrink the hero CTAs" lever — scoped to the two buttons
   inside .hero-actions specifically, so it never touches .hero-btn
   elsewhere (catalog.html's "Suggest a peptide here"). 52px → 46.8px was
   the first reduction (−10%); a further −25% on top of that was applied
   and then undone; this is now that 46.8px shrunk again by 15%, to match
   the rest of the hero copy and the orb assembly moving together. */
.hero-actions .hero-btn {
  height: 39.78px;     /* 52px − 10%, then × 0.85 */
  padding: 0 21.42px;  /* 28px − 10%, then × 0.85 */
}

/* +20% of horizontal padding on each edge, asked for outright, and
   scoped to non-mobile because that is where it was asked for. The
   button grows by that much on the left AND the right; the label keeps
   its own size and stays on the button's midline, because .hero-btn
   centres it with justify-content rather than positioning it from an
   edge — so only the space around the text changes.

   Padding rather than width, so the pair stay flex:1 1 0 siblings
   sharing the row evenly instead of one being sized independently.

   min-width:769px rather than editing the rule above: below that the
   two buttons stack and go full-width (align-items:stretch on
   .hero-actions, ≤1024px), so horizontal padding stops setting their
   size at all — changing it there would be invisible on a good day and
   a surprise on a bad one. */
@media (min-width: 769px) {
  .hero-actions .hero-btn {
    padding: 0 25.704px; /* 21.42 × 1.2 */
  }
}

.hero-btn-primary {
  background: var(--text-primary);
  color: #fff;
  border: 1px solid var(--text-primary);
  transition: background-color var(--transition), transform var(--transition);
}

.hero-btn-primary:hover {
  background: #2f2f35;
}

.hero-btn-secondary {
  background: #fff;
  color: #000;
  border: 1px solid #000;
  transition: background-color var(--transition), transform var(--transition);
}

.hero-btn-secondary:hover {
  background: var(--bg-secondary);
}

/* Shared "Browse Catalog" CTA line — used by the Order Process section's
   closing "Ready to buy" prompt. Toggles with .cta-button at narrower
   widths (see the max-width: 1000px query below). */
/* ============================================================
   SECTION LAYOUT
   ============================================================ */
.section {
  padding: clamp(64px, 8vw, 100px) clamp(24px, 4vw, 48px);
  max-width: 1200px;
  margin: 0 auto;
}


/* ============================================================
   INFO CARDS (What's special, How to buy, CTA)
   ============================================================ */

/* Order Process steps are plain stacked content — no card chrome, one
   step's content flows straight into the next. */
.process-section {
  display: flex;
  flex-direction: column;
  gap: 28px;
  margin-bottom: 72px;
}

.process-section:last-child {
  margin-bottom: 0;
}

/* Featured products section: no heading any more (removed along with
   .process-intro — see index.html), so this only zeroes the flex gap
   that used to separate intro/grid/actions and sets the button's own
   spacing above the frame. */
.process-section:has(.featured-products) {
  gap: 0;
}

/* Distance above the frame equals var(--grid-gap) — the same spacing used
   between two stacked rows of product cards. */
.process-section:has(.featured-products) .process-actions {
  margin-top: var(--grid-gap);
}

/* The frame has to line up with the *cards*, not with the grid element —
   the grid carries var(--grid-edge) of inner padding, so matching the grid's
   outer box left the frame 2 × 12px wider than the card row. Giving
   .process-actions the grid's own width, cap and padding makes the frame's
   border land exactly on the outer card edges at every breakpoint, and both
   stay centred on the page via the shared `margin: 0 auto`. */
.process-actions {
  width: 100%;
  max-width: var(--catalog-grid-w);
  padding: 0 var(--grid-edge);
  margin: 0 auto;
  box-sizing: border-box;
}

/* Each frame follows the fallback point of the grid it sits under, and the
   two grids differ: the home page's drops to two columns at 1585px (its
   section can't fit 1489px + padding below that), while the catalog's holds
   four until 1560px because .container--catalog is capped separately. */
@media (max-width: 1585px) {
  .process-section .process-actions {
    max-width: 1120px;
  }
}

@media (max-width: 1585px) {
  .catalog-body-panel .process-actions {
    max-width: 1120px;
  }
}

/* Frames the Browse Products button to match the product cards it sits
   below: same border thickness/colour and corner radius as .product-card,
   and twice the button's 52px height, with the button centred inside. */
.process-actions-frame {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 104px;
  margin: 0 auto;
  box-sizing: border-box;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  /* The diagonal cross that used to sit behind this frame is gone — it
     read as a placeholder/"empty slot" marker rather than decoration,
     with the prompt and button sitting on top of it. */
}

.process-actions-frame .hero-btn {
  flex: 0 0 auto;
}

/* Used again at the foot of a step's copy, where .feature-text — not
   .process-actions — supplies the width. Keep it off the column flex's
   shrink path so it holds the same 104px as the one under the product
   grid, and let it span the copy rather than the whole row. */
.feature-text > .process-actions-frame {
  flex: 0 0 auto;
  align-self: stretch;
}

/* Catalog variant: a prompt sits beside the button rather than the button
   alone, so the pair wraps to two lines once the frame gets narrow. */
.process-actions-frame--wide {
  flex-wrap: wrap;
  gap: 8px 20px;
  height: auto;
  min-height: 104px;
  padding: 20px 24px;
}

.process-actions-prompt {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: var(--text-secondary);
}

/* Space above the catalog frame matches the gap between two card rows. */
.products-grid + .process-actions {
  margin-top: var(--grid-gap);
}

/* Each step pairs its copy with a supporting visual (the CoA badge, the
   shipping map). Two equal columns on desktop — which is what the map's
   label sizing already assumes — collapsing to one below 900px. */


.process-actions {
  display: flex;
  justify-content: center;
  margin-top: 12px;
}

/* .hero-btn carries flex:1 so the hero's two buttons share a row evenly;
   on its own here it should size to its label instead of stretching. */
.process-actions .hero-btn {
  flex: 0 0 auto;
}

/* When 2/3 in view: softly fade to a slightly-coloured resting state. */
/* On hover: go to full colour. Declared after .visible so it wins. */
/* Inline SVG arrow. stroke="currentColor" inherits the text colour, so it
   recolours in sync with the text (no separate hover rule, no bitmap). */


/* The card title stays centered, but the per-section titles align left.
   Higher specificity overrides the blanket `.card-2 h3` centering. */


/* ============================================================
   PRODUCT GRID — CATALOG
   Responsive: 4 cols → 2 cols (≤1200px) → 1 col (≤800px).
   Gap and window-edge padding are fixed constants.
   ============================================================ */
.products-grid {
  display: grid;
  /* Fixed card width — same size the 3-per-row layout produced
     ((1120px - 2*edge - 2*gap) / 3) — so adding a 4th column widens
     the row instead of shrinking every card to fit. */
  grid-template-columns: repeat(4, calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3));
  gap: var(--grid-gap);
  padding: 0 var(--grid-edge);
  width: var(--catalog-grid-w);
  margin: 0 auto;
}

/* The catalog's .container is normally capped at 1200px; the 4-card
   row above needs more room (~1489px) to avoid overflowing off the
   page, so it gets a wider cap above the point where the grid itself
   drops to 2 columns. */
@media (min-width: 1586px) {
  .container.container--catalog {
    max-width: 1570px;
  }
}

/* Enhanced-style steps: 4 → 2 → 1 per row */
@media (max-width: 1585px) {
  .products-grid {
    grid-template-columns: repeat(2, 1fr); /* 2 per row on tablet */
    width: 100%;
    max-width: 1120px;
  }
}

/* Same reasoning as .featured-products above: one per row is the phone
   step, not the tablet one. */
@media (max-width: 640px) {
  .products-grid {
    grid-template-columns: 1fr;            /* 1 per row on phones */
  }
}

/* ── Featured products grid (homepage) ──
   Card widths still match the catalog's 3-per-row measure, with four of
   them to a row. The tracks are a fixed width, so justify-content is what
   actually centres them inside the container — margin auto alone would
   only centre the container. */
.featured-products {
  display: grid;
  grid-template-columns: repeat(4, calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3));
  justify-content: center;
  gap: var(--grid-gap);
  padding: 0 var(--grid-edge);
  max-width: var(--catalog-grid-w);
  width: 100%;
  margin: 0 auto;
}

/* The featured-products grid's fixed column widths need exactly
   var(--catalog-grid-w) (~1489px) to render at full size — wider than
   .section's normal 1200px cap. The cap is derived from that width plus the
   section's own padding rather than hard-coded, so the content box lands on
   1489px exactly; the previous flat 1570px left only 1474px, and the fixed
   tracks (which can't shrink) spilled 15px past the frame below them.
   The breakpoint is 1586px for the same reason: below that the viewport
   itself can't fit 1489px + padding, so the grid drops to two columns. */
@media (min-width: 1586px) {
  .section:has(.featured-products) {
    max-width: calc(var(--catalog-grid-w) + 2 * 48px);
  }
}

/* Used to double the section's normal top padding for space above the
   "Most Popular" heading that sat here — that heading is gone, and with
   it the reason for extra clearance. This is the fallback for a page
   layout where the grid has no heading directly above it; on the home
   page .grid-bleed's own override further down (higher specificity, so
   it always wins there) sets the real value instead. */
.section:has(.featured-products) {
  padding-top: clamp(24px, 4vw, 48px);
}

/* Below the width the fixed catalog-sized tracks need, the row stays four
   across but the columns become flexible so they shrink to fit rather than
   dropping to two — four per row is the point of this grid, and a laptop
   viewport should still get it. */
@media (max-width: 1585px) {
  .featured-products {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    width: 100%;
    max-width: 1120px;
  }
}

@media (max-width: 1024px) {
  .featured-products {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* One per row is a PHONE layout, not a tablet one — at 900px a single
   card runs about 800px wide, which is a hero image pretending to be a
   product tile. Two columns hold their shape down to 640px (~300px a
   card); below that they genuinely stop fitting. */
@media (max-width: 640px) {
  .featured-products {
    grid-template-columns: 1fr;
  }
}

/* ── Product card ──
   White card with a soft per-product colour wash confined to the image
   area. The wash colour comes from --prod (an "r,g,b" triple set per card
   by the .img-* classes in catalog.html). The card lifts on hover and the
   bottle scales gently. */
.product-card {
  --prod: 120, 120, 128;            /* fallback; overridden per card */
  position: relative;
  background: var(--bg-primary);
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;                 /* clip image corners to the rounded card */
  min-width: 0;                     /* allow the card to shrink with the grid track */
}
/* Search/sort on the catalog and lab-results grids now reuses existing
   card elements instead of rebuilding them (see js/catalog.js and
   js/certifications.js) — a card a search narrows out is hidden rather
   than destroyed, so its own display:flex above needs an explicit
   override here or the plain `hidden` attribute (a browser default,
   which author rules always beat) would do nothing. */
.product-card[hidden] { display: none; }

/* ── Image / header area ──
   Clean white background with a faint top sheen for depth. Square,
   tied to whatever width the grid gives the card — not a fixed height —
   so it stays square as the grid/columns resize. */
.card-img-wrap {
  aspect-ratio: 1 / 1;
  width: 100%;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-primary);
  margin-bottom: 0;
}

.card-img-wrap::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, transparent 38%);
  pointer-events: none;
}

.card-img-wrap figure {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  margin: 0;
  /* 27.5% each side: with a 1:2 (w:h) bottle photo filling the full height,
     its rendered width works out to a bit under 50% of the container — so
     this padding is close to half the image's width per side, leaving a
     bit more breathing room now that the image itself is ~10% smaller.
     Percentage padding resolves against the container's width, so this
     scales automatically with the grid — no breakpoint overrides needed.
     Top/bottom padding is symmetric; the image is nudged down separately
     (see .card-img-wrap img) to trim the whitespace below it specifically.

     5px, not 10px: halved along with the image's own height/translateY
     below so the rendered gap above the bottle (to the frame's top edge)
     and below it (to the title) both come out at exactly half their old
     measured pixel values, not just half this one input. */
  padding: 5px 27.5% 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Brightness lives here, not on the img: figure has a stable 100%×100% box
     and brightness() has no spatial component, so the jar never re-snaps.
     translateZ(0) forces a permanent compositing layer (an identity
     brightness(1) gets optimized away and wouldn't); the jar is snapped to the
     pixel grid once at load and stays, so hover only animates the value. */
  transform: translateZ(0);
  transition: filter 0.2s ease;
}

/* Product bottle photo */
.card-img-wrap img {
  width: auto;
  /* 88.587%, up from 76.5% — with the figure's own padding halved above,
     this is the height that makes both the measured gap ABOVE the bottle
     (to the frame's top edge) and the gap BELOW it (to the title) come
     out to exactly half their previous rendered pixel values, at the
     same top:bottom ratio as before (solved against real
     getBoundingClientRect() measurements on a rendered card, then
     verified — not estimated). */
  height: 88.587%;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  /* 2.096%, down from 5% — same nudge-the-bottle-down mechanism, scaled
     down to match the smaller whitespace band it's now working with (see
     the height comment above). */
  transform: translateY(2.096%);
}


/* ── Card body ── */
.card-top {
  padding: 0 20px 0;
}

.card-body {
  padding: 0 20px 20px;
  display: flex;
  flex-direction: column;
  flex: 1;
}

/* ── Grid item titles ──
   Used for a product's name wherever it appears in a grid — the home
   grid, the catalog grid and the lab-certificate grid all render this
   same class (js/ui.js and js/certifications.js).

   ── The face is .pd-name's; the WEIGHT is not ──
   The typeface moved here from Inter to the Helvetica stack the product
   page's own title uses, so a name reads the same in the catalog as it
   does on the product page. The weight came with it at first — 900 plus
   .pd-name's 0.035em stroke — and that was too much at this size:
   .pd-name is 27–39.6px where the heft is a display treatment, and a
   grid of 22px names all set that dark reads as a page of shouting
   rather than a list.

   Back to 700, no stroke, which is the thickness these carried before.
   Same face, ordinary weight. The tracking stays at -0.03em (it was
   -0.44px, which is -0.02em at 22px) because it belongs to the face
   rather than to the weight. */
.card-name {
  font-family: var(--font-sans);
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.03em;
  text-transform: uppercase;
  /* Half the previous leading (was 1.2) so wrapped names sit closer together. */
  line-height: 1.1;
  margin-bottom: 1px;
  color: #000;
  transition: color 0.2s ease;
}

.card-meta {
  display: flex;
  justify-content: space-between;
  font-family: 'Helvetica', 'Helvetica Neue', var(--font-sans);
  font-size: 21.6px;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.2;
  /* --text-tertiary (#9a9da3) mixed halfway to black. Not the token
     itself, and not --text-secondary either: the token is right for the
     eyebrow labels it was made for, and this row is a price — the one
     number on the card a shopper is actually reading. It needed the
     weight without becoming the near-black the product name already
     owns. */
  color: #4d4f52;
  transition: color 0.2s ease;
}

.card-meta-size {
  flex: 0 1 auto;
}

.card-meta-price {
  flex: 0 0 auto;
  text-align: right;
}

.card-hover-zone {
  cursor: pointer;
  /* Extend the hoverable area ~8px past the name/price row (half the 16px
     gap to the size selector) so the trigger zone doesn't end abruptly
     right at the text — the negative margin cancels the added space so
     layout doesn't shift. */
  padding-bottom: 8px;
  margin-bottom: -8px;
}

/* One hover zone wraps the image and the name/price — hovering anywhere
   in it (image or text) darkens the image and blackens the text together. */
.card-hover-zone:hover .card-img-wrap figure {
  filter: brightness(0.88);
}

.card-hover-zone:hover .card-name,
.card-hover-zone:hover .card-meta {
  color: #000;
}

/* Add-to-cart. Also used, unchanged, as the cert grid's "View Tests"
   link (js/certifications.js) — text-decoration is only for that anchor
   use, a no-op on the <button> this was written for. */
.btn-cart {
  width: 100%;
  height: 36px;
  min-height: 36px;
  padding: 0 16px;
  border: none;
  border-radius: var(--radius-md);
  background: var(--cta);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
  display: flex;
  align-items: center;
  justify-content: center;
}

@media (hover: hover) {
  /* A touch lighter than the #111114 base — enough to register as
     "this responds" without turning the button grey. Same lift the
     hero's primary button uses (#2f2f35), kept a shade darker here
     because this one is small and repeated across a grid, where a
     bigger jump reads as the whole row flickering. */
  .btn-cart:hover {
    background: #2a2a30;
  }
}

.btn-cart:active {
  transform: translateY(0);
}


.tests-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
}


.test-date {
  font-size: 13px;
  color: var(--text-muted);
  margin-bottom: 12px;
}


/* ============================================================
   FOOTER
   ============================================================ */
.footer {
  /* Black, not a quiet tint — the footer is the one place on the page
     dark enough that everything inside it needs its own light-on-dark
     colour, rather than inheriting the page's own text tokens. */
  background: var(--text-primary);
  padding: 56px 48px 36px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* .footer-brand-row: the same "full width, capped at 1200px, centred"
   box .footer-top and .footer-bottom use — not so the ROW is that wide
   (it holds one small logo), but so the row's own LEFT EDGE lands at the
   same x those two do. A plain flex child of .footer would otherwise be
   centred by .footer's own align-items:center, which is right for a
   lone wordmark but wrong once it has to line up with a description
   sitting below it. */
.footer-brand-row {
  align-self: stretch;
  width: 100%;
  /* Same measure as the product grid (--catalog-grid-w, ~1489px), not a
     separate 1200px cap — matched here and on .footer-top/.footer-bottom
     below so the footer reads as one row the same width as four product
     cards, not a narrower block of its own. */
  max-width: var(--catalog-grid-w);
  /* 18px: unchanged from when this gap was the logo's own margin-bottom
     inside .footer-brand-col, above the description. Keeping the number
     is what keeps the two the same visual distance apart now that a
     sibling row carries it instead. */
  margin: 0 auto 18px;
}

/* The wordmark itself — capped at 300px, well short of the row above,
   so width:100% (the row's width) never applies and max-width does. A
   block element defaults to its content's own start edge, which is what
   puts the logo at the row's left rather than centred inside it. */
.footer-brand {
  display: block;
  width: 100%;
  max-width: 300px;
}

.footer-brand-img {
  width: 100%;
  height: auto;
  display: block;
  /* The asset is solid black on transparent — invisible on the footer's
     own black ground without this. The mark is pure black-and-white, so a
     full invert is the whole fix, not an approximation of one. */
  filter: invert(1);
}

/* ── Footer link columns ──
   The wordmark keeps its own column on the left with the identity block
   under it; the three link columns sit beside it and collapse to a
   two-then-one column stack on narrow screens.

   The brand image is capped much smaller here than it used to be: it was
   min(78vw, 1100px), which is a wordmark with nothing beside it. Now that
   there are three columns of links to share the row with, it has to
   behave like a column. */
.footer-top {
  align-self: stretch;
  /* Matches the product grid's own width (--catalog-grid-w), not a
     1200px cap of its own — see the note on .footer-brand-row above.
     The description's own text is capped separately (.footer-blurb's
     42ch), so the extra room this opens up over the old 1200px becomes
     dead space between it and the first sitemap column rather than
     stretching the blurb itself — which is the "more space between the
     description and the sitemap" the row width change was for. */
  max-width: var(--catalog-grid-w);
  width: 100%;
  margin: 0 auto 8px;
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) repeat(3, minmax(0, 1fr));
  /* Widened from 40px now that all three link columns are right-aligned
     — their text hugs each column's own right edge, which puts it
     closer to the NEXT column's left edge than when it sat flush-left,
     so the gap needed to grow to keep the columns reading as separate
     groups rather than crowding together. */
  gap: 56px;
  align-items: start;
}
.footer-brand-col { min-width: 0; }

.footer-blurb {
  margin: 0 0 14px;
  max-width: 42ch;
  font-size: 13.5px;
  line-height: 1.65;
  color: rgba(255, 255, 255, 0.65);
}
.footer-mail {
  display: inline-block;
  font-size: 13.5px;
  font-weight: 600;
  color: #fff;
  text-decoration: none;
  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
  padding-bottom: 1px;
  transition: border-color var(--transition);
}
.footer-mail:hover { border-color: #fff; }

.footer-col { min-width: 0; }

/* Every column's own TRACK always reaches its own right edge — the fr
   tracks share out the row's full width regardless of how much a given
   column's text actually needs, so there was never dead space to trim,
   only text sitting flush-LEFT within a box that ends further right.
   Right-aligning all three puts each column's own rightmost pixel on
   its own track's right edge, which is also what lines the last one up
   with .footer-copy below it. */
.footer-col { text-align: right; }

.footer-col-title {
  margin: 0 0 14px;
  font-size: 11.5px; font-weight: 700;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: rgba(255, 255, 255, 0.45);
}
.footer-links { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 11px; }
.footer-links a {
  font-size: 13.5px; font-weight: 500;
  color: rgba(255, 255, 255, 0.65); text-decoration: none;
  transition: color var(--transition);
}
.footer-links a:hover { color: #fff; }

@media (max-width: 1024px) {
  /* The wordmark takes the full first row rather than being squeezed to a
     quarter of it, and the link columns share the row beneath. */
  .footer-top { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 28px 24px; }
  .footer-brand-col { grid-column: 1 / -1; }
}
@media (max-width: 640px) {
  .footer-top { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.footer-bottom {
  /* .footer is now itself a flex column, and a flex item's auto side-
     margins (from the centring margin:0 auto below) suppress
     align-self:stretch per the flexbox spec — without an explicit width
     this shrank to fit its own short content (the legal line + the
     copyright span) instead of the full row, which is what left it
     narrower than .footer-top and starting well right of the brand
     column. width:100% is what .footer-top already does for the same
     reason; matching it here is what makes the two rows share one true
     left/right edge instead of two different ones that happened to
     overlap by coincidence.

     max-width matches .footer-top/.footer-brand-row (--catalog-grid-w,
     the product grid's own width) rather than a 1200px cap of its own —
     .footer-legal is capped separately at 600px, so the wider row simply
     pushes .footer-copy (space-between's other end) further right,
     which is the "more space between the disclaimer and the company
     info" the row width change was for. */
  align-self: stretch;
  width: 100%;
  max-width: var(--catalog-grid-w);
  margin: 0 auto;
  display: flex;
  /* Bottom, not centre: .footer-legal can wrap to two lines while
     .footer-copy is always one, and centring the two on their combined
     height left the copyright line visibly floating above the
     disclaimer's own baseline rather than sitting level with it. */
  align-items: flex-end;
  justify-content: space-between;
  /* No rule between this and .footer-top any more (see the removed
     ::before below) — 40px, matching .footer-top's own grid gap, is what
     now carries the "these are two groups" signal on its own, on top of
     .footer-top's existing 8px margin-bottom. */
  padding-top: 40px;
  gap: 24px;
  flex-wrap: wrap;
}

.footer-legal {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.45);
  line-height: 1.6;
  max-width: 600px;
}

.footer-copy {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.45);
  white-space: nowrap;
}

/* ============================================================
   RESPONSIVE — catch-all
   ============================================================ */
@media (max-width: 1024px) {
  /* nav, .section and .catalog-hero now use fluid clamp() padding, so they
     no longer jump here — only true layout reflows remain in this block. */

}

@media (max-width: 1024px) {
  .hero { padding: 168px 48px 40px; }
  .hero-subtitle { font-size: 15.3px; }   /* 17px − 10%, matching the desktop reduction */

  .footer       { padding: 48px 24px 32px; }
  .footer-bottom { flex-direction: column; align-items: flex-start; }
}

@keyframes scaleIn {
  from {
    transform: scale(0.95);
  }
  to {
    transform: scale(1);
  }
}

/* ── Delivery times ──
   The seven destinations and how long each takes, under the Delivery copy
   in the order-process card. Was the fallback for an animated SVG map
   that has since been removed; it is now the only presentation. */

/* Rows 1-4 fill the left column, 5-7 the right. The rule goes under every
   row except the last of each column — 4 and 7 — since the container's
   own border closes those off. */
/* The divider between the two columns. */

/* One column below the breakpoint — two would leave each side too narrow
   for "Sydney + ACT" and "3-4 days" to sit on one line. */

@media (max-width: 1024px) {

  /* Reorder hero: mosaic above, content below. */
  .hero {
    flex-direction: column-reverse;
    align-items: stretch;
    height: auto;
    padding: 48px 24px;
  }

  /* Drop the fixed desktop width now that the text is centred, not
     pinned to a left edge. */
  .hero-content {
    width: auto;
    max-width: 100%;
  }

  /* Keep the whole ring+orb group at its native size (so the tile grid/
     offsets and the text ring's path stay coherent) and scale it down as
     one unit — the negative bottom margin reclaims the empty box space
     the scale leaves behind. */
  .hero-orb-wrap {
    display: block !important;
    align-self: center;
    /* Native size, then scaled down as one unit — the tile offsets and
       the ring's path stay coherent that way, and the negative bottom
       margin reclaims the empty box space the scale leaves behind.

       Set through --hero-orb-size, NOT width/height directly: the backing
       disc and the cascade are both derived from that variable, and
       overriding only the box left them sized for the desktop value. */
    /* Tracks the desktop value. This was left at 644px when the desktop
       assembly was cut by 15% to 547.4px, so the phone was showing an orb
       proportionally LARGER than the one it is meant to mirror — the
       reason it crowded the headline below it.

       The negative bottom margin reclaims the empty box the scale leaves
       behind, so it has to move with the size: 547.4 x 0.52 leaves ~263px
       of real height in a 547.4px box, and -204px is the same fraction of
       the new size that -240px was of the old. */
    --hero-orb-size: 547.4px;
    width: var(--hero-orb-size);
    height: var(--hero-orb-size);
    transform: scale(0.52);
    transform-origin: top center;
    margin: 4lvh 0 -204px 0;
  }

  .hero-actions {
    flex-direction: column;
    align-items: stretch;
    width: 100%;
  }

  /* In row layout, flex:1 1 0 (from .hero-btn) splits width evenly; in this
     stacked column layout that would instead split height, which isn't
     wanted — each button should just take its own height, full width via
     align-items:stretch above. */
  .hero-actions .hero-btn {
    flex: none;
  }

}

/* ============================================================
   SHARED PAGE SCAFFOLDING
   Pages other than the home hero sit inside .page (clears the
   fixed nav) and an inner .container for max width.
   ============================================================ */
.page {
  /* Clears the fixed nav with room to spare, plus the standing-offer
     strip the nav itself now sits below. */
  padding-top: calc(112px + var(--promo-h));
  padding-bottom: 96px;
  /* lvh: this only ever sets a floor for a short page, which is a
     question about how much blank space sits above the footer — a
     below-the-fold concern, so the large viewport is the honest one. */
  min-height: 100vh;
  min-height: 100lvh;
}


.catalog-body-panel {
  margin-top: 120px;
  padding-top: 32px;
  padding-bottom: 40px;
}

.container {
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 40px;
}
.page-head { margin-bottom: 32px; }
/* Admin header: title and the storefront power pill on one line. The page
   as a whole clears the fixed nav with 276px of top padding; the admin
   panel is a working tool rather than a landing page, so it halves that
   — the pill sitting inline with the title is what fills the space the
   old eyebrow + banner used to occupy. */
.page-head--admin {
  display: flex; align-items: center; justify-content: space-between;
  gap: 20px; flex-wrap: wrap;
}
.page-eyebrow {
  display: block;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 12px;
}
.page-title {
  font-size: clamp(34px, 5vw, 52px);
  font-weight: 800;
  letter-spacing: -0.03em;
  line-height: 1.05;
  margin: 0 0 14px;
}
.page-sub {
  color: var(--text-secondary);
  font-size: 17px;
  line-height: 1.7;
  max-width: 640px;
}

/* ============================================================
   CATALOG HERO
   A 2/3-viewport-height banner above the toolbar/grid. Content
   is bottom-left anchored via flex + the shared container padding
   so it lines up with the grid below.
   ============================================================ */
.catalog-hero {
  position: relative;
  display: flex;
  align-items: flex-end;
  min-height: auto;
  overflow: visible;
  isolation: isolate;
  padding: 16px 0;
  background: none;
}

/* ── Page header band ──
   Catalog, Research, Lab Results and FAQ. A tall band from the bottom of
   the nav down to a little under the title, so an interior page opens
   with room rather than with a title jammed against the bar.

   It carried a square-grid background at one point; that is gone and the
   band is plain now. The class name and the region it defines stay,
   because the search/sort bar below it is positioned to start exactly
   where this ends. */
.catalog-hero--grid {
  /* 4× the original 16px, then doubled again — this is the whole gap
     between the nav's lower edge and the title. */
  padding-top: 128px;
  /* Halved from 28px — the gap between the title and the end of the band
     was reading as a second, unexplained margin. */
  padding-bottom: 14px;
}

/* ── Hero background effects (back to front) ──
   1. Diagonal repeated wordmark, white-on-grey, the very back layer.
   2. Rainbow spotlights that slowly cycle colour via hue-rotate.
   3. Two thick wavy lines drifting across the banner at different speeds. */
.hero-fx {
  position: absolute;
  inset: 0;
  bottom: -50%;
  z-index: 0;
  overflow: visible;
  /* Purely decorative (empty now that the gradient/waves are hidden) —
     must never intercept clicks on content below the hero, since it
     overflows the hero's own box by design. */
  pointer-events: none;
}

.hero-fx-glow {
  display: none;
}

@keyframes heroHueShift {
  from { filter: blur(60px) hue-rotate(0deg); }
  to   { filter: blur(60px) hue-rotate(360deg); }
}

/* A true sine-shaped wave (proper symmetric cubic-bezier humps, not the
   old slanted curve), amplitude varied a little per half-wave for
   texture. The wave's outline itself is static — it's used only as a
   mask. What actually moves is a ROYGBIV gradient painted underneath
   that mask, sliding left-to-right, so the colour pulses through a
   wave shape that never itself shifts position. */
.hero-fx-wave {
  position: absolute;
  left: -10%;
  width: 120%;
  height: 140px;
  -webkit-mask-repeat: repeat-x;
  mask-repeat: repeat-x;
  -webkit-mask-size: 600px 140px;
  mask-size: 600px 140px;
  background-image: linear-gradient(90deg,
    #ff3b30, #ff9500, #ffe600, #34c759, #30a2ff, #3d5afe, #af52de, #ff3b30);
  background-repeat: repeat-x;
  background-size: 600px 140px;
}

.hero-fx-wave--1 {
  display: none;
}

.hero-fx-wave--2 {
  display: none;
}

@keyframes heroRainbowFlow {
  from { background-position-x: 0; }
  to   { background-position-x: 600px; }
}

.catalog-hero-inner {
  position: relative;
  z-index: 1;
  padding-bottom: 0;
}

.catalog-hero-title {
  font-family: 'Inter', sans-serif;
  font-weight: 700;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  color: var(--text-primary);
  font-size: clamp(25.7px, 4.82vw, 68.5px);   /* 15% smaller than clamp(30.24px, 5.67vw, 80.64px) */
  line-height: 1;
  margin: 0 0 16px;
  position: relative;
  z-index: 1;
  /* .products-grid nests its own --grid-edge padding inside .container,
     on top of the container's own padding — match that same extra inset
     here (title/desc are likewise nested inside .container) so they
     align with the grid's first card, not just the container edge. */
  padding-left: var(--grid-edge);
}

/* Soft radial glow behind the text instead of a solid panel — same
   technique as the homepage hero (.hero-title::before). */
/* .catalog-hero-title::before is gone. It was a decorative plate behind
   the page title, and it had already been switched off — `background:
   none`, nothing drawn. What it kept was `inset: -24px -32px`, which is
   32px of box sticking out past each side of the title, invisible and
   still counted as scrollable overflow.

   That was the sideways scroll. Every interior page carries this title,
   so every one of them reported a document 8px wider than the window
   (title right edge 351 + 32 = 383 against a 375 viewport) — enough for
   a trackpad to drift the page a few pixels while scrolling down, and
   for a phone to rubber-band. Deleted rather than clipped: an element
   that paints nothing should not be in the layout at all. */

@media (max-width: 1024px) {
  .catalog-hero {
    min-height: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero-fx-glow,
  .hero-fx-wave {
    animation: none;
  }
}

.breadcrumb {
  font-size: 14px;
  color: var(--text-muted);
  margin-bottom: 20px;
}
.breadcrumb a { color: var(--text-secondary); }
.breadcrumb a:hover { color: var(--accent-bright); }

.btn-ghost {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 12px 20px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: border-color var(--transition), background var(--transition);
}
.btn-ghost:hover { border-color: var(--border-accent); background: var(--bg-secondary); }

.empty-state {
  text-align: center;
  padding: 72px 32px;
  color: var(--text-secondary);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  background: var(--bg-primary);
  box-shadow: var(--shadow-sm);
}
.empty-state h2 { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; color: var(--text-primary); margin-bottom: 10px; }
.empty-state p { max-width: 420px; margin-left: auto; margin-right: auto; }

/* ============================================================
   SHARED QTY STEPPER
   Unified segmented control used by the cart and product pages
   (markup: <div class="qty"><button>−</button><input><button>+</button></div>).
   ============================================================ */
.qty {
  display: inline-grid;
  grid-template-columns: 38px 48px 38px;
  align-items: stretch;
  height: 40px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
  background: var(--bg-primary);
}
.qty button {
  border: none;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-size: 17px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background var(--transition), color var(--transition);
}
/* Light fill with dark glyph — the inverse of the former near-black fill,
   and the same hover language as the cart / product-card steppers
   (.qty-minus / .qty-plus). */
.qty button:hover { background: var(--bg-secondary); color: var(--text-primary); }
.qty button:active { transform: none; }
.qty input {
  border: none;
  border-left: 1px solid var(--border-soft);
  border-right: 1px solid var(--border-soft);
  text-align: center;
  background: var(--bg-primary);
  font-family: var(--font-sans);
  font-size: 15px;
  font-weight: 700;
  color: var(--text-primary);
  -moz-appearance: textfield;
  padding: 0;
}
.qty input:focus { outline: none; }
.qty input::-webkit-outer-spin-button,
.qty input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* Out-of-stock state — greyed out to match .btn-cart[disabled] below it. */
.qty--disabled,
.qty--disabled button,
.qty--disabled input {
  background: var(--bg-card-hover);
  color: var(--text-muted);
  cursor: not-allowed;
}
.qty--disabled button:hover { background: var(--bg-card-hover); color: var(--text-muted); }

/* ============================================================
   AUTH MODAL
   ============================================================ */
.auth-overlay {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  /* No backdrop-filter. A blurring overlay makes the browser snapshot and
     re-composite everything behind it on every paint, and any element
     underneath that has its own compositing layer — the nav has its own
     backdrop-filter, cards have box-shadows and hover transitions — can be
     sampled into that snapshot late or not at all. What that looked like
     was pale rectangles the exact size of those elements appearing over
     the dimmed page, coming and going as the pointer moved.

     A flat rgba dim composites normally and cannot produce them. The alpha
     below is raised to carry the separation the blur used to. */
  background: rgba(10, 10, 12, 0.62);
}
.auth-overlay[hidden] { display: none; }
.auth-modal {
  position: relative;
  width: 100%;
  max-width: 420px;
  background: var(--bg-primary);
  border-radius: var(--radius-lg);
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
  padding: 32px;
}
/* The Pure Amino mark at the top of the dialog, matching the age gate's
   (.age-gate-logo) in size and placement — the two full-screen dialogs
   on the site introduce themselves the same way. */
.auth-logo {
  height: 38px;
  width: auto;
  margin: 0 auto 22px;
  display: block;
}

/* A drawn glyph in a round hit area, not a bare "×" character. The
   character version was 26px of text whose optical centre sat wherever
   the font put it, and it had no box of its own — so next to a centred
   38px logo it read as debris in the corner rather than as a control.
   36px round, vertically centred on the logo, with an SVG cross that is
   symmetric by construction. */
.auth-close {
  position: absolute;
  /* Vertically centred on the logo beside it, not on the modal's corner.
     The modal pads 32px, the logo is 38px tall and this button is 32px,
     so half the difference (3px) puts the two on one line. Sitting 13px
     high of it, as a corner offset does, read as an afterthought stuck in
     the corner rather than as part of the header row. */
  top: calc(32px + 3px);
  right: 22px;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: none;
  border: 1px solid transparent;
  border-radius: 50%;
  color: var(--text-muted);
  cursor: pointer;
  transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.auth-close svg { width: 15px; height: 15px; display: block; }
.auth-close:hover {
  color: var(--text-primary);
  background: var(--bg-secondary);
  border-color: var(--border);
}
.auth-close:focus-visible {
  outline: 2px solid var(--text-primary);
  outline-offset: 2px;
}

/* ── Continue with Google ─────────────────────────────────────
   Above the email form in the sign-in modal, and hidden entirely unless
   /api/auth/me says the provider is configured (js/nav.js).

   Deliberately NOT styled like the site's own black primary button: this
   hands the customer to a third party, and Google's branding rules ask
   for their mark on a plain white or dark surface. Looking slightly
   apart from everything around it is correct here, the same way the
   preview card in index.html does. */
.auth-google {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  box-sizing: border-box;
  padding: 12px 16px;
  border: 1px solid var(--border-accent);
  border-radius: var(--radius-md);
  background: #ffffff;
  color: #1f1f1f;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.auth-google:hover { background: var(--bg-secondary); border-color: var(--text-primary); }
.auth-google[hidden] { display: none; }
.auth-google svg { width: 18px; height: 18px; flex: 0 0 auto; }

/* "or" on a rule. The line is drawn by the flex children either side of
   the word rather than as a background, so it can never sit behind the
   text at a width the padding did not anticipate. */
.auth-or {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 16px 0;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.auth-or::before,
.auth-or::after {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background: var(--border);
}
.auth-or[hidden] { display: none; }

.auth-tabs {
  display: flex;
  gap: 6px;
  background: var(--bg-secondary);
  border-radius: var(--radius-md);
  padding: 4px;
  /* The logo above now owns the top of the dialog, so this no longer has
     to clear the close button — the button sits beside the logo, not over
     this row. */
  margin-bottom: 24px;
}
.auth-tabs[hidden] { display: none; }
.auth-tab {
  flex: 1;
  padding: 10px;
  border: none;
  border-radius: 6px;
  background: transparent;
  font-size: 14px;
  font-weight: 600;
  color: var(--text-secondary);
  cursor: pointer;
}
.auth-tab.active { background: var(--bg-primary); color: var(--text-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.auth-heading { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; margin-bottom: 20px; }
.auth-field { display: block; margin-bottom: 16px; }
.auth-field[hidden] { display: none; }
.auth-field > span {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-secondary);
  margin-bottom: 6px;
}
.field-input,
.auth-field input,
.auth-field textarea,
.search-field input,
.select-native {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-primary);
  font-family: var(--font-sans);
  font-size: 15px;
  color: var(--text-primary);
  transition: border-color var(--transition), box-shadow var(--transition);
}
/* Only the field type the contact modal introduces — everything else on
   the site uses a single-line input, so this stays scoped rather than
   joining the shared block above. */
.auth-field textarea {
  resize: vertical;
  min-height: 90px;
  font-family: inherit;
}
.field-input:hover,
.auth-field input:hover,
.auth-field textarea:hover,
.search-field input:hover,
.select-native:hover { border-color: var(--border-accent); }
.field-input:focus,
.auth-field input:focus,
.auth-field textarea:focus,
.search-field input:focus,
.select-native:focus {
  outline: none;
  border-color: var(--accent-bright);
  box-shadow: 0 0 0 3px rgba(10, 10, 10, 0.08);
}
.field-input::placeholder,
.search-field input::placeholder { color: var(--text-muted); }
.auth-hint { color: var(--text-muted); font-size: 12px; margin: -10px 0 14px; }
.auth-hint[hidden] { display: none; }
.auth-error { color: #b42318; font-size: 13px; margin-bottom: 14px; }
.auth-error.auth-note { color: var(--text-secondary); }
.auth-submit { margin-top: 4px; }
.auth-forgot {
  display: block;
  width: 100%;
  margin-top: 14px;
  background: none; border: none;
  color: var(--text-secondary);
  font-size: 13px;
  cursor: pointer;
}
.auth-forgot:hover { color: var(--accent-bright); text-decoration: underline; }
.auth-forgot[hidden] { display: none; }

/* ── Verification code field ──
   A plain field, deliberately. This was a row of six boxes with a
   transparent input over them — it looked the part, but the codes
   Supabase actually sends are 8 digits, and a box-per-digit row only
   reads as an instruction when the count matches. Monospaced and widely
   tracked so an 8-digit string stays scannable while it is typed. */
.auth-code-input {
  font-family: Menlo, Consolas, monospace;
  font-size: 20px;
  font-weight: 700;
  letter-spacing: 0.22em;
  text-align: center;
}
.auth-code-input::placeholder {
  color: var(--border);
  letter-spacing: 0.22em;
}

/* ============================================================
   PRODUCT TAGS
   ============================================================ */
.tag {
  display: inline-flex;
  align-items: center;
  padding: 3px 9px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

.tag--preorder { background: #fdf3e6; color: #a06a12; }

/* ============================================================
   CATALOG TOOLBAR (search + sort)
   ============================================================ */
/* ── Page toolbar band ──
   A full-width dark band directly under the page header, holding whatever
   controls that page has. Catalog, Research and Lab Results all use it.

   It starts exactly where the header band ends: both are siblings in
   normal flow with no margin between them, so "where one stops" is
   literally where the other starts — nothing is positioned to match.

   Full-bleed out of the container by the usual 100vw-from-its-own-centre
   trick, while the controls inside stay on the content grid's own width
   so the search field's left edge lines up with the first card's. */
.page-toolbar {
  position: relative;
  left: 50%;
  width: 100vw;
  margin-left: -50vw;
  margin-bottom: clamp(30px, 4vw, 52px);
  /* Halved. The controls were floating in the middle of a band far taller
     than they needed. */
  padding-block: clamp(9px, 1.1vw, 14px);

  background: #23232a;
}
/* The controls line up with the page TITLE, not with the product grid.
   The title sits in .container--catalog, so the inner box borrows that
   container's own width and padding — anything else and the search field
   started a few pixels off the H1 above it, which is exactly the sort of
   near-miss that reads as a mistake. */
/* The controls line up with the GRID BELOW them, not with the page title.
   They used to borrow .container--catalog's 1200/1570px box and 40px
   padding, which is the title's measure — so the search field's left edge
   landed short of the first product card's, and its right edge short of
   the last card's. The grid is .products-grid: var(--catalog-grid-w) wide,
   centred, with var(--grid-edge) of its own padding before the first card.
   Matching all three is what makes the field span the results exactly. */
.page-toolbar-inner {
  width: min(100%, var(--catalog-grid-w));
  margin-inline: auto;
  padding-inline: var(--grid-edge);
  box-sizing: border-box;
}
/* Below 1561px the grid drops to two columns and caps at 1120px (see the
   .products-grid media query), so the toolbar has to follow it there or
   it goes back to overhanging the cards. */
@media (max-width: 1585px) {
  .page-toolbar-inner { width: min(100%, 1120px); }
}
/* On the catalog the row is search + sort; elsewhere it is a lone search
   field, which should not stretch to the full width of the page. */
/* The row fills the toolbar's full measure: the search field takes
   whatever the sort control does not, so the pair spans exactly the width
   of the grid below. The old max-width:560px cap is what stopped it. */
.page-toolbar .search-field { flex: 1 1 auto; max-width: none; min-width: 0; }
.page-toolbar .sort-field { flex: 0 0 auto; }
.page-toolbar .catalog-toolbar { justify-content: flex-start; width: 100%; }

/* The controls are on a dark ground now, so the field styling that works
   on white does not carry over — these are the dark-band versions. */
.page-toolbar .search-field input,
.page-toolbar .select-native {
  background: rgba(255, 255, 255, 0.06);
  border-color: rgba(255, 255, 255, 0.16);
  color: #fff;
}
.page-toolbar .search-field input::placeholder { color: rgba(255, 255, 255, 0.5); }
.page-toolbar .search-field input:hover,
.page-toolbar .select-native:hover { border-color: rgba(255, 255, 255, 0.34); }
.page-toolbar .search-field input:focus,
.page-toolbar .select-native:focus {
  border-color: #fff;
  box-shadow: none;
  outline: none;
}
.page-toolbar .search-field svg { color: rgba(255, 255, 255, 0.55); }
.page-toolbar .sort-field label { color: rgba(255, 255, 255, 0.7); }
/* The native dropdown's own list is drawn by the OS and cannot be styled,
   so its options need a colour that is legible against it. */
.page-toolbar .select-native option { color: var(--text-primary); background: #fff; }

.catalog-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  align-items: center;
  /* The band supplies the spacing now — this used to carry its own
     bottom margin and its own width cap. */
  margin: 0;
  padding: 0;
  max-width: none;
}
/* Match the toolbar's width (and its own edge padding) to the grid's
   so the search field's left edge lines up with the first card's. */
@media (min-width: 1586px) {
  .catalog-toolbar {
    width: var(--catalog-grid-w);
    max-width: none;
  }
}
.search-field {
  position: relative;
  flex: 1 1 320px;
}
.search-field svg {
  position: absolute;
  left: 14px; top: 50%;
  transform: translateY(-50%);
  width: 18px; height: 18px;
  color: var(--text-muted);
  pointer-events: none;
}
.search-field input {
  width: 100%;
  padding: 13px 16px 13px 42px;
}
.sort-field { display: flex; align-items: center; gap: 10px; }
.sort-field label { font-size: 13px; font-weight: 600; color: var(--text-secondary); white-space: nowrap; }
.select-native { width: auto; min-width: 200px; padding-right: 34px; cursor: pointer;
  appearance: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a8a93' stroke-width='3'><polyline points='6 9 12 15 18 9'/></svg>");
  background-repeat: no-repeat;
  background-position: right 12px center;
}
.result-count { display: none; }

/* ============================================================
   PRODUCT CARD — catalog controls
   (extends the existing .product-card / .card-body rules)
   ============================================================ */
/* Fixed height reserves room for a name that wraps to 2 lines (the
   longest product names, e.g. "cjc-1295 (no dac) + ipamorelin") plus
   one line of size/price meta below it. Because the box has a fixed
   height (not min-height) and its content is bottom-anchored, a
   short one-line name just leaves blank space above it, while a
   two-line name fills that same reserved space — the meta line's
   position never moves, and nothing below the header shifts down. */
.card-header {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  height: 82px;
  margin-bottom: 8px;
  padding-bottom: 4px;
}

/* Registered so the browser can smoothly interpolate them (rather than
   snapping instantly) as the glow moves between zones below. Unsupported
   browsers just lose the easing between zones, not the effect itself. */
@property --glow-a {
  syntax: '<number>';
  inherits: false;
  initial-value: 0.35;
}
@property --glow-r {
  syntax: '<length>';
  inherits: false;
  initial-value: 42px;
}

/* Quantity selector — full-width iterator style
   The border "glows" darker wherever the mouse currently is (tracked via
   --mx/--my, set by a delegated mousemove listener in ui.js) and fades
   back to the plain grey border with distance — never a hard edge.
   It stays subtle over the middle (the number isn't clickable) and gets
   both wider and noticeably darker over the +/- buttons (--glow-a/--glow-r,
   toggled via .qty-hover-end), so the glow itself hints at what you can
   actually press. Focusing the input (tapping to type) suppresses this
   cursor-follow glow entirely and instead lights the whole control
   uniformly, the same way .btn-tests lights up on hover — see the
   :focus-within rules below. */
.card-qty-selector {
  position: relative;
  display: grid;
  grid-template-columns: 36px 1fr 36px;
  gap: 0;
  align-items: stretch;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
  margin-bottom: 8px;
  height: 36px;
  --mx: 50%;
  --my: 50%;
  --glow-a: 0.35;
  --glow-r: 42px;
}

.card-qty-selector.qty-hover-end {
  --glow-a: 0.95;
  --glow-r: 66px;
}

.card-qty-selector::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: var(--radius-md);
  background: radial-gradient(var(--glow-r) circle at var(--mx) var(--my), rgb(200 200 200 / var(--glow-a)), rgb(200 200 200 / 0) 75%);
  opacity: 0;
  transition: opacity 0.25s ease, --glow-a 0.2s ease, --glow-r 0.2s ease;
  pointer-events: none;
}

.card-qty-selector:hover::before {
  opacity: 1;
}

.card-qty-selector:focus-within::before {
  opacity: 0 !important;
}

/* Tapping the input to type: light the whole control the same way
   .btn-tests lights up on hover (uniform border + fill, no cursor glow). */
.card-qty-selector:focus-within {
  border-color: var(--text-primary);
}

.card-qty-selector:focus-within .qty-minus,
.card-qty-selector:focus-within .qty-plus,
.card-qty-selector:focus-within .qty-middle,
.card-qty-selector:focus-within .qty-input {
  background: var(--bg-secondary);
}

.qty-minus,
.qty-plus {
  position: relative;
  z-index: 3;
  width: 36px;
  height: 36px;
  border: none;
  background: #fff;
  color: #111;
  cursor: pointer;
  transition: background 0.25s ease;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}

/* Drawn with CSS bars rather than the "−"/"+" glyphs: text-glyph centering
   stayed visibly off no matter how precisely the box math was tuned,
   because the glyphs themselves aren't optically symmetric in every font.
   A plain block box + flex-centered bars sidesteps font metrics entirely,
   so this is centered for real, not just on paper. */
.qty-minus span,
.qty-plus span {
  position: relative;
  display: block;
  width: 12px;
  height: 12px;
}

.qty-minus span::before,
.qty-plus span::before,
.qty-plus span::after {
  content: '';
  position: absolute;
  background: currentColor;
  border-radius: 1px;
}

.qty-minus span::before,
.qty-plus span::before {
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  transform: translateY(-50%);
}

.qty-plus span::after {
  left: 50%;
  top: 0;
  width: 2px;
  height: 100%;
  transform: translateX(-50%);
}

/* Whole button lights on hover, fading out toward the shared edge with
   qty-middle (rather than cutting off sharply) — same fade language as
   the border glow above.

   The wash is a pseudo-element whose OPACITY animates, not a background
   swapped on :hover. `transition: background` cannot interpolate a solid
   colour into a gradient — they are different value types — so the old
   version ignored its own 0.25s and snapped on, then snapped off. Opacity
   on a constant gradient is a single interpolable number, so it actually
   fades, in and out, at the duration it claims.

   ::before, not ::after: both are positioned, so they paint in DOM order
   around the button's content, and only ::before lands UNDER the span
   that draws the +/- bars. */
.qty-minus::before,
.qty-plus::before {
  content: '';
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.25s ease;
  pointer-events: none;
}
.qty-minus::before {
  background: linear-gradient(to right, var(--bg-secondary) 0%, var(--bg-secondary) 60%, transparent 100%);
}
.qty-plus::before {
  background: linear-gradient(to left, var(--bg-secondary) 0%, var(--bg-secondary) 60%, transparent 100%);
}
.qty-minus:hover::before,
.qty-plus:hover::before { opacity: 1; }
/* Keyboard users get the same affordance the mouse gets. */
.qty-minus:focus-visible::before,
.qty-plus:focus-visible::before { opacity: 1; }

/* Sectioned off from the two buttons, the way the product page's .qty is:
   a hairline down each side of the number, so the control reads as three
   parts rather than as one undivided strip with symbols floating at its
   ends. Same token and same weight as the product page uses, so the two
   steppers are recognisably the same component at two sizes.

   The rules live on this middle element rather than on the buttons, so
   there is exactly one line per join — a border on each of the three
   would double up where they meet. Sizing and proportions are untouched;
   this only adds the divisions. */
.qty-middle {
  position: relative;
  z-index: 3;
  height: 36px;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  border-left: 1px solid var(--border-soft);
  border-right: 1px solid var(--border-soft);
  /* The whole area between the two rules is the field — see the width
     note on .qty-input below. The caret cursor is what says so before
     the click. */
  cursor: text;
}

.qty-input {
  height: 36px;
  /* Fills the whole space between the two dividers rather than sitting
     as a 28px island in the middle of it. The input WAS 28px wide, so
     the ~40px of white either side of the number belonged to .qty-middle
     and clicking there hit a plain <div> — the field looked like it
     spanned the gap and only the digits themselves were a hit target.
     Stretching the input to fill its flex parent makes the whole visible
     area the control it already appeared to be.

     The number stays put: text-align:center centres it in whatever width
     the input has, and the input is now centred by being the full width,
     so the digits land on exactly the same pixel as before. */
  width: 100%;
  flex: 1 1 auto;
  min-width: 0;
  border: none;
  text-align: center;
  background: #fff;
  color: #111;
  font-family: var(--font-sans);
  font-size: 14px;
  font-weight: 600;
  -moz-appearance: textfield;
  padding: 0;
  position: relative;
  z-index: 1;
}

.qty-input:focus {
  outline: none;
}

/* Positioned as an overlay (not in normal flex flow) so it never nudges
   the input off-center, whenever it appears. Anchored just past the
   input's own (fixed) width rather than the container's right edge, so
   it reads as sitting between the number and the plus button instead of
   hugging the button. Solid fill + dark text — the previous green-on-green
   tint was too low-contrast to read at a glance. */
.qty-discount {
  position: absolute;
  left: calc(50% + 18px);
  top: 50%;
  transform: translateY(-50%);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: #0a2e17;
  background: #4ade80;
  padding: 3px 7px;
  border-radius: 999px;
  white-space: nowrap;
  pointer-events: none;
}
.qty-discount[hidden] { display: none; }

.qty-input::-webkit-outer-spin-button,
.qty-input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.card-stock {
  display: none;
}

.btn-cart--preorder { background: #a06a12; }
.btn-cart--preorder:hover { background: #8a5c10; }
/* Same grey, same thickness as .card-qty-selector's border — but static:
   no hover/focus darkening at all, unlike every other interactive border
   on this card. */
.btn-cart[disabled],
.btn-cart[disabled]:hover,
.btn-cart[disabled]:focus {
  background: var(--bg-card-hover);
  color: var(--text-muted);
  border: 1px solid var(--border);
  cursor: not-allowed;
}

/* ── "Added to Cart", on the button that was just pressed ──
   Held for a beat by PA.flashAdded (js/ui.js) after every add, on the
   grid cards and the product page alike. The button is genuinely
   `disabled` for that beat — a second press during the hold would add a
   second lot of the same thing, which is never what a double-tap on a
   confirmation means.

   Being disabled is exactly why this rule has to exist and has to win:
   the [disabled] rule above is the SOLD OUT look (grey ground, muted
   text, a border), and a successful add wearing it would read as the
   opposite of what just happened. So this states the confirmation's own
   look and sits after that rule to take it.

   The specificity is deliberate rather than incidental: `.btn-cart.is-added`
   is two classes against the [disabled] selector's one class + one
   attribute — equal weight — so document order is what decides, and
   this being LATER in the file is what makes it apply. Moving it above
   that block would silently stop it working.

   `cursor: default`, not the [disabled] rule's `not-allowed`: nothing
   has been refused here. Colour is the CTA's own, dimmed rather than
   swapped, so the button is plainly the same object mid-acknowledgement
   instead of a different control that appeared under the finger. */
.btn-cart.is-added,
.btn-cart.is-added:hover,
.btn-cart.is-added:focus {
  background: var(--cta);
  color: #fff;
  border: none;
  opacity: 0.55;
  cursor: default;
  /* Only the fade animates. The label swaps instantly — a transition on
     text is not a thing, and easing the colour while the word changes is
     what makes the swap read as intentional rather than as a glitch. */
  transition: opacity var(--transition), background var(--transition);
}
/* ============================================================
   PRODUCT DETAIL PAGE
   ============================================================ */
/* --pd-col-gap is the distance between the two columns. --pd-nav-gap is
   the separate distance from the nav's lower edge down to the top of the
   image frame — half of --pd-col-gap, asked for outright ("half the
   space between the nav bar and the page contents"), and named off it
   rather than restated so it can never drift back out of that exact
   ratio. The two used to be the same one number; they no longer are, so
   they need two names. */
.product-detail {
  --pd-col-gap: 56px;
  --pd-nav-gap: calc(var(--pd-col-gap) / 2);
  /* The band the frame is laid out in, and the cap on how much of it the
     frame actually uses. Named here because TWO things read them: the
     frame itself, and the info column, whose bottom edge is aligned to
     the frame's (see .pd-info below).

     Both are needed because they are not the same number. --pd-frame-h
     is the wrapper's height; --pd-frame-max is what .pd-gallery-frame
     caps itself at, and the VISIBLE white card is therefore
     min(frame-h, frame-max). Aligning to the wrapper instead of to the
     card was the first attempt at this and it was wrong by exactly the
     difference — on a 1000px window the card ended at 903 and the buy
     row at 976, the 73px the wrapper extends past the thing anyone can
     actually see. */
  --pd-frame-h: calc(100vh - var(--promo-h) - var(--nav-h, clamp(56px, 10vh - 16px, 92px)) - var(--pd-nav-gap) - 24px);
  --pd-frame-max: 760px;
  /* What the card's bottom edge is actually at. The one figure .pd-info
     aligns to. */
  --pd-frame-visible-h: min(var(--pd-frame-h), var(--pd-frame-max));
  /* Containing block for .pd-gallery-slot below — taking the sticky
     column fully out of grid flow on desktop needs somewhere to anchor
     to. Doesn't touch layout on its own: nothing here sets an offset. */
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--pd-col-gap);
  align-items: start;
  /* .page clears the fixed chrome with a flat 112px + the offer strip,
     which lands the grid's top edge somewhere unrelated to the nav's
     own height. This is the correction: from the page's own top, back
     up past that 112px, then down by the real nav height and the gap.
     The result is a frame whose top edge sits exactly --pd-nav-gap
     below where the nav actually ends, at any nav height. */
  margin-top: calc(var(--nav-h, clamp(56px, 10vh - 16px, 92px)) + var(--pd-nav-gap) - 112px);
}
/* ── The gallery column ──
   Invisible. Its only job is to occupy exactly the band between the nav's
   lower edge and the bottom of the window, so the frame inside it can be
   centred in that band by ordinary flex centring.

   --nav-h is the nav's measured height, published by js/partials.js; the
   fallback matches the clamp the nav actually uses, for the frame or two
   before the measurement lands. Together with --promo-h that is the exact
   y the page becomes visible at, which is what "centred below the nav"
   has to be measured from — the old fixed 108px was neither of those and
   left the frame hanging low. */
.pd-gallery {
  position: sticky;
  /* The same offset the margin above establishes on first paint, so the
     frame does not move when it starts sticking — it simply stops where
     it already was relative to the nav. */
  top: calc(var(--promo-h) + var(--nav-h, clamp(56px, 10vh - 16px, 92px)) + var(--pd-nav-gap));
  /* One --pd-nav-gap is now spent above the frame, so the band it can
     occupy is that much shorter; the second subtraction keeps the same
     clearance at the bottom that the old padding-block did. Defined on
     .product-detail so .pd-info can align its own bottom to it. */
  height: var(--pd-frame-h);
  /* Cancels the clearance .page keeps above its content (112px + the
     offer strip) down to the nav's real lower edge. Sticky only takes
     over once the page has scrolled; on ARRIVAL the column sits wherever
     normal flow put it, and flow puts it 112px down — so without this the
     band starts below the nav, the frame is centred in the wrong band,
     and it hangs off the bottom of the window by the difference.
     --promo-h cancels out of both sides, leaving only the 112px. */
  /* The offset lives on .product-detail now (see there), so both
     columns start from the same line and the title can be measured
     against the frame's top edge rather than against a band. */
  margin-top: 0;
  display: flex;
  /* flex-start, not centre: the whole point of the offset above is that
     the frame's TOP edge is a known distance below the nav. Centring it
     in a viewport-tall band would put that edge wherever the band's
     leftover height happened to fall. */
  align-items: flex-start;
  justify-content: center;
  padding-block: 0;
  box-sizing: border-box;
  min-height: 0;
}

/* Zero, not offset — asked for outright: the title lines up with the
   frame's own top edge instead of sitting some distance below it, which
   pulls every row under the title up the same column with it (this is
   padding on the COLUMN, not a margin on .pd-name alone, so nothing else
   in .pd-info has to move separately to follow it). Used to be half of
   --pd-col-gap, back when the two columns were meant to read as sharing
   one measurement; align-items:start on .product-detail (above) is what
   actually keeps both columns' top edges level now. */
.pd-info { padding-top: 0; }

/* ── The buy controls end where the photo does ──
   Asked for outright: the bottom edge of the quantity selector and Add
   to Cart sits level with the bottom edge of the image frame, with
   everything above them pushed down to make it so.

   ── 1025px, not 769px ──
   The request said "larger than 768px", and that is the wrong number for
   this particular change through no fault of the request: the two
   columns only sit SIDE BY SIDE above 1024px. At 1024 and below,
   .product-detail collapses to grid-template-columns:1fr and .pd-gallery
   drops its viewport height for an aspect-ratio box, so the photo is
   stacked ABOVE the info rather than beside it.

   Scoped at 769px, this put a 737px min-height on a column with nothing
   next to it — measured at 900px wide, the buy row ended up 769px BELOW
   the frame with an empty band above it. There is no frame edge to align
   to once the columns stack, so the alignment simply does not apply
   there.

   No wrapper is needed, because on desktop the info column genuinely
   ENDS at the buy row — js/product.js moves .pd-block--props up to sit
   directly under the description, so nothing remains below .pd-buy but
   .pd-notify, which is hidden unless the selected size is sold out.
   Bottom-aligning the whole column is therefore the same thing as
   bottom-aligning the buy row.

   min-height, not height: if the column's own content is taller than the
   frame — a long description, a blend with a four-line sequence, six
   sizes — it grows past it and the buy row lands below the photo rather
   than being compressed or clipped. The alignment is a floor, not a cap.

   align-items:start on .product-detail is what makes this work: both
   columns start on the same line, so matching heights means matching
   bottoms. */
@media (min-width: 1025px) {
  .pd-info {
    /* The VISIBLE card's height, not the wrapper's — see the note on
       --pd-frame-visible-h. .pd-gallery is a taller flex box that the
       card sits at the top of; aligning to it put the buy row below the
       card by however much the cap had trimmed. */
    min-height: var(--pd-frame-visible-h);
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
  }
  /* The 12px this normally carries would sit BETWEEN the button and the
     frame's bottom edge, leaving the two 12px out of line — a trailing
     margin still occupies space under justify-content:flex-end. Zeroed
     so "aligned" means aligned. Restored below 1025px by the base rule. */
  .pd-info > .pd-buy { margin-bottom: 0; }
}
/* ── The frame ──
   A window-height box, not a square. The bottle crops are 1:2, so a
   square box left the photo painting at barely a quarter of the frame's
   width with dead air either side. Height-driven instead: the frame fills
   the band it is centred in, and the photo inside it stays comfortably
   smaller than that. */
.pd-gallery-frame {
  width: 100%;
  height: 100%;
  /* Never taller than it is useful: on a very tall window the frame would
     otherwise outgrow the buying controls beside it. Because the cap is
     here and the centring is on the parent, capping no longer costs the
     centring — which is why these are two elements.

     Read from .product-detail rather than stated, so .pd-info's
     alignment reads the same cap this does. */
  max-height: var(--pd-frame-max, 760px);
  min-height: 0;
  background: linear-gradient(160deg, #fcfcfe 0%, var(--bg-secondary) 100%);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 48px;
  box-sizing: border-box;
  overflow: hidden;
  cursor: zoom-in;
}
.pd-gallery img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  /* Zoom is a transform on the image, with transform-origin moved to
     wherever the cursor is — so the point under the pointer stays under
     it as the photo grows, which is what makes it feel like a loupe
     rather than a jump. The origin is set from JS (js/product.js). */
  transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1);
  will-change: transform;
}
.pd-gallery-frame.is-zoomed { cursor: zoom-out; }
.pd-gallery-frame.is-zoomed img {
  transform: scale(2.4);
  /* No transition while tracking the pointer — a 0.25s ease on every
     mousemove turns the loupe into a lagging rubber band. */
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  .pd-gallery img { transition: none; }
}

/* Touch has no hover, so there is nothing to track and a 2.4x zoom with
   no way to pan is a worse view than the fitted one. */
@media (hover: none) {
  .pd-gallery-frame { cursor: default; }
  .pd-gallery-frame.is-zoomed img { transform: none; }
}

@media (max-width: 1024px) {
  /* Stacked layout — a full-height image frame would push the price and
     the Add to Cart button entirely below the fold. There is no band to
     centre in once the columns stack, so the column stops being one:
     it collapses onto the frame's own square. */
  .pd-gallery { position: static; height: auto; padding-block: 0; margin-top: 0; display: block; }
  .pd-gallery-frame { height: auto; aspect-ratio: 1 / 1; max-height: none; }
  /* Both are answers to a two-column layout that no longer exists here:
     the gap between the columns, and the title's offset from a frame
     that is now above it rather than beside it. .page's own padding is
     what clears the nav once the frame is not sitting next to
     anything. */
  .product-detail { margin-top: 0; }
  .pd-info { padding-top: 0; }
}
/* font-weight:900 does nothing on its own here — --font-sans (Helvetica /
   Helvetica Neue / Arial) has no cut above its single Bold, so 700, 800
   and 900 all measure and render identically in this stack. The actual
   thickening is the stroke: a paint-time effect in the text's own
   colour, so advance widths don't change and the name keeps wrapping
   exactly where it already did. Was mobile-only (asked for outright,
   "as thick as it is on mobile", so this tier now carries the same
   treatment unconditionally rather than duplicating it in a ≤768px
   rule). 900 stays alongside the stroke anyway, stating the intent for
   if --font-sans ever gains a family with a heavier cut. */
/* ── The product title, one weight at every size ──
   The mobile title read heavier than the desktop one, and the cause is
   that Helvetica has no 900: the browser synthesises it from Bold, and
   synthetic emboldening does not scale in proportion to the type. At
   27px the extra ink is a larger share of a smaller letter, so the small
   title came out denser than the large one — the same declaration
   producing two different weights.

   Measured rather than eyeballed. Rendering SEMAGLUTIDE to a canvas and
   counting dark pixels over the glyph box gives an ink ratio of 0.4188
   at 27px against 0.4042 at 39.6px on the old 0.03em stroke. Stepping
   the stroke at 39.6px: 0.04em overshoots to 0.4316, so the match falls
   at ~0.0353em. 0.035em is that number.

   It is a hair heavy at the small end (0.4188 → ~0.425, under half a
   percent) and exact at the large end, which is the end that was wrong.
   One value rather than a size-interpolated one: a calc() that tracked
   the clamp would be three unreadable terms to correct a difference
   nobody can see. */
.pd-name { font-size: clamp(27px, 3.6vw, 39.6px); font-weight: 900; -webkit-text-stroke: 0.035em currentColor; letter-spacing: -0.03em; text-transform: uppercase; line-height: 1.05; margin-bottom: 8px; }
.pd-price { font-size: 23.4px; font-weight: 800; margin: 16px 0 20px; }
.pd-desc { color: var(--text-secondary); font-size: 16px; line-height: 1.7; margin-bottom: 28px; }
/* Matched to .pd-name's own margin-bottom, asked for outright — the gap
   above the compound details reads the same as the gap above the
   description itself, ONLY where the compound details follow. js/product.js
   moves .pd-block--props to directly under the description on desktop, so
   this pair reads as one unit — a statement and the figures behind it —
   where the description followed by the Size chooser is two separate
   things and keeps the full 28px. :has() is what lets the same element
   space itself differently depending on what comes next; the stylesheet
   already uses it for .summary-row + .ship-meter. */
.pd-desc:has(+ .pd-block--props) { margin-bottom: 8px; }
.pd-block { margin-bottom: 28px; }
.pd-block-title { font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 14px; }
.pd-sizes { display: flex; flex-wrap: wrap; gap: 8px; }
/* A tile now, not a chip: the bottle for that size sits above its label,
   so the sizes read as the different objects they are rather than as
   three interchangeable words. Column layout because the photo is 1:2 —
   beside the text it would either be a sliver or set the button's height
   for it. */
/* Every length here is the previous value × 1.2 — the tile is 20% bigger
   with its proportions untouched, so it reads as exactly the same object
   at a larger size rather than as a redrawn one. The gap, padding, width,
   type size and the shot's height below all took the same multiplier. */
.pd-size {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 7.2px;              /* 6 × 1.2 */
  width: 100.8px;          /* 84 × 1.2 */
  padding: 12px 12px 10.8px;   /* 10 / 10 / 9 × 1.2 */
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-primary);
  font-size: 15.12px;      /* 12.6 × 1.2 */
  font-weight: 600;
  cursor: pointer;
  transition: border-color var(--transition), background var(--transition);
}
/* Just a box that centres the bottle now — no ground of its own. It used
   to carry a grey fill and a 7px radius so the photo had an edge to sit
   against inside the active tile, back when that tile went near-black and
   a transparent PNG would have vanished into it. The active state is a
   ring rather than a fill now (see .pd-size.active below), so the tile
   behind the bottle is always light and the well was drawing a second,
   pointless rectangle inside the one the border already draws. */
.pd-size-shot {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 64.8px;          /* 54 × 1.2 */
  overflow: hidden;
}
.pd-size-shot img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  display: block;
}
.pd-size-label { line-height: 1.2; }
.pd-size:hover { border-color: var(--border-accent); }
/* Selected reads as a RING, not a fill. It used to go solid near-black
   (background: var(--accent-bright)), which put a hard black rectangle
   in the middle of an otherwise white, light-bordered page and dragged
   the eye away from the product photography beside it. A 2px accent
   border and a barely-there tint say "this one" just as unambiguously
   while leaving the tile part of the page.

   The border thickens rather than the box growing: padding drops by the
   1px the border gains, so the tile does not shift its neighbours when
   selected. */
.pd-size.active {
  border-color: var(--accent-bright);
  border-width: 2px;
  /* One less pixel per side than .pd-size's own padding, so the 1px the
     border gains is given back here and the tile does not shift its
     neighbours when selected. Scaled with the rest: 12/12/10.8 − 1. */
  padding: 11px 11px 9.8px;
  background: var(--bg-secondary);
  color: var(--text-primary);
}
/* ── Sold out is said, not struck ──
   The strike-through and the 0.5 fade this tile used to carry were two
   wordless ways of saying the same thing, and both are now redundant:
   the price line says "Sold out" in words (renderSizes(), js/product.js).
   Keeping the fade would only take that text down with it, and a state
   you have to squint at is worse than the one being replaced. The photo
   dims on its own instead, so the tile still reads as unavailable while
   both lines of type stay legible. */
.pd-size[disabled] {
  cursor: not-allowed;
  color: var(--text-muted);
}
.pd-size[disabled] .pd-size-shot { opacity: 0.45; }

/* No rule closing the block at the top or the bottom — asked for
   outright. Only those two: the lines BETWEEN rows separate one fact
   from the next and stay. */
.pd-props { border-top: none; }
/* 95% of the column, asked for outright — five per cent taken off the
   RIGHT end only, so the table keeps the same left edge as the
   description above it and simply stops short of it. A block-level box
   at less than full width sits left by default; there is deliberately no
   margin-inline:auto, which would centre it and lose the shared edge
   that is the whole point.

   Scoped to the desktop tier, because the relationship it is expressed
   against only exists there: js/product.js puts this table directly
   under the description above 768px, and moves it below the lab results
   on a phone. Down there it is a full-width section among full-width
   sections, and 95% would read as a section that had failed to reach
   the edge rather than as one deliberately inset. */
@media (min-width: 769px) {
  .pd-props { width: 95%; }
}
/* ── Label, then value, both on their own left edge ──
   Was label-left / value-right with the two pushed apart
   (justify-content: space-between), so a one-word value and a
   forty-character sequence began at completely different x positions
   and the block read as ragged. A two-track grid instead: every value
   starts on the same line and the column reads straight down.

   28% (two thirds of the phone tier's own 42%, asked for outright on
   non-mobile — the phone rule below keeps its original 42%) puts that
   line closer to the table's own left edge, so the value column — "the
   information" — starts sooner instead of waiting for two-fifths of the
   row's width to go by. A percentage rather than a pixel figure so the
   relationship holds at every width in this tier. */
.pd-prop {
  display: grid;
  grid-template-columns: 28% 1fr;
  gap: 0;
  padding: 11px 0;
  border-bottom: 1px solid var(--border);
  font-size: 14px;
}
.pd-prop:last-child { border-bottom: none; }
.pd-prop-label {
  color: var(--text-muted);
  /* The labels are two words at most and the value beside them can be a
     four-line component list — without this the label column collapses
     to one character per line while the value wraps. */
  flex: 0 0 auto;
  white-space: nowrap;
}
.pd-prop-value {
  color: var(--text-primary);
  font-weight: 600;
  text-align: left;
  /* A blend's sequence and molar mass are one line per component, joined
     with real newlines by supabase/033. pre-line honours those while
     still collapsing the incidental whitespace the SQL's own string
     concatenation leaves in. */
  white-space: pre-line;
  /* Sequences are long unbroken runs of capitals (a 44-residue
     Tesamorelin string has no space in it) — without this they push the
     column wider than the card instead of wrapping. */
  overflow-wrap: anywhere;
  min-width: 0;
}
/* Sequences and formulae are strings read character by character, which
   is what a monospace face is for. Storage is a sentence and stays in the
   body face — setting prose in mono would say "this is a code" about the
   one row that isn't. */
.pd-prop-value--mono {
  font-family: var(--font-mono, ui-monospace, Menlo, monospace);
  font-size: 13px;
  letter-spacing: -0.01em;
  /* Not bold, asked for outright. .pd-prop-value sets 600 for the block,
     which is right for a short answer read at a glance and wrong for
     these two: a sequence is forty-odd capitals and a molar mass is a
     figure with a unit, both already set apart by the mono face. Bold on
     top of that made the longest rows the heaviest thing in the column. */
  font-weight: 400;
}
/* Storage runs to a couple of sentences where the other two are short
   strings — left-ragged rather than right-aligned, so the wrapped lines
   have a straight edge to read down. */
.pd-prop-value--prose {
  font-weight: 500;
  text-align: left;
  line-height: 1.55;
  color: var(--text-secondary);
  max-width: 42ch;
}
/* A property the compound genuinely has no value for (e.g. NAD+ has no
   sequence — it isn't a peptide). Muted and italic so it reads as an
   explanation rather than as data, the same distinction --mono/--prose
   draw between a string and a sentence. */
.pd-prop-value--unavailable {
  font-weight: 500;
  font-style: italic;
  text-align: left;
  color: var(--text-muted);
}

/* ── The savings meter ──
   One rectangle in four sections, filled from the left as the quantity
   climbs, and draggable to set that quantity. Replaces four separate
   dashed chips: those listed the tiers, this shows which one you are in
   and lets you move.

   The whole thing is one bordered box with the section rules drawn INSIDE
   it, so the fill can cross a boundary mid-drag without a gap or a seam
   to fall into. */
/* ── Bulk discount tiers ──
   A segmented control, not a progress bar. It was built as a draggable
   meter — a gradient fill with a knob riding its leading edge — and once
   the drag was removed (see wireMeter in js/product.js) the knob was an
   affordance for a gesture that no longer existed, and the gradient was
   doing the work of a slider that had stopped being one.

   Four discrete choices are four segments. The treatment is deliberately
   the home page's .op-slider: a quiet track with the selected third
   lifted out of it in white. Reusing that language means the two
   segmented controls on the site read as the same object rather than as
   two designers' guesses.

   The green is kept, but demoted to a tint on the tiers already reached
   — the saving is worth colouring, the chrome around it is not. */
.pd-tiers {
  position: relative;
  display: block;
  width: 100%;
  padding: 4px;
  box-sizing: border-box;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-secondary);
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
}
.pd-tiers:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }

/* The travelling selection, exactly as .op-slider does it: one element
   that slides between the four quarters rather than a background toggled
   on whichever cell is current. Its left offset is driven by --pd-tier,
   set by paintMeter(). */
.pd-tier-fill {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 4px;
  width: calc((100% - 8px) / 4);
  transform: translateX(calc(var(--pd-tier, 1) * 100% - 100%));
  border-radius: calc(var(--radius-md) - 4px);
  background: var(--bg-primary);
  box-shadow: 0 1px 3px rgba(10, 10, 12, 0.13);
  /* 0.17s, half of the 0.34s this was — asked for outright, "about twice
     as fast ... it needs to be snappy". The easing is unchanged: the
     curve is what makes the segment settle rather than stop dead, and
     that reads the same at either duration. */
  transition: transform 0.17s cubic-bezier(0.16, 1, 0.3, 1);
}

.pd-tier-cells {
  position: relative;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
}
.pd-tier {
  text-align: center;
  padding: 8px 6px 9px;
  font-size: 11.5px;
  line-height: 1.25;
  color: var(--text-muted);
  /* ── Out, then in — never both at once ──
     Asked for outright: "when one's fading out, the other shouldn't be
     fading in ... about twice as fast as it happens now."

     Both halves come from one property, because a transition is read off
     the element's NEW state. A cell LOSING is-filled/is-current lands on
     this rule, which has no delay, so it starts fading out immediately.
     A cell GAINING one lands on the delayed rule below, so it waits the
     50ms the outgoing cell needs to finish before it starts. The two
     runs are back to back instead of on top of each other, and 50 + 50
     still totals half of the 200ms (--transition) this used to
     cross-fade over.

     linear, not ease: at 50ms an easing curve is spent almost entirely
     on its own ramp-up and reads as a slower start rather than as
     easing. */
  transition: color 0.05s linear;
}
.pd-tier b {
  display: block;
  font-size: 15px;
  font-weight: 800;
  color: var(--text-secondary);
  transition: color 0.05s linear;
}
/* Reached, but not the one you are on. The tint says "already unlocked"
   without competing with the selected segment. */
.pd-tier.is-filled, .pd-tier.is-filled b { color: #15803d; }
/* The live tier. Sits on the white segment above, so it can afford full
   weight and full contrast. */
.pd-tier.is-current { font-weight: 700; }
.pd-tier.is-current, .pd-tier.is-current b { color: #15803d; }
.pd-tier.is-current b { color: #15803d; }
/* The delayed half of the sequencing above — on the "arrived" states, so
   it applies to a cell entering them and not to one leaving. */
.pd-tier.is-filled,
.pd-tier.is-filled b,
.pd-tier.is-current,
.pd-tier.is-current b { transition: color 0.05s linear 0.05s; }

.pd-tiers.is-disabled {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .pd-tier-fill { transition: none; }
  /* The colour hand-off too, now that it has a delay: a 50ms wait before
     an instant change is still a visible stutter to someone who asked
     for no motion. */
  .pd-tier,
  .pd-tier b,
  .pd-tier.is-filled,
  .pd-tier.is-filled b,
  .pd-tier.is-current,
  .pd-tier.is-current b { transition: none; }
}

/* ── "Save when you buy more", removed ──
   Asked for outright. The bar states every tier and its rate in words;
   the heading above it was naming what the reader is already looking
   at. Every width, not just the phone tier. */
.pd-block--tiers .pd-block-title { display: none; }

/* ── "Compound details", removed on non-mobile only ──
   Asked for outright, scoped to the tier this whole pass was about: the
   table sits directly under the description there (js/product.js moves
   it), which already reads as "more about this compound" without a
   label saying so. Left alone below 769px, where the block is moved out
   to its own place among the page's other sections (order results,
   FBT, reviews) and needs the heading to say what it is. */
@media (min-width: 769px) {
  .pd-block--props .pd-block-title { display: none; }
}
/* 12px below the meter — exactly .pd-buy's own `gap`, i.e. the distance
   between the quantity stepper and the Add to Cart button beside it.
   Asked for outright: the same measure horizontally and vertically is
   what makes the three read as one group. Replaces .pd-block's 28px. */
.pd-block--tiers { margin-bottom: 12px; }

/* margin-top:0 so that 12px is the whole gap rather than stacking on
   top of the 24px this used to carry. margin-bottom stays 12px: it is
   the space under the buy row, which nothing else supplies. */
.pd-buy { display: flex; align-items: stretch; gap: 12px; margin: 0 0 12px; }
.pd-qty { flex: 0 0 auto; }
.pd-qty .qty { height: 100%; grid-template-columns: 44px 52px 44px; }
.pd-buy .btn-cart { flex: 1; height: auto; }
.pd-stock { font-size: 13px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
/* "Only N left" — three units or fewer (PA.availabilityMeta). The only
   stock-state colour on this label at all; plain "In stock" has none to
   distinguish it from because there is nothing urgent to say about it. */
.pd-stock.low, .card-stock.low { color: #a06a12; }

/* Frequently bought together */
/* ── Frequently bought together ──
   The same component as a catalog card, at a smaller size: square white
   image well, uppercase name, price beneath. It used to be its own thing
   — a shadowed card with a gradient image panel and a 160px fixed image
   height — which read as a different product from the grid the shopper
   had just come from.

   Always four across. auto-fill would reflow to three or five depending
   on the column width, and this row is a fixed set of four suggestions,
   not a responsive grid of unknown length. */
/* ── Section rhythm below the buying controls ──
   Three stacked sections — Lab results, Frequently bought together,
   Reviews — which were spaced as if each were a new page. Halved, so they
   read as parts of one product page rather than as three documents that
   happen to be underneath each other.

   The gap ABOVE the first of them is the exception and is not halved: it
   separates the supporting material from the product itself, which is a
   bigger boundary than the ones between the supporting sections. It also
   did not exist before — the lab results began immediately under the
   buying controls and read as part of them. */
/* ── One gap between every section below the product ──
   Asked for outright, and now at every width rather than only on the
   phone tier. The four used to be on three different clamps — 22px to
   the related products, 44px to the lab results, 20px to the reviews —
   each tuned on its own and reading as an arbitrary rhythm rather than
   a deliberate one.

   One clamp for all of them, bottoming out at the 40px the phone tier
   already used so nothing narrows there, and growing with the viewport
   the way the individual clamps did.

   .pd-block--props is deliberately NOT in this list. It is one of these
   sections only on a phone, where js/product.js moves it out below the
   lab results; on desktop it sits inside .pd-info under the description
   and wants .pd-block's ordinary 28px like every other block in that
   column. Its section spacing is set in the ≤768px block instead. */
.fbt,
.pd-tests,
.reviews { margin-top: clamp(40px, 4.5vw, 64px); }
.pd-tests[hidden] { display: none; }

/* ── .pd-gallery-slot: the sticky column, out of the row entirely ──
   .product-detail is a grid with align-items:start, so its own row
   height used to follow the TALLER of its two columns — and
   .pd-gallery (sticky, --pd-frame-h tall: the full nav-to-viewport-
   bottom band, so there is room for it to travel while pinned) is very
   often taller than .pd-info's content, which only needs
   --pd-frame-visible-h (the capped 760px the frame itself never
   exceeds). Whatever that difference was became dead space between the
   visible buy row and .fbt's heading below the grid — on a tall
   monitor, measured at several hundred px instead of the ~60px every
   other section gets, and margin alone on .fbt could only ever cancel
   part of it (a negative margin big enough to cancel the rest would
   have pulled .fbt up into the space still visually holding the
   sticky-pinned frame mid-scroll).

   The actual fix is upstream: .pd-gallery-slot (js/product.js) wraps
   .pd-gallery and takes the tall box out of grid flow with
   position:absolute, sized to match column 1 by hand since an
   absolutely positioned element no longer has a grid track to size
   itself against. .product-detail's row height is now set by .pd-info
   ALONE, which is where the buy row actually ends. .pd-gallery itself
   is untouched — same sticky rule, same height, same centring — it is
   only its PARENT that changed, so the scroll-stick behaviour is
   pixel-identical to before. .pd-info is pinned to grid-column 2
   explicitly, since a lone grid item does not know to skip the column
   its absolutely-positioned sibling used to occupy.

   Scoped to 1025px, matching every other rule that depends on the two
   columns sitting side by side; below it .pd-gallery-slot stays a
   plain static block and the columns simply stack. */
@media (min-width: 1025px) {
  .pd-gallery-slot {
    position: absolute;
    top: 0;
    left: 0;
    width: calc((100% - var(--pd-col-gap)) / 2);
    height: var(--pd-frame-h);
  }
  .pd-info { grid-column: 2; }
}

/* One square, defined once. It sizes the FBT tiles, and the lab-result
   rows above take their height from it, so the two sections line up
   instead of being two different heights that happen to sit near each
   other. Derived from the grid's own arithmetic — the container is
   max 1200px with 40px of padding either side, and the row is four
   columns with --grid-gap between them. */
.product-detail ~ *,
.pd-tests, .fbt, .reviews {
  --pd-content-w: min(1120px, 100vw - 80px);
  --fbt-square: calc((var(--pd-content-w) - 3 * var(--grid-gap, 12px)) / 4);
}

.fbt-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--grid-gap, 12px);
}

/* A square tile, not a portrait one. The card used to be a full-width
   square image with the name and price stacked underneath, which made the
   whole thing half as tall again as it was wide — four of them ran deeper
   than the section above. The card is now the square, and the image well
   gives up the height the caption needs. */
.fbt-card {
  display: flex;
  flex-direction: column;
  min-width: 0;
  aspect-ratio: 1 / 1;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  transition: border-color var(--transition);
}
.fbt-card:hover { border-color: var(--text-muted); }

/* Takes whatever height the caption leaves rather than claiming a square
   of its own — that is what makes the CARD square instead of the image.
   min-height:0 so a flex child is actually allowed to shrink. */
.fbt-img {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Percentage padding — of the WIDTH on all four sides, which is what
     keeps the inset proportional as the column narrows. The bottle sits
     well inside its well here, the same way it does on a catalog card,
     rather than filling the box edge to edge: at this size a bottle
     pressed against the borders read as a cropped photograph. */
  padding: 9% 24%;
  box-sizing: border-box;
  background: var(--bg-primary);
}
.fbt-img img { max-width: 100%; max-height: 100%; object-fit: contain; }

.fbt-body { padding: 0 14px 14px; }

/* The caption's two facts. On this tier the row reads "From $x" on one
   line exactly as it did before the size span existed: .fbt-meta is a
   plain block (so .fbt-price flows as the old <div> did), the size is
   not rendered, and the "From " prefix is. The phone tier below swaps
   all three — see the .fbt-card rules there. */
.fbt-meta { display: block; }
.fbt-meta-size { display: none; }

/* The <figure> is new too: the phone tier needs the same
   wrap > figure > img nesting a product card has, and on this tier it
   must not change the box it was added inside. Full size, no padding of
   its own — .fbt-img's own 9%/24% still does all the insetting — and
   flex centring so the photo sits where it did when it was .fbt-img's
   direct child. */
.fbt-img figure {
  margin: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ── The price, stated per size ──
   Every width now, not just the phone tier: .pd-price above is gone, so
   this IS where a price appears. Written per tile by renderSizes() in
   js/product.js, which also swaps it for "Sold out" when that size
   cannot be bought.

   The negative margin tightens ONLY the label-to-price pair. .pd-size's
   own `gap` is 7.2px and applies between every pair of its children,
   which is right between the photo and the label but reads as two
   unrelated lines between a size and its price. 3px − 7.2px leaves 3px
   of real space, so the two read as one caption. */
.pd-size-price {
  display: block;
  margin-top: calc(3px - 7.2px);
  font-size: 13.2px;
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--text-secondary);
}
.pd-size-price.is-out { color: var(--text-muted); }

/* The control that reveals the rest of the certificate list. Phone tier
   only (see there); display:none up here takes it out of the
   accessibility tree too, which is right — this tier shows every
   certificate already, so there is nothing for it to reveal. */
.pd-tests-more { display: none; }

/* The grid card's own type, scaled down. Same family, weight, casing and
   negative tracking — just smaller, which is the whole brief. */
.fbt-name {
  font-family: 'Inter', sans-serif;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: -0.28px;
  line-height: 1.1;
  text-transform: uppercase;
  margin-bottom: 2px;
  color: #000;
}
.fbt-price {
  font-family: 'Helvetica', 'Helvetica Neue', var(--font-sans);
  font-size: 13.5px;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: #4d4f52;
}

@media (max-width: 1024px) {
  .fbt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Two columns now, so the square is twice as wide — and the lab-result
     rows, which take their height from it, have to follow or they would
     stay sized for a four-column tile that no longer exists. */
  .pd-tests, .fbt, .reviews {
    --pd-content-w: min(1120px, 100vw - 80px);
    --fbt-square: calc((var(--pd-content-w) - var(--grid-gap, 12px)) / 2);
  }
  /* At that width a square tile's worth of height is most of a phone
     screen for one certificate row. The rows go back to being sized by
     their own content here; the matched height is a desktop nicety, not
     something worth a screenful each for. */
  .pd-test { min-height: 0; align-items: center; }
  .pd-test-shot { width: 46px; height: 65px; }
}

/* ── Admin analytics ─────────────────────────────────────────
   A report, so the type is small and the numbers are tabular — but a
   report is still read faster with the top-line numbers set apart from
   the table they were added up from, which is what the stat cards and
   the chart below are for. */
.an-range {
  display: inline-flex; gap: 4px; margin-bottom: 22px; flex-wrap: wrap;
  padding: 4px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-secondary);
}
.an-range-btn { padding: 6px 13px; border-radius: 7px; font-weight: 600; }
.an-range-btn.is-on {
  background: var(--bg-primary);
  color: var(--text-primary);
  text-decoration: none;
  box-shadow: var(--shadow-xs, 0 1px 2px rgba(0,0,0,0.08));
}

/* Named for the two analytics tables it wraps (panel/admin.js) — every
   cell in .an-table is white-space:nowrap, so a wide one needs somewhere
   for the overflow to go besides the page itself. */
.scroller { overflow-x: auto; }
.an-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.an-table th, .an-table td {
  padding: 10px 12px 10px 0;
  text-align: left;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
.an-table th { font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-secondary); }
/* Every column but the first is a figure. */
.an-table td:not(:first-child), .an-table th:not(:first-child) { font-variant-numeric: tabular-nums; }
.an-table tbody tr:hover { background: var(--bg-secondary); }

/* Plain, inline stats (the per-product drilldown's Units/Revenue pair).
   .an-summary below is the card version of the same two classes, for
   the numbers that open the whole tab. */
.an-stats { display: flex; gap: 28px; margin: 14px 0 18px; flex-wrap: wrap; }
.an-stat { display: flex; flex-direction: column; gap: 2px; }
.an-stat-n { font-size: 24px; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary); }
.an-stat-l { font-size: 11px; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; color: var(--text-secondary); }
/* The three numbers this whole tab exists to answer, read in one line
   before the table anyone would otherwise have to add them up from. */
.an-summary { gap: 0; margin-bottom: 22px; }
.an-summary .an-stat {
  flex: 1 1 0;
  padding: 16px 20px;
  border: 1px solid var(--border);
  border-right: none;
}
.an-summary .an-stat:first-child { border-radius: var(--radius-md, 10px) 0 0 var(--radius-md, 10px); }
.an-summary .an-stat:last-child { border-right: 1px solid var(--border); border-radius: 0 var(--radius-md, 10px) var(--radius-md, 10px) 0; }
@media (max-width: 640px) {
  .an-summary { flex-direction: column; }
  .an-summary .an-stat { border-right: 1px solid var(--border); border-bottom: none; border-radius: 0 !important; }
  .an-summary .an-stat:first-child { border-top-left-radius: var(--radius-md, 10px) !important; border-top-right-radius: var(--radius-md, 10px) !important; }
  .an-summary .an-stat:last-child { border-bottom: 1px solid var(--border); border-bottom-left-radius: var(--radius-md, 10px) !important; border-bottom-right-radius: var(--radius-md, 10px) !important; }
}

/* ── The daily chart — dots joined by a line, a soft fill under it ──
   See lineChartSvg() in panel/admin.js for why this replaced a bar per
   day. The SVG's own viewBox is a fixed coordinate space (600×220);
   width:100% + preserveAspectRatio="none" is what lets it fill whatever
   width the card gives it without the browser doing its own object-fit
   letterboxing on a shape that was never meant to keep its own ratio. */
.an-chart {
  padding: 14px 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md, 10px);
  background: var(--bg-secondary);
  margin-bottom: 24px;
}
/* Blue line-and-dot on a plain white plot, dashed grid — asked for
   outright, matching a specific reference chart rather than the rest of
   this panel's own near-black/amber palette. Scoped entirely to this
   chart; nothing else in the admin panel uses this blue. */
.an-chart { background: var(--bg-primary); }
.an-chart-svg { display: block; width: 100%; height: 190px; overflow: visible; }
.an-chart-line { fill: none; stroke: #2451e0; stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.an-chart-dot { fill: #2451e0; stroke: #2451e0; stroke-width: 1; }
.an-chart-dot:hover { fill: #17359e; stroke: #17359e; }
/* The x/y axis lineChartSvg() draws — a plain baseline, dashed
   horizontal gridlines at each y-tick, a tick mark at every single day
   on the x-axis (only some of which get a text label), and the two
   axis titles. */
.an-chart-axis { stroke: var(--text-secondary); stroke-width: 1; }
.an-chart-grid { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 3; }
.an-chart-tick { stroke: var(--text-secondary); stroke-width: 1; }
.an-chart-axis-label {
  fill: var(--text-secondary);
  font-family: var(--font-sans);
  font-size: 11px;
  font-variant-numeric: tabular-nums;
}
.an-chart-axis-title {
  fill: var(--text-primary);
  font-family: var(--font-sans);
  font-size: 12px;
  font-weight: 700;
}

.an-funnel { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
.an-funnel-row {
  display: grid;
  grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) 4rem minmax(0, 13rem);
  align-items: center;
  gap: 12px;
  font-size: 13px;
}
.an-funnel-label { color: var(--text-primary); font-weight: 600; }
.an-funnel-track { height: 10px; border-radius: 5px; background: var(--bg-secondary); overflow: hidden; }
.an-funnel-fill { display: block; height: 100%; background: var(--text-primary); }
.an-funnel-n { font-variant-numeric: tabular-nums; font-weight: 700; text-align: right; }
.an-funnel-pct { font-size: 12px; color: var(--text-secondary); }

@media (max-width: 1024px) {
  .an-funnel-row { grid-template-columns: minmax(0, 1fr) auto; }
  .an-funnel-track, .an-funnel-pct { grid-column: 1 / -1; }
}

/* ── The post-order dialog ───────────────────────────────────
   Wider than the sign-in dialog it borrows its shell from: it carries a
   numbered sequence and a closing block, not two fields. */
.oc-modal {
  max-width: 520px;
  /* ── It has to be able to run out of room ──
     .auth-overlay centres its modal with flex, and a flex item that is
     taller than the line it sits on overflows in BOTH directions — off
     the top of the screen as well as the bottom. Nothing about that
     overflow is reachable: the overlay does not scroll (the body is
     locked while it is up) and the modal had no scroll of its own, so on
     a phone the last third of this dialog simply did not exist. It was
     the one screen on the site a customer cannot re-open by navigating
     back to it.

     Capped and made scrollable, which turns the overflow into something
     the thumb can reach. The copy has been cut hard as well (see
     js/order-complete.js) so on most phones there is nothing to scroll —
     this is the guarantee, not the plan.

     dvh, with vh underneath it for anything that does not know the unit:
     on a mobile browser vh is the tallest the viewport ever gets, with
     the address bar retracted, so a vh-only cap is a cap that is wrong by
     the height of the browser chrome for as long as that chrome is
     showing. 40px is the overlay's own 20px padding, top and bottom. */
  max-height: calc(100vh - 40px);
  max-height: calc(100dvh - 40px);
  overflow-y: auto;
  /* The close button is position:absolute against this box. Once the box
     scrolls, "absolute" pins it to the top of the CONTENT rather than to
     the top of the visible frame — so it scrolls away with everything
     else. Which is correct here: the button belongs to the header row
     beside the logo, and a customer who has scrolled down has the whole
     page behind the overlay to click on instead. Noted so a later pass
     does not "fix" it into a floating control that overlaps the copy. */
  overscroll-behavior: contain;
}
/* Centred, with the lede under it — the two together are the dialog's
   announcement, and an announcement that runs to the left margin while
   the logo above it sits centred reads as two unrelated blocks rather
   than one masthead. Everything BELOW them (the numbered steps, the
   sign-up offer) stays left-aligned, because those are things to read
   and act on rather than things to be told. */
.oc-heading { text-align: center; }
.oc-lede {
  margin: 0 0 20px;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--text-secondary);
  text-align: center;
}

/* The three beats. Numbered because they are genuinely a sequence — this
   happens, then this, then this — which is the one case a numbered list
   earns its numbers. */
.oc-steps {
  list-style: none;
  margin: 0 0 18px;
  padding: 0;
  display: flex;
  flex-direction: column;
  /* Was 16px, sized for steps that were each a small paragraph. They are
     one line each now, and a gap taller than the items it separates
     stops reading as a list. */
  gap: 11px;
}
.oc-step { display: flex; gap: 13px; align-items: flex-start; }
.oc-step-num {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--text-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.oc-step-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.oc-step-title { font-size: 14px; font-weight: 700; color: var(--text-primary); }
.oc-step-text { font-size: 13px; line-height: 1.55; color: var(--text-secondary); }

/* The bordered closing block that used to sit here — "you ordered as a
   guest" / "saved to your orders" — is gone, asked for outright. Its
   content is a clause inside step 1 now (stepsHtml in
   js/order-complete.js); .oc-credit below is the only part that needed a
   home of its own. */

/* A step's text may carry a link or an <strong> now that the closing
   block's copy lives inside one. */
.oc-step-text a { color: var(--text-primary); font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
.oc-step-text strong { color: var(--text-primary); font-weight: 700; }

/* ── Pre-order ──
   Amber, alone on this dialog. Everything else here confirms something
   that has already happened; this is the one block that changes what the
   customer should EXPECT, and it has to survive being skimmed by someone
   who has already decided the screen says "done". */
.oc-preorder {
  padding: 14px 16px;
  margin-bottom: 12px;
  border: 1px solid #f0d488;
  border-radius: var(--radius-md, 12px);
  background: #fefbf0;
}
.oc-preorder-title {
  margin: 0 0 5px;
  font-size: 14px; font-weight: 800;
  color: #7a5a10;
}
.oc-preorder-body { margin: 0; font-size: 13.5px; line-height: 1.55; color: var(--text-secondary); }
.oc-preorder-body strong { color: var(--text-primary); }


/* Centred to close the dialog the way the logo and title opened it, so
   the block has a symmetrical top and bottom rather than trailing off to
   the left. Bumped a little above the old 12.5px: it is the only route to
   a human on this screen, and it was set at the size of a disclaimer. */
.oc-foot {
  margin: 16px 0 0;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--text-secondary);
  text-align: center;
}
.oc-foot a { color: var(--text-primary); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }

/* ── The 5% credit line ──
   What is left of the closing box after its content moved into the steps
   (see stepsHtml in js/order-complete.js). A signed-in customer's store
   credit is not a step — nothing happens next about it — so it is a
   single quiet line under the sequence rather than a fourth numbered
   item or a panel of its own. */
.oc-credit {
  margin: -6px 0 18px;
  padding-left: 37px;   /* the step number's 24px box plus its 13px gap */
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-muted);
}
.oc-credit strong { color: var(--text-primary); }

/* ── The delivery map ──
   Square, asked for outright, and square is also what the Static Maps
   request asks Google for — a ratio mismatch between the box and the
   image would letterbox a photograph of a suburb, which looks like a
   loading state.

   aspect-ratio rather than a fixed height so it stays square at every
   width; the image fills it with object-fit in case the API ever returns
   something off-ratio. Not interactive and not a link: it answers "right
   part of town?" and nothing else, so it takes no taps and steals no
   scrolls from the dialog it sits in. */
.oc-map {
  margin: 0 0 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md, 12px);
  overflow: hidden;
  background: var(--bg-secondary);
}
.oc-map img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  /* No pointer events: a static map that highlights on drag looks
     draggable and is not. */
  pointer-events: none;
  user-select: none;
}
.oc-map-caption {
  padding: 10px 14px;
  border-top: 1px solid var(--border);
  font-size: 12.5px;
  line-height: 1.45;
  color: var(--text-secondary);
  background: var(--bg-primary);
}
.oc-map-caption strong { color: var(--text-primary); }

/* ── Phone: the dialog IS the screen ────────────────────────
   Asked for outright, and the same argument as the age gate above. This
   one has the stronger case: it is the screen a customer lands on
   straight after paying, it is not re-openable by navigating back to it,
   and it now carries a map as well as the steps — so it is both the most
   important thing to be able to read in full and the tallest.

   Full bleed, square, no dim, scrolling internally. The scroll is the
   point rather than a fallback: with the map added there is more here
   than a short phone will show at once, and "scrolled up or down" is
   what was asked for.

   100dvh, not vh. vh on a mobile browser is measured with the browser
   chrome retracted — its largest value — so a vh-sized panel is taller
   than the visible viewport for as long as Safari's bottom bar is
   showing, and the bottom of it sits underneath that bar. dvh tracks the
   bar in and out. The safe-area inset does the same job for the home
   indicator, which never retracts. */
@media (max-width: 768px) {
  .oc-overlay {
    padding: 0;
    align-items: stretch;
    justify-content: stretch;
    background: var(--bg-primary);
  }
  .oc-modal {
    max-width: none;
    width: 100%;
    height: 100vh;
    height: 100dvh;
    max-height: none;
    border-radius: 0;
    box-shadow: none;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 26px 20px calc(26px + env(safe-area-inset-bottom, 0px));
  }
  .oc-modal .auth-logo { height: 30px; margin-bottom: 16px; }
  /* Keeps clear of the close button, which sits in the same row and is
     positioned off the modal's own padding. It scrolls away with the
     content by design — see the note on .oc-modal's overflow. */
  .oc-modal .auth-close { top: 24px; right: 16px; }
  .oc-heading { font-size: 22px; margin-bottom: 8px; padding: 0 34px; }
  .oc-lede { font-size: 13.5px; margin-bottom: 18px; }
  .oc-step-title { font-size: 13.5px; }
  .oc-step-text { font-size: 12.5px; }
  .oc-credit { font-size: 12.5px; }
  .oc-preorder-body { font-size: 13px; }
  .oc-foot { font-size: 13px; margin-top: 20px; }
}

/* ── An empty tab ──
   The Orders tab opens on nothing until the customer has bought
   something, and used to say so in one grey sentence with the way out
   buried in it as a link. The one useful action available is stated as
   a button instead. */
.account-empty {
  border: 1px dashed var(--border-accent);
  border-radius: var(--radius-lg);
  padding: 34px 28px;
  text-align: center;
  background: var(--bg-secondary);
}
.account-empty-title {
  margin: 0 0 6px;
  font-size: 16px;
  font-weight: 800;
  letter-spacing: -0.01em;
  color: var(--text-primary);
}
.account-empty-body {
  margin: 0 auto 18px;
  max-width: 44ch;
  font-size: 14px;
  line-height: 1.6;
  color: var(--text-secondary);
}
.account-empty-cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: auto;
  max-width: 260px;
  margin: 0 auto;
  padding-inline: 26px;
  text-decoration: none;
}

/* ── Back to the grid ──
   Sits where the breadcrumb trail used to. A control, not a path.

   The same object as the home page's .op-link: an underlined run of text
   with an arrow that slides on hover, rather than the small grey uppercase
   label this used to be. Those links are the site's established way of
   saying "this takes you somewhere", and a page's one navigational exit
   should not be quieter than the links inside a marketing section.

   One size up from .op-link's 15px is wrong here — this sits directly
   above a 40px product title and would compete with it — so it takes
   14.5px, which is large enough to read as a control at a glance and
   still clearly subordinate to the name below it. The arrow mirrors:
   it leads the text and slides LEFT, because that is the direction it
   points and the direction it goes. */
.pd-back {
  align-self: flex-start;
  display: inline-block;
  margin-bottom: 14px;
  font-size: 14.5px;
  font-weight: 600;
  color: var(--text-primary);
  text-decoration: underline;
  text-decoration-color: var(--border);
  text-underline-offset: 5px;
  padding: 6px 0 4px;
  transition: text-decoration-color 0.2s ease, color 0.2s ease;
}
.pd-back:hover { text-decoration-color: var(--text-primary); }
.pd-back:hover .pd-back-arrow { transform: translateX(-4px); }
/* The 4px it travels, reserved inside the decorated run — so the arrow
   lands on the start of its own underline rather than overshooting it.
   Mirror of .op-link span's padding-right, for the mirrored direction. */
.pd-back-arrow {
  display: inline-block;
  padding-left: 4px;
  transition: transform 0.2s ease;
}
.pd-back:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 3px; border-radius: 2px; }

/* ── Three rows removed from the info column, at every width ──
   Asked for outright, first on the phone tier and now everywhere: the
   back link, the stock line and the standalone price all go, and the
   price is stated per SIZE instead (see .pd-size-price).

   Placed HERE, at the end of the product-detail rules, and not up beside
   .pd-name where it reads more naturally: .pd-back and .pd-stock each
   have their own later rule setting `display` (inline-flex and flex),
   at the same one-class specificity as this. Declared earlier this lost
   to both of them on document order alone and only the price actually
   disappeared.

   display:none rather than deleting them from productDetailHtml() in
   js/product.js: refresh() writes into .pd-stock and .pd-price by
   data-attribute on every size change, and the elements have to exist
   for that to keep working. Taking them out of the accessibility tree
   as well is deliberate — a reader should not hear a price the page has
   decided not to show. Stock is still communicated where it decides
   something: a sold-out size says so on its own tile in words, its chip
   is disabled, and .pd-notify replaces the buy controls outright. */
.pd-back,
.pd-stock,
.pd-price { display: none; }

/* ============================================================
   CART PAGE
   ============================================================ */
.cart-layout { display: grid; grid-template-columns: 1fr 360px; gap: 48px; align-items: start; }
.cart-lines {
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  background: var(--bg-primary);
  box-shadow: var(--shadow-sm);
  padding: 4px 24px;
}
/* Three columns, mirroring the checkout summary's arrangement: photo,
   copy, price hard right. The price used to sit inside the copy column,
   which is why the two lists never looked like the same thing. */
.cart-line {
  display: grid;
  grid-template-columns: 132px 1fr auto;
  gap: 24px;
  align-items: center;
  padding: 26px 0;
  border-bottom: 1px solid var(--border-soft);
}
.cart-lines .cart-line:last-child { border-bottom: none; }

/* ── The product shot ──
   The same treatment as the checkout summary, scaled up: no frame, no
   fill behind it, the bottle sitting directly on the page with a soft
   drop shadow so a white vial on a white background still reads as an
   object. This is the page where someone decides whether to buy, so the
   product gets the room.

   No padding: the box WAS 92px square with 12px inset, which meant a 1:2
   bottle rendered 68px tall inside a 92px slot — most of the space went
   to nothing. Taller than it is wide instead, matching the crop. */
.cart-line-img {
  position: relative;   /* for the quantity badge */
  width: 132px; height: 156px;
  display: flex; align-items: center; justify-content: center;
}

/* The summary's quantity badge, at this page's scale. Reading a quantity
   off a number in the corner of the photo is the thing the summary got
   right — the full cart had it only inside the stepper, which is a
   control rather than a statement. */
.cart-line-qty-badge {
  position: absolute;
  top: 4px; right: 4px;
  min-width: 24px; height: 24px;
  padding: 0 6px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 999px;
  background: var(--text-primary);
  color: #fff;
  font-size: 12px; font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.cart-line-img img {
  max-width: 100%; max-height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 4px 12px rgba(10, 10, 12, 0.16));
  transition: transform var(--transition);
}
/* The whole image is a link to the product. A small lift on hover says so
   without adding a border to something deliberately unframed. */
.cart-line-img:hover img { transform: translateY(-2px) scale(1.02); }

@media (max-width: 640px) {
  .cart-line { grid-template-columns: 92px 1fr; gap: 16px; }
  .cart-line-img { width: 92px; height: 110px; }
  /* The price drops under the copy rather than squeezing a third column
     onto a phone. */
  .cart-line-price { grid-column: 2; align-items: flex-start; margin-top: 10px; }

  /* ── The stepper and remove button, fixed to their own sizes ──
     .cart-line-meta is flex with no flex-shrink set on either child, so
     once the narrower 92px image column above left only ~184px for the
     whole info column, both were shrinking to fit it — and
     .card-qty-selector's own natural width turned out to be 232px (its
     middle grid track, a bare <input>, has no width cap of its own and
     was sizing off the number input's default intrinsic width once
     nothing constrained it), nearly the entire 184px on its own. The
     browser's default flex-shrink:1 compressed both proportionally,
     which is what took the remove button — width:36px, height:36px in
     its own rule — down to a measured 23×36 rectangle: not shrunk
     equally on both axes, just squashed on the one axis flex was
     fighting over. Fixed here rather than solved by giving the row more
     room: flex-shrink:0 on both stops either one from compressing, and
     .cart-line-qty's own width is capped to 108px (36 + 36, the same
     as its own two flanking buttons — a properly proportioned middle
     column instead of the intrinsic default) so the pair actually fits
     the 184px available with room to spare, rather than needing to
     shrink at all.

     .cart-line-meta .cart-line-qty, not a bare .cart-line-qty: an
     unconditional `.cart-line-qty { width: auto; }` sits further down
     this file (outside any media query) at the SAME specificity as a
     single-class selector here would have, and being later in the
     file it would win regardless of this rule sitting inside a
     ≤640px block — a media query changes whether a rule applies, not
     where it sorts in the cascade once it does. Qualifying with the
     parent is what makes this selector actually win on its own
     specificity instead of needing to out-order that rule. */
  .cart-line-meta .cart-line-qty {
    width: 108px;
    flex-shrink: 0;
  }
  .cart-line-remove {
    flex-shrink: 0;
  }
}
.cart-line-info { min-width: 0; }
.cart-line-name {
  font-size: 16px;
  font-weight: 800;
  letter-spacing: -0.01em;
  text-transform: uppercase;
  color: inherit;
}
/* Size and bulk discount on one quiet line under the name — the
   summary's subtitle, not three separate chips. */
.cart-line-sub {
  margin-top: 4px;
  font-size: 13.5px;
  color: var(--text-muted);
}
.cart-line-tag { display: inline-block; margin-top: 8px; }

/* The price column: the charged figure, with the original struck through
   beneath it. Same stack as the summary's. */
.cart-line-price {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
  font-size: 17px;
  font-weight: 800;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.cart-line-was {
  font-size: 13px;
  color: var(--text-muted);
  text-decoration: line-through;
  font-weight: 500;
}
.cart-line-meta { display: flex; align-items: center; gap: 16px; margin-top: 14px; }
.cart-line-qty { width: auto; }
.cart-line-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: none;
  color: #b42318;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.cart-line-remove svg { width: 17px; height: 17px; }
.cart-line-remove:hover { background: #fdf0ee; border-color: #f3c6bd; }

.cart-summary {
  position: sticky;
  /* Clears the fixed nav AND the standing-offer strip above it. */
  top: calc(108px + var(--promo-h));
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  padding: 26px;
  background: var(--bg-primary);
  box-shadow: var(--shadow-md);
}
.cart-summary h2 { font-size: 13px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 20px; }
.summary-row { display: flex; justify-content: space-between; font-size: 14px; color: var(--text-secondary); margin-bottom: 13px; }
/* The Shipping row when the free-express meter hangs off it — the gap
   below belongs to the meter, not to the row. */
.summary-row:has(+ .ship-meter) { margin-bottom: 9px; }
/* display:flex above beats the UA's [hidden]{display:none}, so a row
   toggled with the `hidden` attribute stayed on screen — which is how a
   "Store credit −$0.00" line showed on orders using none. */
.summary-row[hidden] { display: none; }
.summary-row span:last-child { font-weight: 600; color: var(--text-primary); }
.summary-total { display: flex; justify-content: space-between; align-items: baseline; font-size: 22px; font-weight: 800; letter-spacing: -0.02em; color: var(--text-primary); padding-top: 16px; border-top: 1px solid var(--border); margin-bottom: 20px; }
.cart-summary .btn-cart { height: 48px; font-size: 14px; }
.btn-checkout { text-decoration: none; }


/* ============================================================
   CHECKOUT PAGE — Stripe's hosted checkout, on our own origin
   ------------------------------------------------------------
   Two columns that each own their half of the viewport: what is being
   paid for on the left, what has to be filled in on the right. Asked
   for outright, against a screenshot of Stripe's own page, and the
   reason is not imitation for its own sake — this is the shape a
   customer has already been taught to read by every other checkout
   built on Stripe, and a payment page is the last place to make
   somebody learn a new one.

   The page has no nav and no footer (see checkout.html), so these two
   columns ARE the page: .co is the full viewport height and each side
   scrolls within it rather than the document scrolling as a whole.
   Below the breakpoint that collapses to one column with the left side
   folded into a summary bar at the top, which is Stripe's phone
   layout.
   ============================================================ */
.co-body { background: var(--bg-primary); }
.co {
  display: grid;
  grid-template-columns: 1fr 1fr;
  min-height: 100vh;
  min-height: 100dvh;
}

/* ── Left: what is being paid for ──
   Right-aligned content against a shared centre line, so the two
   columns read as one page split down the middle rather than two
   panels sitting next to each other. Both sides are the same white and
   the hairline between them is the only separation — which is what the
   reference does, and what stops the summary reading as a sidebar
   bolted onto a form. */
.co-left {
  display: flex;
  flex-direction: column;
  padding: clamp(28px, 4vw, 56px) clamp(20px, 4vw, 60px);
  border-right: 1px solid var(--border);
  background: var(--bg-primary);
}
.co-left-inner, .co-left-scroll, .co-legal { width: 100%; max-width: 420px; margin-left: auto; }
.co-left-scroll { flex: 1 1 auto; }

.co-head { display: flex; align-items: center; gap: 12px; margin-bottom: clamp(24px, 4vw, 40px); }
.co-back {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; flex: 0 0 auto;
  padding: 0; border: none; border-radius: 50%;
  background: none; color: var(--text-muted);
  cursor: pointer;
  transition: color var(--transition), background var(--transition);
}
.co-back:hover { color: var(--text-primary); background: var(--bg-card-hover); }
.co-back svg { width: 18px; height: 18px; }
.co-brand { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }
.co-brand-mark { width: 28px; height: 28px; border-radius: 50%; flex: 0 0 auto; }
.co-brand-name { font-size: 15px; font-weight: 700; color: var(--text-primary); }

.co-payto { font-size: 14px; font-weight: 500; color: var(--text-secondary); margin-bottom: 4px; }
/* The one number the page is about, at the size Stripe sets it. */
.co-hero {
  font-size: clamp(32px, 4vw, 38px);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text-primary);
  margin-bottom: clamp(24px, 3vw, 34px);
  font-variant-numeric: tabular-nums;
}

/* ── The line items ──
   Deliberately its own .co-item family rather than a restyle of
   .summary-item: that one is shared with the cart drawer
   (js/cart-drawer.js), which wants the old photo-with-qty-badge row. The
   two layouts have nothing in common beyond both listing a cart, so they
   get one class family each instead of one family with two personalities.

   align-items: center is what puts a single price figure level with the
   middle of the two-line block beside it — at qty 1 there is one figure
   and it centres; above qty 1 there are two and they stack. No rule
   distinguishes the cases, so neither can drift from the other. */
.co-items { display: flex; flex-direction: column; gap: 22px; margin-bottom: 26px; }
.co-item { display: flex; align-items: center; gap: 14px; }
/* No frame, and no crop — the whole bottle, at its own proportions, not
   a square cut out of it. A fixed WIDTH would still crop a tall bottle
   at that width's height, so the box is sized by the photo (height
   fixed, width along for the ride) with object-fit: contain, the same
   approach the diluent upsell photo already used. overflow isn't hidden
   here because nothing is meant to be clipped. */
.co-item-img {
  flex: 0 0 auto; width: 44px; height: 56px;
  display: flex; align-items: center; justify-content: center;
}
.co-item-img img {
  width: 100%; height: 100%;
  object-fit: contain; object-position: center; display: block;
}
.co-item-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.co-item-name {
  font-size: 14px; font-weight: 600; line-height: 1.35;
  color: var(--text-primary);
}
.co-item-qty { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-muted); }
/* A plain-text control, not a bordered field — "Qty" in the same muted
   grey as everything else this small, then the number itself set bold
   so it reads as the one part of the row you can actually change. No
   box to carry that distinction instead: just weight, and the chevron
   that says it opens. */
.co-item-qty-select {
  appearance: none; -webkit-appearance: none;
  padding: 0 16px 0 0;
  border: none; border-radius: 3px;
  background: transparent no-repeat right center / 9px 6px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 7'%3E%3Cpath d='M1 1.5 5 5.5 9 1.5' fill='none' stroke='%23767676' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  font: inherit; font-size: 12.5px; font-weight: 700; line-height: 1.2;
  color: var(--text-primary); cursor: pointer;
}
.co-item-qty-select:focus-visible {
  outline: 2px solid var(--text-primary); outline-offset: 2px;
}
.co-item-price {
  flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
  text-align: right; font-variant-numeric: tabular-nums;
}
.co-item-total { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.co-item-was { margin-left: 6px; font-weight: 400; color: var(--text-muted); }
.co-item-each { font-size: 12.5px; color: var(--text-muted); }

/* ── The totals ──
   Three bands — goods, shipping, total due — each cut off from the next
   by a hairline, the way Stripe stacks them. The optional rows (bank
   transfer, promo, store credit) ride with the band they belong to, so a
   hidden one takes no divider with it.

   .co-totals-inner carries the indent AND the hairlines: 58px is an
   item's photo (44px) plus the gap beside it (14px), so "Subtotal" lines
   up under a product's NAME rather than under its photo, and every
   divider — this one, the one above Shipping, the one above Total due —
   starts at that same point instead of running the summary's full width
   the way a divider under the photo column would. */
.co-totals { display: flex; flex-direction: column; }
.co-totals-inner {
  display: flex; flex-direction: column;
  margin-left: 58px; padding-top: 20px;
  border-top: 1px solid var(--border);
}
.co-totals .summary-row { font-size: 14px; margin-bottom: 12px; }
/* Stripe puts the service under the word rather than beside the price —
   "Shipping / Ground shipping (3-5 business days)". */
.co-ship-row { padding-top: 14px; border-top: 1px solid var(--border); }
.co-ship-row > span:first-child { display: flex; flex-direction: column; gap: 2px; }
.co-ship-note { font-style: normal; font-size: 12.5px; color: var(--text-muted); }
.co-total-due {
  display: flex; justify-content: space-between; align-items: baseline;
  margin-top: 12px; padding-top: 14px;
  border-top: 1px solid var(--border);
  font-size: 15px; font-weight: 700; color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.co-legal {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin-top: 32px; padding-top: 20px;
  font-size: 12px; color: var(--text-muted);
}
.co-legal a { color: var(--text-muted); }
.co-legal a:hover { color: var(--text-primary); text-decoration: underline; }
.co-legal-sep { color: var(--border-accent); }

/* ── Right: the form ── */
.co-right {
  padding: clamp(28px, 4vw, 56px) clamp(20px, 4vw, 60px);
  background: var(--bg-primary);
}
.co-right #checkout-form { width: 100%; max-width: 420px; margin-right: auto; }
.co-fieldset { margin-bottom: 34px; }
.co-fieldset[hidden] { display: none; }
/* margin-bottom was 4px, sized for the description paragraph that used
   to sit directly under it. That's gone (or empty) under Payment
   details now, and Shipping information never had one, so the heading
   was landing right on top of the first field either way — widened to
   give it room to read as its own line before the form starts. */
.co-h { font-size: 16px; font-weight: 700; letter-spacing: -0.01em; color: var(--text-primary); margin-bottom: 18px; }
.co-sub { font-size: 13px; line-height: 1.5; color: var(--text-muted); margin-bottom: 18px; }
/* Empty by default now that the static description under "Payment
   details" is gone — this only holds real, momentary status text
   (bank-transfer instructions, "Preparing secure payment…"), and an
   empty paragraph shouldn't hold its margin open when it has nothing
   to say. */
.co-sub:empty { display: none; }
.co-label { font-size: 13px; font-weight: 500; color: var(--text-secondary); margin: 18px 0 6px; }

/* ── The two field shapes ──
   .co-field is a standalone box with its label above; .co-group is
   Stripe's joined box, where the cells share one border and are told
   apart by hairlines. Both use .co-input, so a focus ring, an error
   state or a font change lands on every field on the page at once. */
.co-field { display: block; margin-bottom: 14px; }
.co-field-label { display: block; font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
.co-opt { color: var(--text-muted); font-weight: 400; }
.co-input {
  width: 100%;
  padding: 11px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-primary);
  font-family: var(--font-sans);
  font-size: 15px;
  color: var(--text-primary);
  box-shadow: 0 1px 1px rgba(10, 10, 12, 0.04);
  transition: border-color var(--transition), box-shadow var(--transition);
}
.co-input::placeholder { color: var(--text-muted); }
.co-input:hover { border-color: var(--border-accent); }
.co-input:focus {
  outline: none;
  border-color: var(--text-primary);
  /* The ring Stripe draws: the border darkens and a soft halo of the
     same colour sits outside it, rather than a browser outline that
     would square off the 6px corners. */
  box-shadow: 0 0 0 3px rgba(10, 10, 12, 0.12);
}
.co-input:disabled { background: var(--bg-secondary); color: var(--text-muted); cursor: not-allowed; }
.co-input.is-invalid { border-color: #b42318; }
textarea.co-input { resize: vertical; min-height: 68px; line-height: 1.5; }
.co-select { appearance: none; cursor: pointer;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a8a93' stroke-width='3'><polyline points='6 9 12 15 18 9'/></svg>");
  background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }

/* One bordered box, hairlines between the cells: the address block reads
   as one thing to fill in rather than a column of separate questions.
   The cells drop their own border and radius and the box supplies both;
   overflow:hidden is what makes the first and last rows take the box's
   own rounded corners without either of them being told about it. */
.co-group {
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
  box-shadow: 0 1px 1px rgba(10, 10, 12, 0.04);
}
.co-group .co-input {
  border: none;
  border-radius: 0;
  box-shadow: none;
  border-bottom: 1px solid var(--border);
}
/* :focus has to sit above its neighbours or the ring is clipped by the
   cell below it — the box's own overflow:hidden would otherwise cut it
   in half. */
.co-group .co-input:focus { position: relative; z-index: 1; box-shadow: 0 0 0 3px rgba(10, 10, 12, 0.12); }
/* Only the box's actual bottom edge loses its hairline — not everything
   inside whichever element happens to be .co-group's last DOM child.
   .addr-fields is display:contents (see its own rule), so it draws no
   box of its own, but `*:last-child` still matched it AS a DOM node,
   and `*:last-child .co-input` then meant every field anywhere inside
   it — line 1, line 2, the lot — leaving every hairline in the address
   block missing at once. These four selectors name the field that is
   actually rendered last at each depth .co-group is used at, instead. */
.co-group > .co-input:last-child,
.co-group > .co-group-row:last-child .co-input,
.co-group > .addr-fields > .co-input:last-child,
.co-group > .addr-fields > .co-group-row:last-child .co-input {
  border-bottom: none;
}
.co-group-row { display: flex; }
.co-group-row .co-input { flex: 1 1 0; min-width: 0; border-right: 1px solid var(--border); }
.co-group-row .co-input:last-child { border-right: none; }
/* Australia only, and stated rather than asked — see coCell's own note
   in js/checkout.js. Styled as a filled row so it reads as an answer
   already given, not a field someone has failed to fill in. */
.co-country {
  padding: 11px 12px;
  border-bottom: 1px solid var(--border);
  background: var(--bg-secondary);
  font-size: 15px;
  /* Every cell around this one is an <input>, and a form control's line
     box follows the UA's own default rather than the page's inherited
     line-height: 1.6 — a plain <div> like this one DOES inherit it,
     which was making its row measurably taller than the fields above
     and below it despite identical padding and font-size. Pinned to the
     same "normal" a bare input renders at so the row matches. */
  line-height: normal;
  color: var(--text-secondary);
}
.co-billing { margin: 12px 0 18px; }

/* Everything a parcel does not strictly need, behind one line. */
.co-more { margin-top: 16px; }
.co-more > summary {
  cursor: pointer; list-style: none;
  font-size: 13px; font-weight: 600; color: var(--text-secondary);
  padding: 4px 0;
}
.co-more > summary::-webkit-details-marker { display: none; }
.co-more > summary::before { content: '+ '; color: var(--text-muted); }
.co-more[open] > summary::before { content: '\2212 '; }
.co-more > summary:hover { color: var(--text-primary); }
.co-more-body { padding-top: 12px; }

.co-check { display: flex; align-items: flex-start; gap: 10px; margin: 16px 0; font-size: 13.5px; color: var(--text-secondary); cursor: pointer; }
.co-check input { margin-top: 2px; flex: 0 0 auto; accent-color: var(--text-primary); width: 16px; height: 16px; }

/* Wallets, above the card form and above everything else on the page —
   someone who can pay with a thumbprint should not have to scroll past
   a form to find that out. Hidden until Stripe says a wallet exists. */
.co-express { margin-bottom: 26px; }
.co-express[hidden] { display: none; }
.co-or {
  display: flex; align-items: center; gap: 14px;
  margin-top: 22px;
  font-size: 13px; color: var(--text-muted);
}
.co-or::before, .co-or::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--border); }

/* The one button on the page at any moment: Continue while the details
   are being filled in, Pay once the card form is up. */
.co-pay {
  display: flex; align-items: center; justify-content: center;
  width: 100%; min-height: 44px;
  margin-top: 20px; padding: 0 18px;
  border: none; border-radius: 6px;
  background: var(--text-primary); color: var(--bg-primary);
  font-family: var(--font-sans); font-size: 15px; font-weight: 600;
  cursor: pointer;
  transition: background var(--transition), opacity var(--transition);
}
.co-pay:hover:not(:disabled) { background: #2a2a30; }
.co-pay:disabled { opacity: 0.45; cursor: not-allowed; }
.co-pay[hidden] { display: none; }

.co-fineprint { margin-top: 14px; font-size: 11.5px; line-height: 1.55; color: var(--text-muted); }
.co-fineprint strong { font-weight: 600; color: var(--text-secondary); }

/* The whole form goes quiet while a priced payment form is mounted —
   an edit made underneath it is exactly the mismatch that dance exists
   to prevent. The payment block itself stays live. */
#checkout-form.is-locked .co-fieldset:not(:has(.pay-embed)) { opacity: 0.55; }
#checkout-form.is-locked .co-fieldset:not(:has(.pay-embed)) * { cursor: default; }

/* ── The phone layout ──
   One column, with the left panel folded to the peek bar at the top —
   Stripe's own phone arrangement. .is-folded is set by js/checkout.js
   on first paint and toggled by the bar. */
@media (max-width: 900px) {
  .co { grid-template-columns: 1fr; min-height: 0; }
  .co-left {
    border-right: none;
    border-bottom: 1px solid var(--border);
    padding: 18px clamp(16px, 5vw, 28px);
  }
  .co-left-inner, .co-left-scroll, .co-legal { max-width: none; margin-left: 0; }
  .co-head { margin-bottom: 14px; }
  /* The peek bar carries the price on a phone, so the hero pair below
     it would be the same number twice. */
  .co-payto, .co-hero { display: none; }
  .co-left.is-folded .co-left-scroll { display: none; }
  .co-legal { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-soft); }
  .co-left.is-folded .co-legal { display: none; }
  .co-right { padding: 24px clamp(16px, 5vw, 28px) 48px; }
  .co-right #checkout-form { max-width: none; }
}
/* Above the fold breakpoint there is nothing to fold: the panel is a
   column and the bar has no job. */
@media (min-width: 901px) {
  .co-left .summary-peek { display: none; }
}

.btn-place-order { margin-top: 20px; }
.field { display: block; }
.field.full { grid-column: 1 / -1; }
.field > span { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.field .req { color: #b42318; }
.field .opt { color: var(--text-muted); font-weight: 500; }

/* ── Store credit ──
   The one payment SOURCE this site owns, rather than a route like card
   or bank transfer — a single tappable line, not a card with its own
   explanation underneath: "Use store credit $X available" is the whole
   decision, the tick is right there to make it, and what it does (comes
   off the total, can't combine with a promo code) is still true and
   still enforced in JS, just no longer stated here.

   margin-bottom is deliberately generous — this sits right above the
   actual choice of how to pay (.pay-methods below), and the two want
   visible daylight between them so "tick this if you like" doesn't read
   as step one of "choose bank or card". */
.pay-option {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  margin-bottom: 30px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: border-color var(--transition), background var(--transition);
}
.pay-option:hover { border-color: var(--border-accent); }
/* The whole row reacts to the tick, so "applied" is visible from across
   the page rather than only in the 18px box. */
.pay-option:has(input:checked) { border-color: var(--accent-bright); background: var(--bg-secondary); }
.pay-option input { accent-color: var(--accent-bright); width: 18px; height: 18px; flex-shrink: 0; }
.pay-option-label {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px;
  min-width: 0;
  font-weight: 700; font-size: 15px; color: var(--text-primary);
}
.pay-option-amount { font-size: 13px; font-weight: 700; color: #15803d; }

.pay-accepted {
  display: flex; align-items: center; gap: 12px;
  margin-top: 16px; padding-top: 16px;
  border-top: 1px solid var(--border-soft);
}

.pay-logos { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.pay-logos img { height: 22px; }

/* ── Choosing how to pay ──────────────────────────────────────
   Two cards, not two buttons. Card and bank transfer do genuinely
   different things — one settles now, the other reserves stock and waits
   — so the difference has to be readable before the choice is made, not
   discovered after it. */
.pay-methods {
  display: grid;
  gap: 10px;
  margin-bottom: 16px;
}

.pay-method {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  padding: 14px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md, 12px);
  cursor: pointer;
  transition: border-color var(--transition), background var(--transition);
}
.pay-method:hover { border-color: var(--text-muted); }
/* :has on the label, so the whole card carries the selected state rather
   than just the radio inside it. */
.pay-method:has(input:checked) {
  border-color: var(--text-primary);
  background: var(--bg-secondary);
}
.pay-method:has(input:focus-visible) {
  outline: 2px solid var(--text-primary);
  outline-offset: 2px;
}

.pay-method input {
  flex: 0 0 auto;
  width: 17px;
  height: 17px;
  margin: 2px 0 0;
  accent-color: var(--text-primary);
}

.pay-method-body { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
/* Title and its "Save 5%" callout on one baseline, wrapping if the row
   runs out of room — the same layout .pay-option-label already uses for
   Store credit's own "$X available", so the two green-amount treatments
   on this page read as one pattern rather than two similar ones. */
.pay-method-title-row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 8px;
}
.pay-method-title { font-size: 14.5px; font-weight: 700; color: var(--text-primary); }
/* Exactly .pay-option-amount's own rule, restated rather than shared via
   a joined selector — the two live in unrelated components (a payment
   method card here, a checkbox option there) that only happen to want
   the same green figure treatment. */
.pay-method-amount { font-size: 13px; font-weight: 700; color: #15803d; }
.pay-method .pay-logos { margin-top: 3px; }
.pay-method .pay-logos img { height: 18px; }
/* Shown, not removed. A payment method that disappears between visits
   looks like a bug; one that is visibly greyed reads as a temporary
   state of the shop, which is what it is. */
.pay-method.is-unavailable {
  opacity: 0.5;
  cursor: not-allowed;
  background: var(--bg-secondary);
}
.pay-method.is-unavailable:hover { border-color: var(--border); }
/* `display: flex` on .pay-method above beats the UA stylesheet's
   `[hidden] { display: none }` — same specificity, later origin — so a
   payment method taken off the menu (applyBankAvailability in
   js/checkout.js sets `hidden` on it) stayed perfectly visible. Restated
   here at a specificity the base rule cannot win. */
.pay-method[hidden] { display: none; }

/* ── The bank-transfer screen ─────────────────────────────────
   Reached only after the order has been recorded, so this is a receipt
   with instructions on it rather than a step that can be gone back on. */
.pay-bank {
  margin-top: 18px;
  padding: 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md, 12px);
  background: var(--bg-secondary);
}
.pay-bank[hidden] { display: none; }

.bank-intro {
  margin: 0 0 14px;
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--text-secondary);
}

.bank-details {
  display: grid;
  gap: 1px;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}

/* A three-column row: label, value, copy. The value is the column that
   grows, and it is the one set in tabular figures — a BSB and an account
   number are read digit by digit, and proportional numerals make that
   harder than it needs to be. */
.bank-row {
  display: grid;
  grid-template-columns: minmax(0, 8.5rem) minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  padding: 11px 13px;
  background: var(--bg-primary);
}
.bank-row-label {
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-secondary);
}
.bank-row-value {
  font-size: 14.5px;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}
/* The amount and the reference are the two a mistake is unrecoverable
   on, so they are the two given weight. */
.bank-row.is-strong .bank-row-value { font-size: 17px; font-weight: 700; }

.bank-copy {
  padding: 5px 11px;
  border: 1px solid var(--border);
  border-radius: 7px;
  background: var(--bg-primary);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  transition: background var(--transition), border-color var(--transition);
}
.bank-copy:hover { border-color: var(--text-muted); }
.bank-copy.is-copied { background: var(--text-primary); color: #fff; border-color: var(--text-primary); }

.bank-qr {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-top: 14px;
  padding: 14px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-primary);
}
/* White plate behind the code regardless of the surface: a QR needs its
   quiet zone and its contrast, and neither survives a tinted ground. */
.bank-qr img {
  flex: 0 0 auto;
  width: 128px;
  height: 128px;
  padding: 7px;
  background: #fff;
  border-radius: 8px;
}
.bank-qr-note { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--text-secondary); }

.bank-warn {
  margin: 14px 0 0;
  padding: 11px 13px;
  border-radius: 9px;
  background: rgba(200, 120, 20, 0.1);
  border: 1px solid rgba(200, 120, 20, 0.28);
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--text-primary);
}

/* ── Pay, then tell us ──
   The one action on an otherwise read-only screen, so it gets the
   card treatment the copy rows do not: this is a thing to DO, not
   another detail to read off. */
.bank-confirm {
  margin-top: 18px;
  padding: 16px 18px;
  border: 1px solid var(--border-accent);
  border-radius: var(--radius-md);
  background: var(--bg-secondary);
}
.bank-confirm-q {
  margin: 0 0 6px;
  font-size: 14.5px; font-weight: 800;
  color: var(--text-primary);
}
.bank-confirm-note {
  margin: 0 0 14px;
  font-size: 13px; line-height: 1.55;
  color: var(--text-secondary);
}
.bank-confirm-note strong {
  font-family: var(--font-mono, ui-monospace, Menlo, monospace);
  color: var(--text-primary);
}
.bank-confirm-error {
  margin: 0 0 14px;
  padding: 10px 12px;
  border-radius: 8px;
  background: rgba(200, 30, 30, 0.08);
  border: 1px solid rgba(200, 30, 30, 0.25);
  font-size: 12.5px; line-height: 1.5;
  color: #b91c1c;
}
/* Acknowledged. Green, and the button is gone — there is nothing left
   to press, and leaving a pressed button behind invites a second press
   that would say the same thing twice. */
.bank-confirm.is-done {
  border-color: #b7e0c6;
  background: #f2fbf5;
}
.bank-confirm.is-done .bank-confirm-q { color: #15803d; }
.bank-confirm.is-done .bank-confirm-note { margin-bottom: 0; }

@media (max-width: 640px) {
  .bank-row { grid-template-columns: minmax(0, 1fr) auto; }
  /* The label spans both columns above the value, so a long account name
     is never squeezed into a third of a phone's width. */
  .bank-row-label { grid-column: 1 / -1; }
  .bank-qr { flex-direction: column; align-items: flex-start; }
}

/* ── The Stripe Elements payment step ──
   Everything inside the two mount points belongs to Stripe: cross-origin
   iframes from js.stripe.com, sized by their script. Nothing here styles
   their CONTENTS — it cannot, and that is exactly what keeps card entry
   out of PCI scope. The fields inside are themed through the Appearance
   API instead (stripeAppearance() in js/checkout.js, which reads the same
   tokens used below, so the two cannot drift).

   What is left for CSS is the frame: the header with the way back out,
   the wallet row, the divider, our own pay button, and the reserved
   heights that stop the page collapsing between mount and first paint. */
.pay-embed {
  margin-top: 20px;
  padding-top: 20px;
  border-top: 1px solid var(--border);
}
.pay-embed[hidden] { display: none; }
.pay-embed-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; margin-bottom: 14px;
}
.pay-embed-title {
  font-size: 13px; font-weight: 700; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--text-muted);
}
.pay-embed-edit { font-size: 13px; }

/* Reserved while Stripe's iframes measure themselves, so the page does
   not collapse and snap back between mount and first paint. */
#stripe-payment-mount { min-height: 230px; }
#express-checkout-mount { min-height: 46px; }

/* ── Wallets ──
   Hidden until the Express Checkout Element reports that the browser
   actually offers one — an empty bordered box above a divider reads as
   something that failed to load. */
.pay-express { margin-bottom: 22px; }
.pay-express[hidden] { display: none; }

/* "or pay with card" on a rule. Drawn by the pseudo-elements either side
   of the words rather than as a background, so the line can never end up
   behind the text at a width the padding did not anticipate. */
.pay-or {
  display: flex; align-items: center; gap: 12px;
  margin-top: 20px;
  font-size: 12px; font-weight: 700;
  letter-spacing: 0.07em; text-transform: uppercase;
  color: var(--text-muted);
}
.pay-or::before, .pay-or::after {
  content: ''; flex: 1 1 auto; height: 1px; background: var(--border);
}

/* ── Our pay button ──
   The reason for Elements over the embedded Checkout this replaced: the
   final control belongs to the page, says the real amount, and sits in
   the site's own type. */
.pay-now-btn { margin-top: 20px; }
.pay-now-btn:disabled { opacity: 0.6; cursor: default; }

.pay-error { margin-top: 16px; }

/* The promo hint says one of two things. Only the refusal is coloured —
   "applied when you continue" is a note, not a warning. */
.promo-row-hint.is-rejected { color: #b42318; }

.pay-fineprint {
  margin: 12px 0 0;
  font-size: 12.5px; line-height: 1.55; color: var(--text-muted);
  text-align: center;
}

/* The details form while a session is mounted. Greyed rather than hidden:
   the customer is paying against exactly these details and should be able
   to read them, just not change them without invalidating the session. */
#checkout-form.is-locked .checkout-section:not(:has(.pay-embed)) { opacity: 0.55; }
#checkout-form.is-locked .checkout-section:not(:has(.pay-embed)) * { cursor: default; }

.intent-check {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  padding: 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-secondary);
  margin: 8px 0 20px;
  font-size: 13px;
  color: var(--text-secondary);
  line-height: 1.6;
}
.intent-check input { margin-top: 2px; accent-color: var(--accent-bright); width: 18px; height: 18px; flex: 0 0 auto; }

/* ── Billing address ──
   The checkbox reuses .intent-check's box so the two controls in this
   step read as one family, but it is a statement of fact rather than a
   legal declaration — so it sits closer to the fields it governs and
   drops the bottom margin that separates the intent box from the button
   below it. Centred, since its label is one line rather than a
   paragraph. */
.billing-same-check {
  align-items: center;
  margin-bottom: 0;
  color: var(--text-primary);
}
/* Revealed by unticking the box above. The negative top margin closes
   the gap the checkbox's own margin would otherwise leave, so the fields
   read as belonging to it rather than floating under it. */
.billing-fields {
  margin: 14px 0 20px;
}
.billing-fields[hidden] { display: none; }

/* ============================================================
   ACCOUNT PAGE
   ============================================================ */
.account-layout { display: grid; grid-template-columns: 220px 1fr; gap: 40px; align-items: start; }
.account-nav { display: flex; flex-direction: column; gap: 4px; position: sticky; top: calc(108px + var(--promo-h)); }
.account-nav button {
  text-align: left;
  padding: 12px 16px;
  border: none;
  border-radius: var(--radius-md);
  background: none;
  font-family: var(--font-sans);
  font-size: 15px;
  font-weight: 600;
  color: var(--text-secondary);
  cursor: pointer;
}
.account-nav button:hover { background: var(--bg-secondary); color: var(--text-primary); }
.account-nav button.active { background: var(--accent-bright); color: #fff; }
.account-nav .account-signout { margin-top: 16px; color: var(--text-muted); font-weight: 500; }
/* Red on hover — asked for outright, the same warning colour the
   account-menu dropdown's own Sign out already turns on (.nav-account-
   signout). Beats the generic .account-nav button:hover above it on
   specificity (three classes vs. two plus an element), so no !important
   needed here despite that rule being the one this overrides. */
.account-nav .account-signout:hover {
  background: rgba(200, 50, 43, 0.09);
  color: var(--danger, #c8322b);
}

/* Hidden by default — the mobile-only counterpart under Store credit
   in Overview (js/account.js's overviewHtml); the ≤768px rule further
   down this file is what actually shows it, on the one tier that asked
   for it in place of the tab row's own copy above. */
.account-overview-signout { display: none; margin-top: 20px; max-width: 520px; }

.account-panel { display: none; }
.account-panel.active { display: block; }
.panel-title { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; margin-bottom: 20px; }

.order-card {
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  /* Back up to 20px (matching the bottom) — asked for outright, more
     white space above the title than the compressed 12px an earlier
     pass left it at. */
  padding: 20px 22px;
  margin-bottom: 16px;
  background: var(--bg-primary);
  box-shadow: var(--shadow-sm);
  transition: box-shadow var(--transition);
}
.order-card:hover { box-shadow: var(--shadow-md); }
/* No longer a <details>/<summary> — on the customer's own account page
   the card is permanently expanded (asked for outright), so this is
   just a header row over the body below it there. align-items:center
   centres order-heading (the only thing on the left now — no date, no
   items/total subtitle, both removed) against the status badge
   directly. No padding-right any more either: that was clearance for
   the expand arrow, which went with the arrow — the status badge sits
   flush against the same right edge as the buttons and content in the
   sections below it.

   The admin panel brings its OWN expand/collapse back on top of this,
   scoped to .admin-order-card below — a real toggle again, but a
   circular +/- next to the status pill this time, not the disclosure
   triangle <summary> used to draw. */
.order-summary {
  display: flex;
  align-items: center;
  gap: 14px;
}
.order-summary-main { flex: 1 1 auto; min-width: 0; }
.order-summary-side { flex: 0 0 auto; }
/* Mono, asked for outright — a reference number, not a sentence. */
/* Back to the site's own sans, not mono — asked for outright, reversing
   an earlier pass. */
.order-heading { font-weight: 700; color: var(--text-primary); }
/* height:24px — a flex item (.order-summary-side's child) is blockified,
   so height applies despite this being a <span>. Explicit rather than
   left to padding+line-height so .order-card-toggle (admin only, below)
   can be given the exact same 24px and match its diameter to this
   exactly, not approximately. */
.order-status {
  display: inline-flex; align-items: center;
  height: 24px;
  font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
  padding: 0 10px;
  border-radius: 999px;
}
/* No divider under the header row any more (asked for outright, along
   with the section titles it used to sit above) — just the plain gap
   down to the content. */
.order-card-body {
  margin-top: 12px;
}
.order-status.pending { background: #fdf3e7; color: #a06a12; }
.order-status.complete { background: #eaf5ee; color: #15803d; }
/* Same green as .complete — a cancelled or refunded order reads as
   CLOSED to an operator, not as a problem the way a rejected review
   (.dead) does. Admin-only: see the note on orderStatusOf() in
   panel/admin.js for why this tone diverges from the customer's own
   account page. */
.order-status.closed { background: #eaf5ee; color: #15803d; }
/* 36px — matching .order-section + .order-section exactly, asked for
   outright, so the gap below Order contents (before the total) reads
   the same as the gap below Order details (before Order contents). */
/* centre, not flex-end — asked for outright (reversing an earlier
   flex-end pass), so Total sits mid-height against the button group's
   full height rather than sharing its bottom edge. */
.order-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 36px; }
.order-total { font-weight: 800; font-size: 16px; }

.info-list { border-top: 1px solid var(--border); max-width: 520px; }
.info-row { display: flex; justify-content: space-between; gap: 20px; padding: 14px 0; border-bottom: 1px solid var(--border); }
.info-label { color: var(--text-muted); font-size: 14px; }
.info-value { font-weight: 600; font-size: 14px; text-align: right; }
/* A second line under an info row's value, for the one case where the
   figure needs a reason — "Available to spend", where the difference
   from the balance above it is credit an unpaid order is holding. */
.info-note {
  display: block; margin-top: 2px;
  font-size: 12px; font-weight: 500; color: var(--text-muted);
}
.address-card { border: 1px solid var(--border-soft); border-radius: var(--radius-md); padding: 16px; margin-bottom: 12px; font-size: 14px; line-height: 1.6; background: var(--bg-primary); box-shadow: var(--shadow-xs); }
/* The one checkout prefills. Marked on the card itself rather than in a
   separate setting, so "which address is used" and "change it" are the
   same place. */
.address-card.is-default { border-color: var(--text-primary); }
.address-card-actions {
  display: flex;
  align-items: center;
  gap: 16px;
  margin-top: 8px;
}
.address-default-tag {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--text-primary);
}


/* ============================================================
   ARTICLES (list + article)
   ============================================================ */


.article-hero .molecule { width: 120px; height: 120px; margin-bottom: 24px; }


/* ============================================================
   LAB RESULTS
   A grid of per-compound symbol tiles matching the product grid's
   width. Tapping a tile opens its certificate detail elsewhere.
   ============================================================ */
/* The symbol image itself is the button — no card chrome around it. */

.symbol-tile.active { filter: brightness(0.88); }
/* No border-radius here — the symbol PNGs already have their own
   rounded-corner artwork baked in; clipping on top of that trims it. */

/* ============================================================
   ADMIN PANEL
   ============================================================ */
.admin-banner.online { background: #eaf5ee; color: #15803d; }
.admin-banner.offline { background: #fdecea; color: #b42318; }
.power-btn {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 12px 20px;
  border: none; border-radius: 999px;
  font-size: 14px; font-weight: 700; cursor: pointer;
  color: #fff;
}
.power-btn.online { background: #15803d; }
.power-btn.offline { background: #b42318; }
.power-btn svg { width: 18px; height: 18px; }

.admin-section { margin-bottom: 48px; }


/* ── The five top-level tabs ──
   Pill buttons, the same shape and active-state treatment the Flow-era
   sub-tabs and Reviews filters both used to use. They used to be tabs
   in the literal sense: sitting on a shared rule with a bordered panel
   underneath, the active one painting over its own width of that rule
   so the panel looked like it rose out of it specifically.

   That frame is gone. Asked for outright — the content below the tab
   bar is free-floating now, or sits inside whatever box it already had
   of its own (.stock-card wraps Static's table; every Inflow, Outflow,
   Reviews and Analytics element already draws its own border where it
   needs one). Nothing here replaces the frame; there is no frame.

   Static, Inflow and Outflow used to be one Stock tab with a Balance/
   Flow sub-tab row inside it, Flow itself sliced further by a five-way
   filter row — three levels of navigation to reach one of five actual
   screens. They are five peer tabs now; nothing here is nested inside
   anything else. */
.admin-tabs {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  margin-bottom: 22px;
}
.admin-tab {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 9px 18px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-primary);
  color: var(--text-secondary);
  font-size: 14px; font-weight: 700;
  cursor: pointer; white-space: nowrap;
  transition: border-color var(--transition), background var(--transition), color var(--transition);
}
/* A tab carrying a count badge tightens its own right side so the
   pill's rounded end curves into the badge rather than leaving it
   stranded in the middle of generic 18px padding — the two circles
   (the corner's own curve and the badge) end up reading as one
   continuous shape instead of two unrelated ones. */
.admin-tab:has(.admin-tab-count) { padding-right: 8px; }
.admin-tab:hover { border-color: var(--border-accent); color: var(--text-primary); }
.admin-tab.active {
  background: var(--text-primary);
  border-color: var(--text-primary);
  color: var(--bg-primary);
}
.admin-tab:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }
.admin-tab.active .admin-tab-count { background: var(--bg-primary); color: var(--text-primary); }

.admin-tab-new-btn {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 14px 7px 11px;
  border: 1px solid var(--border); border-radius: 999px;
  background: var(--bg-primary); color: var(--text-primary);
  font-size: 13px; font-weight: 700; cursor: pointer; white-space: nowrap;
  transition: border-color var(--transition), background var(--transition);
}
.admin-tab-new-btn:hover { border-color: var(--accent-bright); background: var(--bg-secondary); }
.admin-tab-new-btn svg { width: 15px; height: 15px; flex-shrink: 0; }

/* The active tab's own "+ New" button(s) — Static's + Product, Inflow's
   + Order/+ Arrival — riding the same row as the tabs themselves,
   pushed to its right edge. Outflow, Reviews and Analytics carry none:
   nothing on those screens is created from here, so an empty div here
   costs nothing. */
.admin-panel-actions {
  display: flex; gap: 8px;
  margin-left: auto;
}

@media (max-width: 768px) {
  .admin-panel-actions { width: 100%; margin-left: 0; }
}

/* Shown the instant a tab is selected, while its data is still in
   flight. Reserving the height stops the page collapsing and then
   snapping back when rows arrive. */
.admin-loading { padding: 8px 0; }
.admin-loading-row {
  height: 18px; margin-bottom: 14px;
  border-radius: 6px;
  background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-soft) 50%, var(--bg-secondary) 75%);
  background-size: 200% 100%;
  animation: admin-shimmer 1.3s ease-in-out infinite;
}
.admin-loading-row:nth-child(2) { width: 82%; }
.admin-loading-row:nth-child(3) { width: 91%; }
.admin-loading-row:nth-child(4) { width: 74%; }
@keyframes admin-shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .admin-loading-row { animation: none; }
}

/* ── Stock Balance table ──
   Header and rows share ONE grid so columns align themselves. Size, SKU,
   Stock and Prestock are all the same fixed width; Product takes
   max-content so the widest name still fits on one line, and the final
   (visibility) column absorbs the remaining space with its content
   pushed to the far right. */
/* Widths come from sizeStockColumns() in js/admin.js, which measures the
   real rendered text after paint. The fallbacks only apply for the frame
   before that runs. Every data column shares one width so the block reads
   as a table; Product gets its own. */
.stock-grid {
  display: grid;
  grid-template-columns:
    var(--product-col-w, max-content)
    repeat(5, var(--stock-col-w, 88px))
    auto
    1fr;
  align-items: baseline;
  column-gap: 16px; row-gap: 12px;
}
.stock-cell { text-align: left; color: var(--text-primary); font-weight: 700; }
.stock-cell--product { font-size: 15px; align-self: start; }
/* Mono for the figures — Size, Stock and Prestock line up digit-for-digit
   down the column. JetBrains Mono is already loaded for the site. */
.sv-num { font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; }
/* Deliberately NOT mono — the size reads as a label like the product
   name, not as a figure to be compared down the column. */
.sv-size { font-family: var(--font-sans); font-size: 15px; font-weight: 700; }
.sv-sku { font-family: var(--font-mono); font-size: 12px; }
/* Left-aligned, in its own auto-width column rather than pinned to the
   row's far right — asked for outright, reversing an earlier pass. */
.stock-cell--visibility { justify-self: start; text-align: left; }
/* No fixed width of its own — the trailing 1fr column takes whatever
   space is left, same reasoning .stock-cell--visibility used to have
   the row's far edge for. */
.sv-insights { justify-self: start; }
.sv-insights.is-muted { font-size: 13px; font-weight: 600; color: var(--text-muted); }

/* The table's own rounded card inside the tab panel. It redefines
   --panel-pad to its own padding so the dividers below (which bleed out
   by exactly that much) run edge to edge of THIS card, not the panel. */
.stock-card {
  --panel-pad: 20px;
  border: 1px solid var(--border);
  /* Tighter than the tab frame that encloses it: nested corners reading
     the same radius look like a mistake, and a smaller inner curve is
     what makes the pair look deliberately concentric. */
  border-radius: var(--radius-md);
  padding: 18px var(--panel-pad);
  background: var(--bg-primary);
}

/* One element spanning every column, bled out past the surrounding
   padding, so the rule is a single unbroken line from edge to edge —
   per-cell borders would break at every column gap. */
.stock-divider {
  grid-column: 1 / -1;
  height: 1px; background: var(--border-soft);
  margin: 0 calc(var(--panel-pad, 20px) * -1);
}

/* Inline creation card (Create Product / Create Order / Create
   Arrival) — sits at the top of its tab's content, boxed the same way
   the popups were, with the same close ×. position:relative anchors
   that absolutely-placed × to this card. */
.admin-inline-form {
  position: relative;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 26px 28px;
  background: var(--bg-primary);
  margin-bottom: 20px;
}
.admin-inline-form .auth-heading { font-size: 20px; margin-bottom: 22px; }

/* ── The creation forms' dismiss control ──
   .auth-close is positioned for the sign-in dialog, where it sits beside
   a 38px logo — `top: 32px + 3px` is that alignment, and against these
   forms (26px of padding, a 20px heading and no logo) it landed low and
   inboard of the corner. It was also drawn as a &times; glyph, which
   .auth-close never sizes because it only styles an svg, so it rendered
   at whatever font-size it inherited: a small, faint x.

   Bigger, squarely in the corner, and on a visible plate so it reads as a
   control rather than as a stray character. */
.admin-inline-form .admin-close {
  top: 14px;
  right: 14px;
  width: 38px;
  height: 38px;
  background: var(--bg-secondary);
  border-color: var(--border);
  color: var(--text-secondary);
}
.admin-inline-form .admin-close svg { width: 19px; height: 19px; }
.admin-inline-form .admin-close:hover {
  background: var(--text-primary);
  border-color: var(--text-primary);
  color: #fff;
}
.admin-inline-form .admin-close:focus-visible {
  outline: 2px solid var(--text-primary);
  outline-offset: 2px;
}
/* The heading must not run under the button now that it is larger. */
.admin-inline-form .auth-heading { padding-right: 52px; }

/* ── Creation-form typography ──
   One label style for every field across all three flows: label sits
   close to its own input, with a clear gap to the NEXT group, so the
   pairing reads by proximity rather than by any divider. */
.form-field { display: block; margin-bottom: 20px; }
.form-field > span {
  display: block;
  font-size: 13px; font-weight: 600; color: var(--text-secondary);
  margin-bottom: 6px;
}
.form-field .field-input { width: 100%; }

/* "optional", inline after a field's label. The compound-detail fields
   can all be left blank — a product is often listed before its
   certificate has been read off — and saying so on the label is what
   stops the form reading as five things that must be filled in.

   A nested span, so it overrides .form-field > span's own display:block
   above and sits on the same line as the label rather than under it. */
.np-optional {
  display: inline;
  margin-left: 6px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* Section heading — sits between a field label and the modal title in
   weight, and is spaced to belong to what FOLLOWS it (small gap below,
   large above) so it reads as opening a group rather than closing one. */
.form-section-title {
  font-size: 16px; font-weight: 800; letter-spacing: -0.01em;
  margin: 34px 0 12px;
}
.form-section-title:first-child { margin-top: 0; }

/* Clearly inert until a product is chosen — the old default gave almost
   no visual signal, so the field looked broken rather than waiting. */
.field-input:disabled,
.select-native:disabled {
  background: var(--bg-secondary);
  color: var(--text-muted);
  border-color: var(--border-soft);
  cursor: not-allowed;
  opacity: 1; /* override the UA's own washed-out default */
}
.field-input:disabled::placeholder { color: var(--text-muted); }

/* Decline mirrors Approve exactly — same shape, height and weight — and
   differs only in colour, so the pair reads as one control set. */
.btn-cart.btn-cart--decline {
  background: var(--bg-primary);
  color: var(--text-primary);
  border: 1px solid var(--border);
}
.btn-cart.btn-cart--decline:hover { background: var(--bg-secondary); border-color: var(--accent-bright); }

/* Column headers — plain, left-aligned, uppercase/muted. The active sort
   column stays dark so it's obvious which one is driving the order. */
.stock-sort-btn {
  background: none; border: none; padding: 0; text-align: left; cursor: pointer;
  align-self: end;
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--text-muted); transition: color var(--transition);
}
.stock-sort-btn:hover { color: var(--text-primary); }
.stock-sort-btn.is-active { color: var(--text-primary); }
.stock-head-spacer { display: block; } /* insights column has no header */
/* Visibility's own header, styled like a .stock-sort-btn it isn't —
   there is nothing to sort a Show/Hide column by, so this is a plain
   span rather than a <button data-sort>. */
.stock-head-visibility {
  align-self: end;
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--text-muted);
  text-align: left;
}

/* Hide/Show is plain tappable text, not a pill — same size as the row's
   own figures. Mono, asked for outright, matching the other figure
   columns (.sv-num, .sv-sku) rather than the sans-serif label columns. */
.hide-toggle {
  background: none; border: none; padding: 0; margin: 0;
  font-family: var(--font-mono); font-weight: 700; font-size: 13px;
  cursor: pointer; text-align: left;
}
/* The label is the ACTION, so the colour is the action's: "Hide" removes
   it from the storefront (red), "Show" puts it back (green). */
.hide-toggle[data-hidden="0"] { color: #b42318; }
.hide-toggle[data-hidden="1"] { color: #15803d; }
.hide-toggle:hover { text-decoration: underline; }
/* Optimistically flipped to its new label the instant it's clicked, then
   held here — dim, inert — until the write is actually confirmed. */
.hide-toggle.is-pending { opacity: 0.4; cursor: default; pointer-events: none; text-decoration: none; }

/* The row's own "it's on the storefront" state, where .hide-toggle is
   the row's own "here's the choice to make" state — same mono/size/
   weight so a glance down the column reads as one column regardless of
   which of the two a given row shows. Grey rather than .hide-toggle's
   green: there is nothing to decide here, just somewhere to look,
   and green would wrongly read as another action to take. */
.stock-view-link {
  font-family: var(--font-mono); font-weight: 700; font-size: 13px;
  color: var(--text-secondary);
}
.stock-view-link:hover { text-decoration: underline; }

/* A stock balance below zero — the ledger recorded more leaving than the
   shelf held. Shoppers never see it (anything <= 0 reads as out of
   stock); in here it's flagged so it can be reconciled. */
.sv-num.is-negative { color: #b42318; }

/* ── Stock Flow tab: the ledger ── */
.ledger-line {
  display: grid;
  /* product 1/2 · size 1/4 · qty 1/4, then a fixed bin column. The
     header row uses this same template, so its labels always sit over
     the fields they name. */
  grid-template-columns: 2fr 1fr 1fr 34px;
  gap: 10px; align-items: center; margin-bottom: 10px;
}
.ledger-line--head {
  margin-bottom: 6px;
  font-size: 11px; font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.06em; color: var(--text-muted);
}
.bin-icon { width: 17px; height: 17px; display: block; }
/* Greyed rather than hidden on a lone row: an empty slot at the end of
   the row read as a rendering fault, a dimmed bin reads as "nothing to
   remove yet". */
/* Beats .admin-link-btn.danger, which is declared later at the same
   specificity and would otherwise keep the bin red while inert. */
.admin-link-btn.danger.is-disabled,
.admin-link-btn.is-disabled { color: var(--border); pointer-events: none; }
[data-remove-line], [data-remove-variant] { justify-self: center; }

/* Full-width, plain white, centred — reads as a row-shaped affordance
   under the rows it adds to. */
.admin-link-btn.line-add-btn {
  display: block; width: 100%;
  margin: 2px 0 0;
  padding: 10px 0;
  border: 1px solid var(--border); border-radius: var(--radius-md);
  background: var(--bg-primary); color: var(--text-primary);
  font-size: 13px; font-weight: 700; text-align: center;
}
.admin-link-btn.line-add-btn:hover { border-color: var(--accent-bright); text-decoration: none; background: var(--bg-secondary); }

/* Deliberate gap so the destructive-ish submit isn't adjacent to the
   button people click repeatedly while filling the form in. */
.form-submit-row { margin-top: 28px; }

.confirm-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.confirm-actions .btn-cart, .confirm-actions .btn-ghost { max-width: 160px; }

/* A typed product name that doesn't match the catalog. */
.field-input.is-invalid { border-color: #b42318; }
.ledger-history { display: flex; flex-direction: column; gap: 10px; }
/* Action Items / History — Inflow, Outflow and Reviews all share this
   same pair of headings above their own .ledger-history list (see
   panel/admin.js). Plain text, not a pill or a tab: there is nothing to
   click here, only two lists stacked in a fixed order.

   84px (3× the normal 28px section gap) above every title but the
   first — asked for outright, so History reads as a clear second
   section rather than the next item in the Action Items list above it. */
.admin-list-title {
  font-size: 15px; font-weight: 800; color: var(--text-primary);
  margin: 84px 0 14px;
}
/* :first-of-type, not :first-child — Inflow's panel puts a hidden
   <p class="auth-error" id="orders-error"> ahead of this in the markup
   (for wireStockOrders()/wireCancelOrder() failures), which is invisible
   but is still the actual first child, so :first-child never matched it
   there and Action Items sat 84px lower than on Outflow and Reviews.
   :first-of-type only counts other h2s, so the hidden <p> doesn't
   count against it on any of the three tabs. */
.admin-list-title:first-of-type { margin-top: 0; }
/* Empty-state lines directly under an .admin-list-title/Balance header
   (Static's "No products", every "Nothing here yet."/"You're all
   done!") — .page-sub's own 17px is sized for a page's intro paragraph,
   not a one-line note sitting right under a 15px section title, which
   is what made it read as bigger than the heading above it. */
.admin-empty { color: var(--text-secondary); font-size: 14px; line-height: 1.6; }
.ledger-entry { border: 1px solid var(--border); border-radius: var(--radius-md); padding: 16px 18px; }
.ledger-entry.is-pending-order { border-color: #f0d488; background: #fefbf0; }


/* The admin entry point, revealed in the nav only for admin accounts. */
.ledger-entry-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
}
.ledger-entry-title { display: flex; align-items: center; gap: 8px; }
.ledger-entry-title strong { font-size: 14px; }
.ledger-status-badge.is-muted { background: var(--bg-secondary); color: var(--text-muted); }
.ledger-status-badge {
  font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
  padding: 2px 8px; border-radius: 999px; background: #fdf3e7; color: #a06a12;
}

.ledger-entry-date { font-size: 12px; color: var(--text-muted); }
/* The new-product confirmation's descriptive lines (sequence, molar
   mass, storage). Written by panel/admin.js since it was added and never
   given a rule, so they rendered as unstyled body text in a card where
   everything else is 13px muted — this just makes them match. */
.ledger-entry-desc { font-size: 13px; color: var(--text-secondary); margin-bottom: 4px; }
.ledger-entry-lines { display: flex; flex-direction: column; gap: 6px; }
.ledger-entry-line { display: flex; align-items: baseline; gap: 10px; font-size: 13px; flex-wrap: wrap; }
.ledger-entry-item { font-weight: 600; }
.ledger-delta-pos { color: #15803d; font-weight: 700; font-variant-numeric: tabular-nums; }
.ledger-delta-neg { color: #b42318; font-weight: 700; font-variant-numeric: tabular-nums; }
.ledger-entry-result { color: var(--text-muted); font-variant-numeric: tabular-nums; }
.ledger-entry-result.is-negative { color: #b42318; font-weight: 600; }
.admin-link-btn { background: none; border: none; color: var(--accent-bright); font-size: 13px; font-weight: 600; cursor: pointer; }
.admin-link-btn:hover { text-decoration: underline; }
.admin-link-btn.danger { color: #b42318; }

/* ── New Product modal ── — reuses .auth-modal's chrome, just wider and
   scrollable, since the form (sizes, image dropzone) needs more room
   than the 420px sign-in dialog it's borrowed from. */
.auth-modal--wide { max-width: 640px; max-height: 88vh; overflow-y: auto; }

.supply-form { border: 1px solid var(--border-soft); border-radius: var(--radius-lg); padding: 22px; margin-bottom: 24px; background: var(--bg-primary); box-shadow: var(--shadow-sm); }

/* Accordion trigger for #addr-form — desktop never shows it (the form
   there stays permanently open); the ≤768px rules below turn it on and
   collapse the form behind it. */
.addr-toggle {
  display: none;
  width: 100%;
  align-items: center;
  justify-content: space-between;
  padding: 16px 18px;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  background: var(--bg-primary);
  box-shadow: var(--shadow-sm);
  font-family: 'Inter', sans-serif;
  font-size: 14px;
  font-weight: 600;
  color: var(--text-primary);
  cursor: pointer;
  margin-bottom: 12px;
}
.addr-toggle-icon {
  position: relative;
  width: 16px;
  height: 16px;
  flex-shrink: 0;
}
.addr-toggle-icon::before,
.addr-toggle-icon::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  background: var(--text-muted);
  transition: transform 0.2s ease;
}
.addr-toggle-icon::before { width: 16px; height: 2px; transform: translate(-50%, -50%); }
.addr-toggle-icon::after { width: 2px; height: 16px; transform: translate(-50%, -50%); }
.addr-toggle[aria-expanded="true"] .addr-toggle-icon::after { transform: translate(-50%, -50%) rotate(90deg); opacity: 0; }
.supply-form textarea {
  width: 100%; min-height: 80px;
  padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-md);
  font-family: var(--font-sans); font-size: 14px; resize: vertical; margin-bottom: 14px;
}


/* Drag-and-drop product image dropzone (Add a New Product, admin.js).
   Reuses .field-input's own border colour so it reads as part of the
   same form rather than a distinct widget; .dragover is toggled purely
   in JS (dragenter/dragleave), since :hover alone doesn't distinguish
   "a file is being dragged over this" from an ordinary mouseover. */
.np-dropzone {
  border: 2px dashed var(--border);
  border-radius: var(--radius-md);
  padding: 28px;
  text-align: center;
  color: var(--text-muted);
  font-size: 14px;
  cursor: pointer;
  transition: border-color var(--transition), background var(--transition);
  margin-bottom: 16px;
}
.np-dropzone:hover,
.np-dropzone.dragover {
  border-color: var(--accent-bright);
  background: var(--bg-secondary);
}
.np-dropzone-preview { max-width: 160px; max-height: 160px; border-radius: var(--radius-md); display: block; margin: 0 auto 10px; object-fit: cover; }
.np-dropzone-filename { font-size: 12px; color: var(--text-secondary); margin-top: 8px; word-break: break-all; }

/* Offline notice shown to shoppers when admin flips the site off. */
.offline-screen {
  position: fixed; inset: 0; z-index: 900;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; padding: 40px;
  background: var(--bg-primary);
}
.offline-screen h1 { font-size: clamp(30px, 5vw, 48px); font-weight: 800; letter-spacing: -0.03em; margin-bottom: 16px; }
.offline-screen p { color: var(--text-secondary); font-size: 18px; max-width: 480px; }

/* ============================================================
   RESPONSIVE — new pages
   ============================================================ */
@media (max-width: 1024px) {
  .container { padding: 0 24px; }
  .product-detail { grid-template-columns: 1fr; gap: 32px; }
  .pd-gallery { position: static; aspect-ratio: 16 / 12; }
  .cart-layout, .checkout-layout { grid-template-columns: 1fr; }
  .cart-summary, .account-nav { position: static; }
  .account-layout { grid-template-columns: 1fr; }
  .account-nav { flex-direction: row; flex-wrap: wrap; }
}
@media (max-width: 640px) {
  .page { padding-top: calc(92px + var(--promo-h)); }
  .catalog-toolbar { flex-direction: column; align-items: stretch; }
  .sort-field { justify-content: space-between; }
  .select-native { width: 100%; }
  .form-grid { grid-template-columns: 1fr; }


  .ledger-line { grid-template-columns: 1fr 1fr; }
  .ledger-line--head { display: none; }
  /* The sort buttons used to be hidden outright at this width, back when
     the header row was six of them and could not fit. It is four now
     (Product and Size are dropped at 768px, below), which does fit — and
     hiding them left the header row empty while its divider still drew,
     so the table opened with a blank band and a stray rule above the
     first row. Kept visible, and sortable. */
}

/* ── Stock table on a phone: SKU onward ──
   Product and Size are removed below 768px, asked for outright. They are
   the two widest columns and the two most redundant on a small screen —
   the SKU already identifies the exact variant, which is what both of
   them together were saying.

   Dropping the CELLS is only half of it. panel/admin.js also stops
   GROUPING at this width (see STOCK_PHONE_MQ there): a group is a
   Product cell spanning its variants' rows, and grouped mode omits the
   divider between a product's own sizes. With the name column gone those
   rows would be undivided and unlabelled — indistinguishable. Flat rows,
   one divider between every pair, which is what the JS now always emits
   here.

   display:none rather than a narrower track: a grid item that is not
   displayed is not placed at all, so the remaining cells flow into the
   five tracks below with no per-row bookkeeping. */
@media (max-width: 768px) {
  .stock-cell--product,
  .sv-size,
  .stock-sort-btn[data-sort="name"],
  .stock-sort-btn[data-sort="size"] {
    display: none;
  }
  /* Four data columns (SKU, Price, Stock, Prestock) plus the visibility
     control, which absorbs what is left.

     The measured shared width from sizeStockColumns() is deliberately
     NOT used here. That width is the widest cell across all four
     columns — on the desktop table it is what makes the block read as a
     table rather than four unrelated lists, and it is dominated by the
     longest header, "Prestock". Four columns at that width plus the gaps
     and the card's padding comes to roughly 420px, which does not fit a
     phone: the table scrolled sideways from the moment it loaded, and
     the visibility control — the one interactive thing in the row — was
     the part off the edge.

     max-content per column instead. Each takes exactly what its own
     content needs, the four disagree in width, and the row fits. The
     even-columns effect is worth paying for on a desktop and not worth
     an off-screen control on a phone. */
  .stock-grid {
    grid-template-columns: repeat(4, max-content) auto;
    column-gap: 8px;
  }
  /* Every remaining pixel goes to the columns. The card's own padding is
     the largest thing on this row that is not data — 20px a side on a
     ~287px card is a seventh of the width — and the type comes down half
     a step with it. Together they are the difference between the row
     fitting and the Hide control sitting past the right edge. */
  .stock-card { --panel-pad: 12px; padding: 14px var(--panel-pad); }
  .sv-sku { font-size: 11.5px; }
  .sv-num { font-size: 12px; }
  .stock-sort-btn { font-size: 10px; }
  /* Nothing in a row wraps. Every cell here is a single token — an
     identifier, a price, a count — and none of them mean anything broken
     across two lines. */
  .stock-cell { white-space: nowrap; }
  /* The escape valve the nowrap above needs. A phone narrow enough that
     four figures and a control will not fit gets a sideways scroll on
     this one card, rather than the whole page scrolling or the columns
     collapsing into each other. */
  .stock-card { overflow-x: auto; }
}

/* Add-to-cart toast */
.pa-toast {
  position: fixed;
  left: 50%;
  bottom: 28px;
  transform: translate(-50%, 20px);
  z-index: 1100;
  padding: 13px 22px;
  background: var(--cta);
  color: #fff;
  font-size: 14px;
  font-weight: 600;
  border-radius: 999px;
  box-shadow: 0 12px 32px rgba(0,0,0,0.3);
  opacity: 0;
  pointer-events: none;
  /* Springy in, plain out. A symmetrical ease made the toast arrive and
     leave at the same weight, which reads as a box being faded rather
     than a thing that appeared — the overshoot curve on the way in is
     what makes it feel like it was PUT there. The exit stays linear-ish
     and slightly quicker: a notice that lingers on its way out competes
     with whatever the customer moved on to.

     Only the transform springs. Bouncing opacity would flicker. */
  transition: opacity 0.18s ease, transform 0.34s cubic-bezier(0.22, 1.15, 0.36, 1);

  /* ── One line, always ──
     Asked for outright. This is a pill, and a pill that wraps to two
     lines is a lozenge with a sentence broken across it — the shape
     stops reading as a single transient notice. nowrap is what holds it
     to one line.

     The other two properties are what make nowrap safe rather than
     destructive. Left alone, "Semaglutide 5mg (10 vials) added to cart"
     on a 375px screen would set a pill wider than the phone, centred on
     the viewport, with both ends off the edge — the message unreadable
     in both directions. Capped to the screen less a margin, with the
     overflow taken off the end it can most afford to lose. */
  white-space: nowrap;
  max-width: calc(100vw - 32px);
  overflow: hidden;
  text-overflow: ellipsis;
}
.pa-toast.show { opacity: 1; transform: translate(-50%, 0); }
/* Reduced motion keeps the message and drops the travel — the toast
   still fades, it just does not slide or overshoot. */
@media (prefers-reduced-motion: reduce) {
  .pa-toast { transform: translate(-50%, 0); transition: opacity 0.15s ease; }
}

/* On a phone this carries a product name rather than a short status
   line, and it is the ONLY confirmation of an add (see addedToCart in
   js/ui.js). Tighter type and padding buy roughly six more characters
   before the ellipsis has to do anything — which for most of the
   catalog is the difference between the whole name and a truncated one. */
@media (max-width: 768px) {
  .pa-toast {
    bottom: 24px;
    padding: 11px 18px;
    font-size: 13px;
    max-width: calc(100vw - 24px);
  }
}


/* ============================================================
   HOME — FEATURED CARD "VIEW" BUTTON
   ------------------------------------------------------------
   Same .btn-cart the catalog's Add to Cart uses, so the two grids share
   one button treatment. margin-top:auto keeps every card's button on the
   same baseline no matter how many lines the product name takes.
   ============================================================ */
.featured-products .card-body .btn-cart {
  margin-top: auto;
}

/* ============================================================
   HOME — ORDER PROCESS ROWS
   ------------------------------------------------------------
   One row per step: copy on the left, artwork on the right.
   ============================================================ */
/* Sizing and centring for "Your Order Process" now both live entirely on
   .research-banner--capped's two children (below) — this bare selector
   used to carry the title's real width/margin/font-size, but every
   property it set is overridden there anyway (that rule is more
   specific, so it always wins), which just left two competing "here's
   the true size" spots for a future edit to find and trust the wrong
   one. Nothing else uses .process-mega-heading, so there's no base
   style actually needed here any more. */

/* ============================================================
   HOME — ORDER PROCESS
   ------------------------------------------------------------
   Three sections, stacked and scrolling with the rest of the page like
   any other content here — no snap, no sticky, no fade. Each is only
   as tall as its own content; the gap between them (and the gap above
   the first one, off the title, tripled) is what paces the flow rather
   than a fixed height.
   ============================================================ */
/* Carries the disclaimer's field on through the flow, edge to edge. .op
   sits in here rather than in .section so it is free of that wrapper's
   width cap; the horizontal padding .section used to supply is reproduced
   here so the cards keep the same measure as before. */
.op-bleed {
  background: var(--bg-primary);
  padding-inline: clamp(24px, 4vw, 48px);
}

.op {
  position: relative;
  width: 100%;
  /* Eight-tenths of the grid width the catalog/featured-products cap
     uses elsewhere — a tenth taken off twice, across two separate
     requests — scaled off that shared variable rather than a fresh
     value, so it still tracks it if that ever changes; margin-inline:
     auto keeps the narrower measure centred same as before. */
  max-width: calc(var(--catalog-grid-w) * 0.9 * 0.9);
  padding-inline: var(--grid-edge);
  margin-inline: auto;
  box-sizing: border-box;
}

@media (max-width: 1585px) {
  .op { max-width: calc(1120px * 0.9 * 0.9); }
}

/* ── Sections ── */

/* ── The copy card ──
   No panel: the copy reads directly off the field. The two op-step
   children are the section's two subsections — Browse The Catalog above
   Pay It Your Way, say — and used to stack. They now sit side by side
   instead, one to a column, with the card title spanning both above them.
   No extra wrapper is needed for that: grid-column: 1 / -1 on the title
   claims the whole of row 1, and the two op-step siblings that follow
   auto-place into row 2's two columns in source order — first (formerly
   "top") on the left, second (formerly "bottom") on the right — landing
   in the same row and so sharing a top edge without it being asked for
   explicitly. */
/* The two steps in a pair sit side by side and top-aligned (align-items:
   start), which is what keeps their headings on a shared line. The gutter
   between them is double what it was, so the pair reads as two columns
   rather than one run of text. */
/* --op-card-radius survives from the folder-tab era; nothing reads it any
   more but it is one line and harmless. */
:root {
  --op-card-radius: clamp(16px, 1.6vw, 24px);
}


.op-step {
  display: flex;
  flex-direction: column;
  gap: clamp(6px, 1lvh, 10px);
  min-width: 0;
}

/* The per-card "STEP 1"…"STEP 6" eyebrow (see index.html). Hidden by
   default and switched on only in the ≤768px tier, where the three-tab
   slider that normally carries this wording is display:none and each
   .op-step is its own card. display:none rather than visually-hidden so
   it leaves the accessibility tree too at every width where the slider
   is announcing the same thing. */
.op-step-num { display: none; }

.op-step-title {
  font-size: 18px;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.3;
  margin: 0;
  color: var(--text-primary);
}

.op-step-body {
  /* Tightened from 18px/1.7. The copy itself was cut back at the same
     time, so this is a denser block of fewer words rather than the same
     text squeezed — which is what keeps it readable at this leading. */
  font-size: 15.5px;
  line-height: 1.55;
  /* Held just off full black so the headings still lead. */
  color: var(--text-secondary);
  margin: 0;
}

/* Carries the phrase each sentence turns on. Full-strength as well as
   slanted, since italics alone go soft against the lighter body copy. */
.op-step-body em {
  font-style: italic;
  color: var(--text-primary);
}

/* Full/short device for the Browse Our Catalog and Arrival + Aftercare
   copy — same pattern as .hero-subtitle-full/short. Short hidden by
   default; the ≤768px rule swaps them. */
.op-body-short { display: none; }

/* ── Visuals inside the card ── */


/* ── Payment methods, as a list ───────────────────────────────
   Built on .op-bulk, so it is the same object as the bulk ladder in step
   1 and the delivery table in step 2: same rules, same uppercase heads,
   same measure. It replaced a grid of logo tiles, which was a fourth
   visual treatment on a panel that already had one for exactly this kind
   of short reference table.

   The hierarchy is the table's own. The first column is a payment TYPE
   and is a row header (<th scope="row">) rather than a cell — it names
   the row rather than being data in it, which is also what lets a screen
   reader announce "Debit & credit" before each method inside it.
   ──────────────────────────────────────────────────────────── */
.op-pay-table {
  /* Clear of the paragraph above. The column heads are small uppercase
     labels, which sit optically close to body copy and need real space to
     read as the start of something. */
  margin-top: clamp(14px, 2lvh, 20px);
  table-layout: fixed;
}
/* The type column takes only what it needs; the methods get the rest.
   Both the column head and the row headers are sized here, or the two
   would disagree and the table would shift between its header and body.
   34%, not 40%: the longest type ("Debit & credit") fits comfortably
   inside it, and the space it was holding beyond that was pushing the
   methods further right than they needed to sit. */
.op-pay-table th[scope="col"]:first-child,
.op-pay-table th[scope="row"] { width: 34%; }

/* The row header. Weighted like the ladder's own left column so the two
   tables read as one component, and top-aligned because the cell beside
   it can run to two lines. */
/* Both columns share their padding, font-size AND line-height. The type
   and the first method in its row are two separate boxes on the same
   line, so anything that differs between them — 3px of padding, a
   different leading — offsets one against the other and the row reads as
   slightly broken. Stated once as a variable rather than twice as two
   values that can drift apart. */
/* .op-bulk is on the same element and its `th, td` rule sets 6px of top
   padding at the SAME specificity as a bare `.op-pay-table td` — and it
   appears later in this file, so it was winning. The row header (a
   two-class selector) kept 9px while the cell beside it dropped to 6,
   which is precisely the misalignment this block exists to prevent.
   Naming both classes puts this ahead of it regardless of order. */
/* The cell treatment, shared by every table in this section.
   .op-bulk--rows opts the bulk ladder and the delivery table into exactly
   what the payment table already had: the same padding, size and leading,
   and a weighted first column that is a row header rather than a cell.
   They were three tables of the same kind reading as two different
   components — the ladder and the delivery times sat tighter and lighter
   than the payment table directly between them.

   Both selectors name .op-bulk as well, because .op-bulk's own `th, td`
   rule sets 6px of padding at the SAME specificity as a single-class
   selector here and appears later in the file, so it would win. */
.op-bulk.op-pay-table th[scope="row"],
.op-bulk.op-pay-table td,
.op-bulk--rows th[scope="row"],
.op-bulk--rows td {
  --op-pay-lh: 1.45;
  padding: 9px 12px 9px 0;
  font-size: 13.5px;
  line-height: var(--op-pay-lh);
  vertical-align: top;
}
/* .op-bulk clears the bottom rule on its last `td`, but these tables'
   first column is a `th` — which that rule does not reach, so half a
   divider was left hanging under the final row. Both are cleared here. */
.op-bulk.op-pay-table tbody tr:last-child th,
.op-bulk.op-pay-table tbody tr:last-child td,
.op-bulk--rows tbody tr:last-child th,
.op-bulk--rows tbody tr:last-child td {
  border-bottom: none;
}

.op-bulk.op-pay-table th[scope="row"],
.op-bulk--rows th[scope="row"] {
  font-weight: 600;
  color: var(--text-primary);
  text-align: left;
}

/* The methods within a type. A list, not a row of tiles: each is a mark
   and its name on one line, stacked, so a type with one method and a type
   with two are obviously the same kind of thing. */
.op-pay-methods {
  --op-mark-w: 33px;
  --op-mark-h: 22px;
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  /* Enough to read as separate items without the group sprawling. At 2px
     the two methods under one type were touching and read as one block;
     this separates them while keeping the group shorter than the two
     table rows either side of it. */
  gap: 7px;
}
.op-pay-methods li {
  display: flex;
  align-items: center;
  gap: 9px;
  /* Inherits the cell's size and leading, so the first item's text sits
     on the same line as the row header beside it. */
  line-height: var(--op-pay-lh);
  color: var(--text-primary);
}
/* One fixed box for every mark, which is what keeps the NAMES in a
   straight column whether the logo beside them is square (Visa) or a wide
   wordmark (Google Pay). The marks are centred within that box — so they
   read as a set aligned to each other — while the text stays hard left
   against the box's right edge.

   The box is 50% larger than it was (22x15 -> 33x22). Height is what
   limits a square mark and width is what limits a wide one, so both
   dimensions had to grow together or the two would have scaled by
   different amounts. */
.op-pay-methods img {
  flex: 0 0 var(--op-mark-w);
  width: var(--op-mark-w);
  height: var(--op-mark-h);
  object-fit: contain;
  object-position: center;
}

/* The bank glyph is a drawn icon rather than a brand wordmark, and at the
   full box height it read heavier than the logos beside it. 25% up from
   the old size rather than 50%, so it sits with them instead of leading
   them. */
.op-pay-methods img[src*="bank"] { height: calc(15px * 1.25); }

/* The one thing on this table that is an offer rather than a fact, so it
   is the one thing given colour. */

/* ── The bulk-discount ladder ──
   Three rows stating the tiers the catalog applies silently. Ruled rather
   than boxed: it is a footnote to the paragraph above it, not a second
   subject competing with it. */
.op-bulk {
  width: 100%;
  margin: 14px 0 0;
  border-collapse: collapse;
  font-size: 13.5px;
  font-variant-numeric: tabular-nums;
}

.op-bulk th,
.op-bulk td {
  padding: 6px 10px 6px 0;
  text-align: left;
  border-bottom: 1px solid var(--border);
}
.op-bulk th {
  font-size: 11px;
  font-weight: 600;
  color: var(--text-secondary);
}
.op-bulk td { color: var(--text-primary); }
.op-bulk tbody tr:last-child td { border-bottom: none; }
/* The delivery table has three columns to the bulk ladder's two, so it is
   allowed the full measure rather than sharing the ladder's narrow one. */
.op-bulk--wide td:not(:first-child),
.op-bulk--wide th:not(:first-child) { text-align: right; }

/* A hanging footnote mark: the dagger sits in the margin and the sentence
   keeps a straight left edge, rather than the first line being pushed in
   by the width of the symbol. */
.op-dagger {
  position: absolute;
  left: 0;
  color: var(--text-muted);
}

/* Several footnotes rather than one paragraph: each states a separate
   thing about the figures above, and running them together made a block
   that had to be read whole to find the one line that mattered. Same
   hanging dagger as the single note, applied per item. */
.op-bulk-notes {
  margin: 9px 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.op-bulk-notes li {
  position: relative;
  padding-left: 1.1em;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--text-secondary);
}

/* Below this width the card's two subsections are too cramped side by
   side, so they go back to stacking full-width — the header stays a
   plain centred row on top either way, so nothing needs overriding
   there, only the two steps' own column collapses to one. */
/* ── Catalog symbols (9 × 2) ──
   One rounded rectangle with the product card's border; the 18 symbols
   sit inside it without separators of their own. Named "strip" rather than
   "grid" because certifications.html already owns a `.symbol-grid` that
   pins itself to var(--catalog-grid-w) — reusing the name here let that
   width win and blew the box out to 1489px inside a 534px column. */


/* The symbols are 300×500 art, so height follows from the track width —
   nine tracks plus eight 12px gaps fill the column exactly. */

/* ============================================================
   18+ AGE GATE
   ============================================================ */
html.age-gate-open,
html.age-gate-open body {
  overflow: hidden;
}

.age-gate {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  /* No backdrop-filter. A blurring overlay makes the browser snapshot and
     re-composite everything behind it on every paint, and any element
     underneath that has its own compositing layer — the nav has its own
     backdrop-filter, cards have box-shadows and hover transitions — can be
     sampled into that snapshot late or not at all. What that looked like
     was pale rectangles the exact size of those elements appearing over
     the dimmed page, coming and going as the pointer moved.

     A flat rgba dim composites normally and cannot produce them. The alpha
     below is raised to carry the separation the blur used to. */
  background: rgba(10, 10, 10, 0.78);
}

.age-gate-panel {
  width: 100%;
  max-width: 460px;
  box-sizing: border-box;
  padding: 40px 32px 32px;
  text-align: center;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
}

.age-gate-logo {
  height: 38px;
  width: auto;
  margin: 0 auto 21.6px;   /* 24px − 10% */
  display: block;
}

.age-gate-heading {
  font-size: 26px;
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.2;
  margin: 0 0 12px;
}

.age-gate-body {
  font-size: 15px;
  line-height: 1.65;
  color: var(--text-secondary);
  margin: 0 0 28px;
}

/* ── The two declarations ──
   Stacked, left-aligned, each a full row. The panel's own text-align is
   centre, which is right for the heading and the body copy and wrong for
   these — a checkbox whose label is centred leaves the box floating in
   the middle of the line with no column for the eye to follow. */
.age-gate-checks {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 4px 0 22px;
  text-align: left;
}

.age-gate-check {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  cursor: pointer;
  font-size: 14px;
  line-height: 1.5;
  color: var(--text-secondary);
}
.age-gate-check input {
  /* flex-shrink:0 and the explicit box: a checkbox next to a two-line
     label gets squashed by the flex row otherwise. margin-top aligns it
     to the label's first LINE rather than to the block's centre. */
  flex: 0 0 auto;
  width: 17px;
  height: 17px;
  margin: 2px 0 0;
  accent-color: var(--text-primary);
  cursor: pointer;
}
.age-gate-check strong { color: var(--text-primary); font-weight: 700; }
.age-gate-check:hover { color: var(--text-primary); }

.age-gate-actions {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* Plainly unavailable until both boxes are ticked, rather than looking
   pressable and doing nothing. */
.age-gate-actions .hero-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.age-gate-actions .hero-btn {
  flex: 0 0 auto;
  width: 100%;
}

.age-gate-fine {
  margin: 24px 0 0;
  font-size: 12px;
  letter-spacing: 0.02em;
  color: var(--text-muted);
}

/* ── Phone: the gate IS the screen ──────────────────────────
   Asked for outright. A centred card floating on a dimmed page is a
   desktop shape: it says "there is a site behind this, and you are being
   briefly interrupted". On a phone that reading is wrong twice over —
   there is no room for the page around it to be seen, so the dim is just
   a border, and this is not an interruption but the actual first screen
   of the site. Nothing is reachable until it is answered.

   So it becomes the page: full bleed, square corners, no dim, content
   sitting at the top rather than optically centred in a box.

   Internally scrollable, and by the same argument the order dialog is
   (see .oc-modal): the panel carries a heading, two multi-line
   declarations with checkboxes, two buttons and a fine-print line. On a
   short phone in landscape that is taller than the viewport, and a
   flex-centred child overflows off the TOP as well as the bottom — where
   nothing can reach it, because the gate deliberately locks the body.

   100dvh, with vh under it. On a mobile browser vh is the tallest the
   viewport ever gets — measured with the address bar retracted — so a
   vh-only height is wrong by the height of the browser chrome for as
   long as that chrome is showing, which is exactly the Safari bottom-bar
   problem: the buttons sit under the bar until you scroll. dvh tracks
   the chrome as it comes and goes. */
@media (max-width: 768px) {
  .age-gate {
    padding: 0;
    align-items: stretch;
    justify-content: stretch;
    background: var(--bg-primary);
  }
  .age-gate-panel {
    max-width: none;
    width: 100%;
    min-height: 100vh;
    min-height: 100dvh;
    border: none;
    border-radius: 0;
    box-shadow: none;
    overflow-y: auto;
    overscroll-behavior: contain;
    /* Content at the top of the screen, not floating in the middle of
       it. The safe-area inset keeps the fine print and the buttons clear
       of the home indicator on a notched phone — without it the last
       line sits under it. */
    padding: 40px 24px calc(32px + env(safe-area-inset-bottom, 0px));
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  .age-gate-logo { margin-bottom: 28px; }
}

@media (max-width: 640px) {
  .age-gate-panel { padding: 32px 22px calc(24px + env(safe-area-inset-bottom, 0px)); }
  .age-gate-heading { font-size: 22px; }
}

/* ============================================================
   TRACKING / STATIC PAGES (About, FAQ)
   Shared look with the catalog body panel — same measure, same
   type scale as the rest of the site's long-form copy.
   ============================================================ */


/* The timeline. The connecting rule is drawn on the list itself so it sits
   behind every marker rather than being rebuilt per row. */


.tracking-step.is-done::before {
  background: var(--ok);
  border-color: var(--ok);
}

.tracking-step.is-current::before {
  background: var(--cta);
  border-color: var(--cta);
  box-shadow: 0 0 0 4px rgba(17,17,20,0.12);
}


.tracking-step:not(.is-done):not(.is-current) .tracking-step-title {
  color: var(--text-muted);
  font-weight: 500;
}


/* ── About / FAQ long-form ── */
.static-page {
  max-width: 760px;
  padding-bottom: 24px;
  /* Matches .catalog-hero-title's own extra inset (same var, same
     reasoning: the grid's first card gets this padding on top of
     .container's, so the title lines up with the card rather than the
     container edge) — without it, the title on every interior page
     (FAQ, Contact, Shipping, Returns, Terms, Privacy) sat 12px right of
     the body copy below it instead of flush with it. */
  padding-left: var(--grid-edge);
}


.static-page-body a,
.faq-answer a {
  text-decoration: underline;
  text-underline-offset: 2px;
}

.faq-list {
  margin-top: 26px;
}

.faq-item {
  border-bottom: 1px solid var(--border);
}
/* No rule under the LAST question in a subsection — asked for outright.
   .faq-item and .faq-section are flat siblings inside .faq-list (see
   faq.html), so the item right before the next h2.faq-section is the
   close of its own group; that heading already draws a border-top 68px
   below it (see .faq-section above), so a border-bottom here as well
   would put two dividers on screen for one break between sections. Also
   covers the very last item on the page, which has no section below it
   to draw a matching rule at all. */
.faq-item:has(+ .faq-section),
.faq-item:last-child {
  border-bottom: none;
}

.faq-question {
  list-style: none;
  cursor: pointer;
  padding: 18px 34px 18px 0;
  position: relative;
  font-size: 18px;
  font-weight: 700;
  color: var(--text-primary);
  transition: color var(--transition);
}

.faq-question::-webkit-details-marker { display: none; }

.faq-question:hover { color: var(--text-muted); }

/* Chevron, rotated open. Drawn in CSS so the summary stays plain text. */
.faq-question::after {
  content: '';
  position: absolute;
  right: 6px;
  top: 50%;
  width: 9px;
  height: 9px;
  margin-top: -6px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform var(--transition);
}

.faq-item[open] .faq-question::after {
  transform: rotate(-135deg);
  margin-top: -2px;
}

.faq-answer {
  font-size: 17px;
  line-height: 1.75;
  color: var(--text-secondary);
  padding: 0 34px 20px 0;
}
/* The answer is a <div> of paragraphs now, not a single <p>. Several
   answers genuinely have two or three things to say — what a rule is,
   then the consequence of it — and running those together was what made
   the longer ones hard to scan. First and last margins are collapsed
   into the container's own padding so a multi-paragraph answer occupies
   exactly the same box as a one-line one. */
.faq-answer p { margin: 0 0 0.85em; }
.faq-answer p:last-child { margin-bottom: 0; }

/* ── Section headings inside the list ──
   The list is long enough now that it needs signposting: a reader
   looking for a shipping answer should be able to find the shipping
   block rather than opening questions until one matches.

   It sits ON the list's own dividing rules rather than between them —
   the top border draws the break, so a heading does not need a second
   rule of its own. The first one has no border: .faq-list used to draw
   one above it, which read as a divider sitting above the page's very
   first heading with nothing to divide from — removed, so the section
   list simply starts.

   Top padding is what separates one section from the last question of
   the one before it — doubled (34px → 68px) so a heading reads as a
   clean break rather than crowding the answer text just above it. */
.faq-section {
  margin: 0;
  padding: 68px 0 12px;
  border-top: 1px solid var(--border);
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.faq-section:first-child {
  border-top: none;
  padding-top: 22px;
}

/* ══ Checkout order summary ══════════════════════════════════
   Was a run of text rows: "Retatrutide · 10mg × 2 …$318.00". A parcel of
   near-identical small vials is exactly the order where "did I pick 10mg
   or 20mg?" is worth answering by LOOKING, so each line carries the same
   bottle photo the cart and catalog use, with the quantity as a badge on
   the corner rather than buried in the name.
   ──────────────────────────────────────────────────────────── */
.summary-items {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-bottom: 18px;
  padding-bottom: 18px;
  border-bottom: 1px solid var(--border-soft);
}
.summary-item { display: flex; align-items: center; gap: 16px; }

/* ── The product shot ──
   No frame and no card behind it. These are bottles photographed on
   white; a box around one adds a rectangle the product already has, and
   the grey fill behind it turned every shot slightly dingy. Letting the
   bottle sit directly on the panel is both truer to the photograph and
   the reason it can be this size without crowding — the only thing taking
   up room is the product.

   72px tall against 46px before: at a thumbnail size these read as
   coloured smudges, and the cap colour is how customers tell two
   otherwise identical vials apart. */
.summary-item-img {
  position: relative;
  flex: 0 0 auto;
  width: 60px; height: 72px;
  /* The bottle crops are 1:2, so height is the constraint and width
     follows. No overflow:hidden — the quantity badge deliberately sits
     outside this box's corner, and clipping here swallowed it whole. */
  display: flex; align-items: center; justify-content: center;
}
.summary-item-img img {
  width: auto; height: 100%; max-width: 100%;
  object-fit: contain; display: block;
  /* A whisper of depth so a white bottle on a white panel still reads as
     an object rather than a floating label. */
  filter: drop-shadow(0 2px 5px rgba(10, 10, 12, 0.14));
}
/* Sat on the corner rather than in the name: it is a count, and a count
   reads faster as a number on the thing it counts. */
.summary-item-qty {
  position: absolute;
  top: 2px; right: -4px;
  min-width: 21px; height: 21px;
  padding: 0 6px;
  box-sizing: border-box;
  border-radius: 999px;
  background: var(--accent-bright);
  color: #fff;
  font-size: 11.5px; font-weight: 700; line-height: 21px;
  text-align: center;
  /* A ring in the panel's own colour, so the badge separates from the
     bottle behind it now that there is no frame to sit on. */
  box-shadow: 0 0 0 3px var(--bg-primary);
}
.summary-item-text { min-width: 0; flex: 1 1 auto; }
.summary-item-name {
  display: block;
  font-size: 14.5px; font-weight: 700; color: var(--text-primary);
  line-height: 1.3;
}
.summary-item-size { display: block; margin-top: 2px; font-size: 12px; color: var(--text-muted); }
.summary-item-price {
  flex: 0 0 auto; text-align: right;
  font-size: 14px; font-weight: 700; color: var(--text-primary);
  white-space: nowrap;
}
.summary-item-was {
  display: block; margin-top: 1px;
  font-size: 12px; font-weight: 500; color: var(--text-muted);
  text-decoration: line-through;
}

.summary-credit { color: #15803d; }
.summary-free { color: #15803d; font-weight: 700; }

/* ── Free-express meter ──
   The offer on the black bar at the top of every page, made specific to
   this cart: how much further it has to go.

   Deliberately NOT a card. It sat in a bordered grey box between two
   totals rows, which made it read as a widget someone had dropped into
   the receipt — the one boxed thing in a column of plain figures. It now
   hangs off the Shipping line as an annotation to it: a hairline rule, a
   thin bar, and a sentence, all in the type already used around it. The
   meter belongs to that row, and now looks like it.

   Kept visible once earned — a benefit that disappears at the moment it
   is won reads as a bug — but reduced to a single green line, because at
   that point it is confirming rather than persuading. */
.ship-meter {
  /* Pulled up only as far as the Shipping row's own bottom margin, so the
     bar reads as belonging to that line without crowding its baseline. */
  margin: -5px 0 16px;
}
.ship-meter-track {
  height: 3px;
  border-radius: 999px;
  background: var(--border-soft);
  overflow: hidden;
}
.ship-meter-fill {
  display: block; height: 100%; width: 0;
  border-radius: 999px;
  background: var(--text-primary);
  transition: width 0.35s ease, background var(--transition);
}
.ship-meter-text {
  margin: 6px 0 0;
  font-size: 11.5px; line-height: 1.45; color: var(--text-muted);
}
.ship-meter-text strong { color: var(--text-primary); font-weight: 700; }

/* Earned: the bar goes green and the track under it disappears, so the
   row settles into a statement rather than a half-finished gauge. */
.ship-meter.is-earned .ship-meter-track { background: transparent; }
.ship-meter.is-earned .ship-meter-fill { background: var(--ok); }
.ship-meter.is-earned .ship-meter-text { color: #15803d; }
.ship-meter.is-earned .ship-meter-text strong { color: #14532d; }

/* ── Diluent upsell ──
   Lyophilised peptide cannot be used without a diluent, so a cart of
   vials and no water is more likely a forgotten line than a decision.
   Offered once, inline, and only while it is genuinely absent. */
/* ── The pre-order confirmation ──
   Replaces the offer's own row in place when Add is pressed on a
   pre-order diluent. Not a modal: this is a $12 decision about an item
   whose card is right there, and covering the page to ask about it is out
   of proportion to what is being asked. */
.summary-upsell-confirm { display: none; }
.summary-upsell.is-confirming .summary-upsell-img,
.summary-upsell.is-confirming .summary-upsell-text,
.summary-upsell.is-confirming .summary-upsell-add { display: none; }
.summary-upsell.is-confirming .summary-upsell-confirm {
  display: block;
  grid-column: 1 / -1;
}
.summary-upsell-confirm-body {
  margin: 0 0 12px;
  font-size: 13px;
  line-height: 1.55;
  color: var(--text-secondary);
}
.summary-upsell-confirm-body strong { color: var(--text-primary); }
.summary-upsell-confirm-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* Both are .btn-cart in the markup now, so everything structural — height,
   radius, weight, letter-spacing, uppercase — is already identical and
   only these three lines size them for this row. */
.summary-upsell-yes, .summary-upsell-no {
  flex: 1 1 auto;
  padding: 9px 14px;
  font-size: 13px;
  min-width: 0;
}
/* ── The two colours, the other way round ──
   Asked for outright: "Add it anyway" is the WHITE one and "No thanks" is
   the black. It reads oddly at first glance and is right on reflection —
   the black, filled button is the site's primary-action look, and the
   primary action in this particular box is declining. Saying yes here
   means accepting a pre-order that holds the rest of the parcel back, so
   it should not wear the colour that means "just press this".

   .summary-upsell-no needs no rule of its own: black on white IS
   .btn-cart's own look, so it simply keeps it. Only the yes is inverted,
   with a border because a white button on the card's near-white ground
   would otherwise have no edge. Hover/active are restated because
   .btn-cart's own darken-the-fill hover would take this back to black. */
.summary-upsell-yes {
  background: var(--bg-primary);
  color: var(--text-primary);
  border: 1px solid var(--border-accent);
}
.summary-upsell-yes:hover { background: var(--bg-card-hover); }
.summary-upsell-yes:active { background: var(--bg-card-hover); }

/* ── The diluent offer, as a line in the summary rather than an ad ──
   It used to be a dashed-border card dropped into the list — its own
   little box, with a background and a frame nothing else in the summary
   has. That box's bottom edge sat right above .co-totals's own top
   hairline, which read as two dividers stacked over Subtotal. Styled
   instead as one more row — no border, no fill, the same 22px the item
   rows themselves use above it — there's exactly one hairline left
   above the totals, wherever this offer happens to be showing. */
.summary-upsell {
  display: flex; align-items: center; gap: 14px;
  margin: -8px 0 22px;
  padding: 0;
  border: none; border-radius: 0;
  background: transparent;
}
.summary-upsell-img {
  flex: 0 0 auto;
  width: 48px; height: 48px;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden; border-radius: 6px;
}
.summary-upsell-img img {
  width: 100%; height: 100%;
  object-fit: contain; display: block;
}
.summary-upsell-text { min-width: 0; flex: 1 1 auto; }
.summary-upsell-title {
  display: block; font-size: 13px; font-weight: 700; color: var(--text-primary);
}
.summary-upsell-body {
  display: block; margin-top: 2px;
  font-size: 12px; line-height: 1.45; color: var(--text-secondary);
}
.summary-upsell-add { flex: 0 0 auto; font-size: 13px; padding: 7px 16px; }

/* Pre-order warning inside the offer. Amber, not red: this is a
   condition of the purchase, not an error — but it has to be legible
   before the Add button, because it changes when the WHOLE order ships. */
.summary-upsell-warn {
  display: block;
  margin-top: 7px;
  padding: 5px 9px;
  border: 1px solid #f5c78a;
  border-radius: 6px;
  background: #fdf5e7;
  color: #92400e;
  font-size: 11.5px; font-weight: 700; line-height: 1.4;
}

/* ── "Your data" — export and deletion ────────────────────────
   The last block of the profile panel. Two tools with the same shape so
   they read as one section: a title, a sentence of consequence, and one
   control. Only the second is destructive, and only the second is behind
   a disclosure.
   ──────────────────────────────────────────────────────────── */
.data-tools {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 14px;
}
.data-tool {
  padding: 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-primary);
}
/* The export row lays its control out beside the copy; the delete one is
   a <details> whose summary does that instead (below). */
.data-tool:not(.data-tool--danger) {
  display: flex;
  align-items: center;
  gap: 16px;
}
.data-tool-text { display: block; min-width: 0; flex: 1 1 auto; }
.data-tool-title {
  display: block;
  font-size: 15px; font-weight: 700; color: var(--text-primary);
}
.data-tool-body {
  display: block; margin-top: 3px;
  font-size: 13px; line-height: 1.5; color: var(--text-secondary);
}

/* Red border, not a red panel: the section is a place to read before it
   is a place to act, and a solid red block reads as an error that has
   already happened. */
.data-tool--danger { border-color: #f3c6bd; }
.data-tool-summary {
  display: flex;
  align-items: center;
  gap: 12px;
  cursor: pointer;
  /* The default triangle is replaced by the chevron below, which can be
     rotated with the open state. */
  list-style: none;
}
.data-tool-summary::-webkit-details-marker { display: none; }
.data-tool-summary::after {
  content: '';
  flex: 0 0 auto;
  width: 9px; height: 9px;
  margin-right: 4px;
  border-right: 2px solid var(--text-muted);
  border-bottom: 2px solid var(--text-muted);
  transform: rotate(45deg);
  transition: transform var(--transition);
}
.data-tool--danger[open] .data-tool-summary::after { transform: rotate(-135deg); }
.data-tool--danger .data-tool-title { color: #b42318; }

.data-tool-panel {
  margin-top: 18px;
  padding-top: 18px;
  border-top: 1px solid var(--border-soft);
}
.data-tool-note {
  margin: 0 0 18px;
  font-size: 13px; line-height: 1.6; color: var(--text-secondary);
}

.btn-danger { background: #b42318; }
.btn-danger:hover { background: #96200f; }
.btn-danger:disabled { opacity: 0.6; }

@media (max-width: 640px) {
  .data-tool:not(.data-tool--danger) { flex-direction: column; align-items: stretch; }
}

/* ── Profile save bar ─────────────────────────────────────────
   Raised by js/account.js the moment a contact field differs from what
   the database holds. Contact edits are provisional until this button is
   pressed — switching tabs or leaving the page discards them — so this
   bar is the only route from "typed" to "saved", and it has to be
   impossible to miss.

   bottom/z-index deliberately match .cart-float-bar, which occupies the
   same corner of the screen; body.has-save-bar hides that one for as
   long as this is up rather than letting the two overlap. */
.profile-save-bar {
  position: fixed;
  left: 50%;
  bottom: 24px;
  transform: translateX(-50%);
  z-index: 95;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px 12px 12px 22px;
  background: var(--text-primary);
  color: #fff;
  border-radius: var(--radius-lg);
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
  max-width: calc(100vw - 32px);
}
.profile-save-bar[hidden] { display: none; }

.profile-save-text {
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
}

.profile-save-btn {
  border: none;
  cursor: pointer;
  padding: 10px 20px;
  border-radius: calc(var(--radius-lg) - 4px);
  background: #fff;
  color: var(--text-primary);
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
}
.profile-save-btn:hover { background: var(--bg-secondary); }
.profile-save-btn:disabled { opacity: 0.6; cursor: default; }

body.has-save-bar .cart-float-bar { display: none; }

@media (max-width: 640px) {
  .profile-save-bar { gap: 10px; padding-left: 16px; width: calc(100vw - 32px); }
  .profile-save-text { white-space: normal; }
  .profile-save-btn { margin-left: auto; }
}

/* .op-bleed no longer needs position:relative for a rail (removed — it
   used to run behind the cards as a vertical line+dots threading the
   whole flow), but nothing else inside it needs repositioning either, so
   left as a harmless no-op rather than hunted down and removed. */
.op-bleed {
  position: relative;
  /* Hoisted here rather than onto .op-tabs-wrap so the TITLE BLOCK can
     use it too: the heading and disclaimer are siblings of the tabs, and
     they have to land on the same x as the first tab's label. */
  --op-inset: clamp(16px, 2vw, 28px);

  /* ── The order-process band ──
     No fixed height any more. It used to be 2 x the height visible below
     the fixed chrome, and with the content centred in that box the
     leftover space landed as a ~467px gap above AND below — far larger
     than any padding here, which is why quartering the padding alone did
     nothing. The space above and below is now the padding-block further
     down this file, so it is the thing that actually sets the gap. */
  width: 100vw;
  margin-left: calc(50% - 50vw);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

/* ── The order-process card ───────────────────────────────────
   One enclosing card: title and framing sentence, then a three-way
   slider, then the selected step's content in a card of its own.

   The inner two (slider and content) share the width the old tab panel
   had; the enclosing card is that plus its own padding, so it reads as a
   frame around them rather than as a fourth box competing with them. */
.op-card {
  /* ── The whole block, 10% smaller ──
     zoom, not transform: scale(). A transform shrinks the PAINT but
     leaves the original box in the layout, so the card would have kept
     its full-size footprint and floated in a hole the shape of the 10%
     it no longer used. zoom scales the used values, so the layout
     shrinks with the content and everything inside — type, padding,
     radii, the tiles, the tables — keeps its proportions to everything
     else. That is what "the same, just smaller" actually asks for. */
  zoom: 0.9;

  --op-inner-w: 884px;
  --op-card-pad: clamp(22px, 2.6vw, 40px);

  width: min(100%, calc(var(--op-inner-w) + 2 * var(--op-card-pad)));
  margin-inline: auto;
  padding: var(--op-card-pad);
  box-sizing: border-box;

  /* No frame: border, radius and shadow removed along with the grid the
     card used to sit on. With nothing behind it to be lifted off, the
     card read as a box drawn around content for no reason — the centring
     in .op-bleed is what groups it now. */
}

.op-card-head {
  text-align: center;
  /* × 1.5 — the framing sentence and the control it introduces were
     sitting closer together than the control and its own content. */
  margin-bottom: clamp(30px, 3.9lvh, 48px);
}

/* The phone-only journey indicator — see the PHONE HERO block for the
   full rule (dots, line, fill). Off by default; the desktop/tablet
   three-tab slider is what carries this job at every width above the
   phone tier. */
/* .op-journey and its dots are gone — see the note in index.html where
   the markup used to be. */

/* The section's title. It carries .process-mega-heading for the name, but
   that class has no base styling of its own — every rule for it is scoped
   to .research-banner--capped, and this heading sits outside that wrapper
   (it has no disclaimer line to pair with). So the type is restated here
   from the same --op-heading-unit the featured-products title uses, which
   is what keeps the two reading as one treatment.

   Two steps down from that title, though: it is inside a card now rather
   than spanning the page, and at the full 3.41 it overwhelmed the box
   holding it. */
.op-heading {
  margin: 0;
  font-weight: 800;
  letter-spacing: -0.035em;
  line-height: 1.12;
  color: var(--text-primary);
  /* Matched to the hero's "Tested & Delivered" — same expression as
     .hero-title-subtitle (0.82em of the hero title, which is itself
     clamp(32px,5vw,72px) * 0.9 * 1.15), spelled out here rather than
     shared via a variable because the hero's clamp is scoped to
     .hero-title and reads oddly reused on a heading with no relation to
     it. Was --op-heading-unit * 2.3, which put the two big titles on the
     page at two different sizes for no reason the reader could see. */
  font-size: calc(clamp(32px, 5vw, 72px) * 0.9 * 1.15 * 0.82);
}
.op-card-sub {
  margin: 12px auto 0;
  max-width: 62ch;
  font-size: calc(clamp(32px, 5vw, 72px) * 0.9 * 1.15 * 0.82 * 0.661 / 2.3);
  color: var(--text-secondary);
  line-height: 1.65;
}

/* ── The slider ──
   A segmented control: a light-grey track with three equal thirds, the
   selected one lifted out of it in white.

   grid rather than flex, because the three shares have to be EQUAL and
   the labels are very different lengths — flex would size each to its own
   content and the three "Step n" eyebrows would land at three unrelated
   x positions instead of forming a column. */
/* ── The slider ──
   A segmented control: a light-grey track, three equal thirds, and one
   white thumb that SLIDES between them.

   The thumb is a single absolutely-positioned element rather than a
   background on the selected segment — that is the whole reason the
   selection can travel. Its width is exactly one third of the track's
   inner width and it moves in whole multiples of its own width, so it
   lands on each segment precisely without anything being measured.

   No `gap` on the grid: a gap would make each column (100% - gaps)/3
   rather than a clean third, and the thumb's translate would drift
   further off with each step. The separation between segments comes from
   the segments' own padding instead. */
.op-slider {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  --op-track-pad: 5px;
  padding: var(--op-track-pad);
  background: var(--bg-secondary);
  border: 1px solid var(--border-soft);
  border-radius: 16px;
}

/* Set by the slider script to 0, 1 or 2. */
.op-slider-thumb {
  position: absolute;
  top: var(--op-track-pad);
  bottom: var(--op-track-pad);
  left: var(--op-track-pad);
  width: calc((100% - 2 * var(--op-track-pad)) / 3);
  transform: translateX(calc(var(--op-active, 0) * 100%));

  background: var(--bg-primary);
  border: 1px solid var(--border-soft);
  border-radius: 12px;
  box-shadow: 0 1px 2px rgba(10, 10, 12, 0.06), 0 6px 16px rgba(10, 10, 12, 0.08);

  /* The slide. Slightly overshooting cubic-bezier so it settles rather
     than stopping dead — a linear or ease-out slide of a small object
     across a short distance reads as mechanical. */
  transition: transform 0.34s cubic-bezier(0.34, 1.28, 0.48, 1);
  pointer-events: none;
}

.op-seg {
  position: relative;   /* over the thumb */
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 3px;
  /* Left-aligned inside its own third — the second half of "equal
     shares, left-aligned content". --op-seg-inset is the distance from
     the TRACK's outer edge to this text, and the panel copy below reads
     the same value so the two line up. */
  align-items: flex-start;
  text-align: left;
  min-width: 0;
  padding: 13px 16px;
  border: none;
  background: transparent;
  color: var(--text-muted);
  font-family: inherit;
  cursor: pointer;
  transition: color var(--transition);
}
.op-seg:hover { color: var(--text-primary); }
/* The thumb has no outline of its own, so the keyboard state needs one. */
.op-seg:focus-visible { outline: 2px solid var(--text-primary); outline-offset: -3px; }
.op-seg[aria-selected="true"] { color: var(--text-primary); cursor: default; }

.op-seg-step {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  opacity: 0.7;
}
.op-seg-label {
  font-size: clamp(15px, 1.25vw, 18px);
  font-weight: 700;
  letter-spacing: -0.015em;
  line-height: 1.25;
}

/* ── The content ──
   No card of its own any more. It sits directly in the enclosing card, so
   the section reads as one object with a control at the top rather than
   as two stacked boxes.

   The horizontal inset matches the slider's own text inset exactly: the
   track's 5px padding plus a segment's 16px. That is what puts the left
   column's first character on the same x as "STEP 1", and the right
   column's right edge the same distance in from the other side. */
.op-content {
  --op-seg-inset: calc(var(--op-track-pad, 5px) + 16px);
  /* The same clamp .op-card-head uses for its margin-bottom, so the
     slider sits with equal air above and below it. It was the smaller
     clamp(20px, 2.4vh, 30px), which pushed the control up towards the
     description it belongs under and away from the content it selects. */
  margin-top: clamp(30px, 3.9lvh, 48px);
  padding-inline: var(--op-seg-inset);
}

.op-panel {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: clamp(28px, 4vw, 56px);
  row-gap: clamp(17px, 2.4lvh, 26px);
  align-items: start;
}
/* `hidden` is not used for the swap any more — the script cross-fades
   with .is-leaving/.is-entering instead, and a `display:none` in the
   middle of that would make the fade a cut. Kept for the no-JS case. */
.op-panel[hidden] { display: none; }

/* ── The cross-fade ──
   The outgoing panel is lifted out of flow so both are painted at once
   and the container's height is driven by the incoming one alone. Without
   that there is a frame with neither in flow, the card collapses, and the
   page below jumps. */
/* Height is fixed to the TALLEST panel and never animates. It used to
   grow and shrink with whichever stage was showing, so choosing a tab
   moved everything below it — the disclaimer and the product grid slid up
   and down for a change that had nothing to do with them.

   --op-panels-h is measured once by the slider script: it lays out each
   panel in turn, takes the largest, and pins it. Content stays at the top
   of that box rather than centring in it, so the short stages look like
   short stages rather than floating ones. */
.op-panels {
  position: relative;
  height: var(--op-panels-h, auto);
}
.op-panel {
  opacity: 1;
  transform: translateX(0);
  /* Slide AND fade. It used to be a pure cross-fade, which left the copy
     sitting still while the thumb travelled — the two halves of the same
     gesture disagreeing. --op-dir is set to +1/-1 by the slider script so
     the content leaves and enters on the side the thumb is moving
     towards, and the fade rides along with it rather than replacing it. */
  transition: opacity 0.24s ease, transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* Every panel is absolutely positioned now, not just the outgoing one:
   with the container at a fixed height nothing needs to be in flow to
   size it, and top:0 is what keeps each stage's content at the top of the
   box instead of drifting. */
.op-panel {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.op-panel.is-leaving {
  opacity: 0;
  transform: translateX(calc(var(--op-dir, 1) * -28px));
  pointer-events: none;
}
/* The incoming panel starts on the far side and is moved to 0 on the next
   frame — same distance, opposite sign, so the pair reads as one strip of
   content sliding past rather than two separate moves. */
.op-panel.is-entering {
  opacity: 0;
  transform: translateX(calc(var(--op-dir, 1) * 28px));
}

@media (prefers-reduced-motion: reduce) {
  .op-slider-thumb, .op-panel, .op-panels { transition: none; }
  .op-panel.is-leaving, .op-panel.is-entering { transform: none; }
}

@media (max-width: 1024px) {
  /* Three equal segments stop working before the labels do. Stacked, the
     thumb's "one third of the track, moved by whole multiples of itself"
     geometry becomes one third of the HEIGHT — so it moves on Y here. */
  .op-slider { grid-template-columns: 1fr; }
  .op-slider-thumb {
    width: calc(100% - 2 * var(--op-track-pad));
    height: calc((100% - 2 * var(--op-track-pad)) / 3);
    bottom: auto;
    transform: translateY(calc(var(--op-active, 0) * 100%));
  }
  .op-panel { grid-template-columns: minmax(0, 1fr); }
}


/* ── The plain text links that replaced two visuals ───────────
   Was a four-bottle picker in the first card and an animated certificate
   in the second. Both were miniature versions of pages that already
   exist and do the job better, so each is now a plain line of text that
   goes there. Underline offset and the arrow's slide on hover are what
   make it read as tappable without becoming a button — a button here
   would compete with the card's own heading. */
/* ONE line under the whole CTA, text and arrow together.

   It used to be display:inline-flex with a gap. That is what broke the
   rule in two: flex items are laid out as separate boxes and each is
   decorated on its own, and the gap between them belongs to no box at
   all — so the underline came apart with a hole in the middle, and the
   two halves lit up as independent elements on hover.

   Without the flex container the CTA is one ordinary block of inline
   content, and its text-decoration is drawn as a single rule across the
   whole run: the arrow sits after a real space in the same run, so it is
   covered by the same line and changes colour with it. Decoration also
   follows the text rather than the box, which matters for the long
   review CTA — that one wraps, and a border-bottom would have hung a
   full-width rule off the end of a half-empty second line. */
.op-link {
  align-self: flex-start;
  margin-top: 10px;
  display: inline-block;
  font-size: 15px;
  font-weight: 600;
  color: var(--text-primary);
  text-decoration: underline;
  text-decoration-color: var(--border);
  text-underline-offset: 5px;
  padding: 6px 0 4px;
  transition: text-decoration-color 0.2s ease, color 0.2s ease;
}
/* The arrow slides 4px right on hover; the padding is that 4px reserved
   inside the decorated run, so the arrow lands on the end of its own
   underline instead of overshooting it. */
.op-link span { transition: transform 0.2s ease; display: inline-block; padding-right: 4px; }
.op-link:hover { text-decoration-color: var(--text-primary); }
.op-link:hover span { transform: translateX(4px); }

/* When .op-link is a <button> (the Contact CTA, which opens a modal
   rather than navigating anywhere) rather than an <a> — strips the
   default button chrome so it reads identically to the anchor-based
   links it sits alongside. */
button.op-link {
  border: none;
  background: none;
  font-family: inherit;
  cursor: pointer;
  text-align: left;
}
.op-link:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 3px; border-radius: 2px; }

/* ── Google sign-in preview ────────────────────────────────────
   Non-functional design placeholder — see the comment on the markup in
   index.html for what it's for and why it currently shows on every
   dismissal of the discount popup rather than once. Dark rounded card,
   deliberately similar in shape to a native OS/browser account prompt
   (icon, one line of context, one primary action) rather than styled
   like the rest of this site's own components — it's previewing a
   THIRD-PARTY-flavoured interaction (Google's), which is exactly the
   kind of moment that's supposed to look slightly apart from a site's
   own UI, the same way a real browser or OS account picker does. */
.gsi-preview {
  position: fixed;
  /* Above .nav (100) AND the standing-offer strip (.promo-bar, also 101),
     both of which this now sits over — it is pinned to the window's own
     top-right corner, so anything fixed up there is something it has to
     clear rather than sit under. Still below the discount overlay (1000)
     and the age gate (9999), neither of which is ever on screen at the
     same time as this. */
  z-index: 102;
  width: 320px;
  max-width: calc(100vw - 32px);
  padding: 16px;
  border-radius: 16px;
  background: #202124;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.2);
  color: #e8eaed;
  /* ── The actual top-right corner ──
     Asked for outright. This used to be offset down past the
     standing-offer strip AND the nav bar (var(--promo-h) + var(--nav-h) +
     the gutter), which put it a third of the way down a phone screen —
     "below everything fixed at the top" rather than in a corner at all.

     Now it is one gutter from the top edge and one from the right, which
     is what makes the two gaps equal and what makes it read as a corner
     object: the same relationship the OS and browser account prompts it
     is imitating have to their own window. It overlaps the offer strip
     and the nav, deliberately — that is what being in the corner means,
     and the z-index above is what makes the overlap land the right way
     round.

     --nav-gutter's 12px mobile override is scoped to .nav itself, so this
     resolves the :root 16px at every width. */
  top: var(--nav-gutter, 16px);
  right: var(--nav-gutter, 16px);
  transform-origin: top right;
  animation: gsiPreviewIn 0.3s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
.gsi-preview[hidden] { display: none; }

.gsi-preview-close {
  position: absolute;
  top: 10px;
  right: 10px;
  width: 24px;
  height: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: none;
  color: #9aa0a6;
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
}
.gsi-preview-close:hover { background: rgba(255, 255, 255, 0.08); color: #e8eaed; }

.gsi-preview-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding-right: 20px;   /* clears the close button */
}

.gsi-preview-icon {
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
}

.gsi-preview-text {
  margin: 0;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.4;
}

.gsi-preview-btn {
  display: block;
  width: 100%;
  margin-top: 16px;
  padding: 11px;
  border: none;
  border-radius: 999px;
  background: #8ab4f8;
  color: #202124;
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.15s ease;
}
.gsi-preview-btn:hover { background: #aecbfa; }
/* An <a> now, not a <button> — OAuth starts as a top-level navigation.
   Anchors bring their own underline and inherited colour with them. */
.gsi-preview-btn { text-align: center; text-decoration: none; }

@keyframes gsiPreviewIn {
  from { opacity: 0; transform: scale(0.85) translateY(-6px); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .gsi-preview { animation: none; }
}

/* ============================================================
   HOME — FEATURED PRODUCTS BAND
   ------------------------------------------------------------
   Carries the featured grid and its title block, starting immediately
   below the hero (which is min-height:100vh) so the first thing any
   scroll reveals is this.

   This was briefly a black field with every piece of chrome inside it
   inverted — title, disclaimer, prompt, frame, primary button and card
   borders all had dark-field overrides. Reverted to the page's own white
   ground, so all of those inherit their normal styling again and none of
   the overrides are needed.
   ============================================================ */
.grid-bleed {
  background: var(--bg-primary);
  /* Halved from clamp(56px, 7vw, 96px). This plus .op-bleed's own
     padding-top is the whole gap between the Browse Products frame and
     the order-process title, so both were cut. */
  padding-bottom: clamp(28px, 3.5vw, 48px);
}

.research-banner--band {
  background: transparent;
  /* Halved from clamp(48px, 6vw, 80px) — everything below moves up with
     it, which is the point. */
  padding-top: clamp(24px, 3vw, 40px);
}

/* Two CTAs stacked at the foot of the Aftercare step are a pair, not two
   separate endings — they sit closer to each other than either sits to
   the copy above. A link's own top offset is margin 10 + padding 6 = 16px;
   dropping the margin and halving the padding halves the gap to 8px,
   without touching the space between the body copy and the first CTA.
   Zeroing the padding outright would leave the second CTA's text sitting
   directly on the first one's underline now that the underline is a real
   border on the box rather than a text decoration inside it. */
.op-link + .op-link {
  margin-top: 0;
  padding-top: 8px;
}

.op-link.op-link--spaced {
  padding-top: 0 !important;
}

/* .hero-content keeps its stacking context: .hero-orb-wrap sits at
   z-index 2, and without a context of its own the copy column could be
   painted under anything positioned that lands between them. (This is
   what remains of a grid + aurora pair that briefly sat behind the hero
   — both removed.) */
.hero-content {
  position: relative;
  z-index: 2;
}

/* ============================================================
   LOADING SKELETONS (storefront)
   ------------------------------------------------------------
   The same idea as the admin panel's .admin-loading rows, applied to the
   two storefront pages that render nothing until /api/catalog answers:
   the catalog grid and a product page. Both were blank in the meantime,
   so the page appeared broken on a slow connection and then snapped into
   place.

   These live in the PAGE'S OWN HTML rather than being injected by
   script, so they are on screen from the first paint — before any of the
   catalog JS has parsed, which is exactly the window they exist to
   cover. Whatever renders the real content replaces them wholesale by
   assigning innerHTML, so there is nothing to tear down.

   The shimmer is shared with the admin panel (admin-shimmer) rather than
   redefined, so the two never drift.
   ============================================================ */
.pa-skel {
  border-radius: 6px;
  background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-soft, var(--border)) 50%, var(--bg-secondary) 75%);
  background-size: 200% 100%;
  animation: admin-shimmer 1.3s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .pa-skel { animation: none; }
}

/* ── A placeholder the real card can replace without moving anything ──
   Every figure below is the corresponding part of .product-card, so the
   two come out the same height and the grid does not reflow when the
   catalog arrives. They were not: the placeholder was a square image
   over three evenly spaced bars, which on a phone stood 454px against a
   real card's 442px AND put its image well 75px too deep — so every
   card visibly jumped when the fetch resolved.

   Measured against a rendered card rather than read off the rules, then
   checked back against them:

     .card-img-wrap   250.13px on a phone, square above (see below)
     .card-header      82px, + its own 8px margin-bottom
     .card-body       100px = the 36px stepper, its 8px margin,
                      the 36px button, and 20px of padding under them
     .card-top /
     .card-body       20px of side padding each

   442.13 = 250.13 + 82 + 8 + 100 + 2px of border. */
.pa-skel-card {
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--bg-primary);
  display: flex;
  flex-direction: column;
}
/* Square, matching .card-img-wrap's own aspect-ratio at this tier. The
   phone tier pins it to a fixed height instead — see there. */
.pa-skel-card .pa-skel-img {
  aspect-ratio: 1 / 1;
  border-radius: 0;
}
.pa-skel-card .pa-skel-lines {
  /* .card-top's and .card-body's shared 20px inset, and .card-body's
     20px under its last control. */
  padding: 0 20px 20px;
  display: flex;
  flex-direction: column;
  /* No gap: each bar below carries the margin its real counterpart
     does, which is the only way the total can match. */
  gap: 0;
}
/* Stands in for .card-header — a fixed box with the name and meta bars
   bottom-anchored in it, exactly as the real one anchors its two lines,
   so a placeholder and a card with a one-line name agree. */
.pa-skel-head {
  height: 82px;
  margin-bottom: 8px;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 6px;
}
.pa-skel-line { height: 14px; }
.pa-skel-line--sm { height: 11px; width: 55%; }
/* .card-qty-selector, margin included. */
.pa-skel-line--qty { height: 36px; margin-bottom: 8px; border-radius: var(--radius-md); }
/* .btn-cart, which is a flat 36px. */
.pa-skel-line--btn { height: 36px; border-radius: var(--radius-md); }
/* The lab-results card has no stepper and a shorter header (see
   .cert-card-header's own 53.375px), so its placeholder says so. */
.pa-skel-card--cert .pa-skel-head { height: 53.375px; }

/* Product page: image on the left, copy stack on the right — the same
   two-column split product.js renders into. */
.pa-skel-product {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(24px, 4vw, 56px);
  align-items: start;
}
.pa-skel-product .pa-skel-hero {
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-lg);
}
.pa-skel-product .pa-skel-copy {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.pa-skel-product .pa-skel-line--title { height: 30px; width: 70%; }

@media (max-width: 1024px) {
  .pa-skel-product { grid-template-columns: minmax(0, 1fr); }
}

/* The phone tier pins .card-img-wrap to a fixed height rather than a
   square (see the grid rules in the ≤768px block), so the placeholder
   has to read the same expression — a square here is 325px against the
   real 250px, which was most of the jump this rule exists to remove. */
@media (max-width: 768px) {
  .pa-skel-card .pa-skel-img {
    aspect-ratio: auto;
    height: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.7);
  }
}

/* ============================================================
   HOME — HARMONISING PASS
   ------------------------------------------------------------
   Small alignment corrections only. Each of these is a value that no
   longer matches its neighbours after the larger changes above, rather
   than a new design decision.
   ============================================================ */

/* The hero's own top padding was tuned for a nav that floated 12px down
   the page inside a rounded pill. The bar is flush and shorter now, so
   this was leaving the copy sitting lower than the orb's optical centre. */
/* ── Vertically centred between the nav and the grid's bottom line ──
   The hero is 100vh tall and the page grid's bottom row closes on exactly
   that line, so the region to centre in is [nav bottom, 100vh].

   align-items:center centres within the CONTENT box, so the two paddings
   are what define that region: the top one is the nav's own clearance,
   and the bottom is zero. It used to be 24px, which pushed everything up
   by half of that — visible as the cascade sitting a little high in its
   own band. */
.hero {
  /* Exactly the nav's lower edge: the offer strip's height plus the nav's
     own, which js/partials.js measures and publishes as --nav-h. Together
     with padding-bottom:0 and align-items:center, that makes the content
     box precisely the region between the nav and the fold — which is the
     region the grid's bottom line closes, so the cascade and the copy
     centre in the band they actually sit in.

     The fallback keeps the old clamp for the moment before the
     measurement lands, and for anything running without JS. */
  padding-top: calc(var(--promo-h) + var(--nav-h, clamp(56px, 10svh - 16px, 92px)));
  padding-bottom: 0;
}

/* Both title blocks — the products band and the order process — derive
   their type from --op-heading-unit, but only the order-process one had
   its top clearance tuned. Matching them means the two sections start
   with the same rhythm instead of one sitting noticeably tighter. */
.research-banner--band {
  padding-bottom: clamp(8px, 1.2vw, 18px);
}

/* The featured grid sat hard under its own title. A little air, matched
   to the gap the order-process tabs get below their heading. */
.grid-bleed .section:has(.featured-products) {
  padding-top: clamp(16px, 2.4vw, 34px);
}

/* Third and last contributor to the Browse-Products-to-order-process gap:
   the generic .section bottom padding, halved alongside .grid-bleed's and
   .op-bleed's. All three had to move together — halving only the two
   bleeds would have left this one carrying most of the original gap. */
.grid-bleed .section:has(.process-actions) {
  padding-bottom: clamp(32px, 4vw, 50px);
}

/* The order-process band's own top clearance. .op-sections carried all
   of it, which pushed the tab row away from the heading above it while
   leaving the heading itself crowded against the section before. */
.op-bleed {
  /* The real gap above and below the content, quartered, then halved
     again on top of that (asked for outright) — 0.149 -> 0.0745.
     It measured 467px a side (leftover space from centring the content in
     a 2-window-tall box); a quarter of that is ~117px. Expressed against
     the same visible-height figure the band used to be sized off, so it
     still tracks the viewport instead of being a frozen pixel value. */
  /* lvh, not vh: this section is below the fold, so by the time anyone
     sees it the browser chrome has retracted and the large viewport is
     what is actually on screen. On iOS the two resolve to the same
     number; stating lvh is what stops that being a coincidence. */
  --op-gap: calc((100lvh - var(--promo-h) - var(--nav-h, clamp(56px, 10lvh - 16px, 92px))) * 0.0745);
  padding-top: var(--op-gap);
  padding-bottom: var(--op-gap);
}

/* Product cards on the home grid and the catalog grid are the same
   component but were landing at different visual weights, because only
   the catalog's sat on a page with a toolbar above it. A single quiet
   lift on both makes them read as the same object in both places. */
.product-card {
  box-shadow: 0 1px 2px rgba(10, 10, 12, 0.03);
  transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.product-card:hover {
  border-color: var(--border-accent);
  box-shadow: 0 6px 20px rgba(10, 10, 12, 0.07);
}

/* ── Order-process title block, aligned to the tabs ───────────
   Left aligned, and specifically to the x where the first tab's "Step 1"
   sits — not merely to the left edge of the section.

   That x is: the measure .op is capped to, plus .op's own --grid-edge
   padding, plus the tab's --op-inset. Mirroring all three here is what
   makes the heading, the disclaimer, the tab label and the panel's first
   line of copy share one left edge. The 1585px cap below has to mirror
   .op's matching breakpoint for the same reason.

   Scoped to --op so the featured-products band above (which shares
   --capped) stays centred. */
.research-banner--op .research-banner-inner { text-align: left; }


/* ============================================================
   CART DRAWER
   ------------------------------------------------------------
   The cart as a right-hand panel, on every page except cart.html and
   checkout.html (whose whole job it already is). See js/cart-drawer.js
   for why this replaced a navigation.

   Three rows: a fixed header, a scrolling list of lines, and a summary
   pinned to the bottom. The summary must never scroll out of reach —
   the total and the checkout button are the two things somebody opens
   this to find.
   ============================================================ */
.cart-drawer-root {
  position: fixed;
  inset: 0;
  z-index: 120;   /* over the nav (100) and the float bar (90) */
}
.cart-drawer-root[hidden] { display: none; }

.cart-drawer-scrim {
  position: absolute;
  inset: 0;
  /* No backdrop-filter. A blurring overlay makes the browser snapshot and
     re-composite everything behind it on every paint, and any element
     underneath that has its own compositing layer — the nav has its own
     backdrop-filter, cards have box-shadows and hover transitions — can be
     sampled into that snapshot late or not at all. What that looked like
     was pale rectangles the exact size of those elements appearing over
     the dimmed page, coming and going as the pointer moved.

     A flat rgba dim composites normally and cannot produce them. The alpha
     below is raised to carry the separation the blur used to. */
  background: rgba(10, 10, 12, 0.5);
  opacity: 0;
  transition: opacity 0.26s ease;
}
.cart-drawer-root.is-open .cart-drawer-scrim { opacity: 1; }

.cart-drawer {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: min(420px, 100vw);
  display: grid;
  /* Header and summary size to their content; the line list takes the
     rest and is the only part that scrolls. */
  grid-template-rows: auto minmax(0, 1fr) auto;
  background: var(--bg-primary);
  box-shadow: -18px 0 48px rgba(10, 10, 12, 0.22);
  transform: translateX(100%);
  transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.cart-drawer-root.is-open .cart-drawer { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .cart-drawer, .cart-drawer-scrim { transition: none; }
}

/* ── Header ── */
.cart-drawer-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 20px 22px 16px;
  border-bottom: 1px solid var(--border);
}
.cart-drawer-title {
  margin: 0;
  font-size: 18px;
  font-weight: 800;
  letter-spacing: -0.02em;
  color: var(--text-primary);
}
.cart-drawer-count { color: var(--text-muted); font-weight: 600; }
.cart-drawer-close {
  flex: 0 0 auto;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: none;
  border: 1px solid transparent;
  border-radius: 50%;
  color: var(--text-muted);
  cursor: pointer;
  transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.cart-drawer-close svg { width: 15px; height: 15px; display: block; }
.cart-drawer-close:hover {
  color: var(--text-primary);
  background: var(--bg-secondary);
  border-color: var(--border);
}
.cart-drawer-close:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }

/* ── Lines ── */
.cart-drawer-lines {
  overflow-y: auto;
  overscroll-behavior: contain;   /* don't hand the scroll to the page behind */
  padding: 6px 22px;
}

/* ── Drawer line ──
   The item itself is rendered with the checkout summary's own classes
   (.summary-item — see lineHtml in js/cart-drawer.js), so everything
   about how a cart line LOOKS lives in one place and the drawer and the
   checkout summary cannot drift apart. What remains here is only what
   the drawer adds: the row separator, and the edit controls the summary
   has no equivalent of. */
.cart-dl {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 18px 0;
  border-bottom: 1px solid var(--border-soft);
}
.cart-dl:last-child { border-bottom: none; }
/* A line the server will refuse to price. Still shown, still removable —
   greying it out says "this is why checkout is blocked" where dropping it
   silently would just make the total change on its own. */
.cart-dl.is-unavailable { opacity: 0.55; }

/* .summary-item-name is a <span> at checkout and an <a> here, so the
   underline has to be turned off and offered back on hover. */
.cart-dl .summary-item-name { text-decoration: none; }
.cart-dl .summary-item-name:hover { text-decoration: underline; }
.cart-dl .summary-item-img { text-decoration: none; }

.cart-dl-flags { display: flex; flex-wrap: wrap; gap: 6px; }
.cart-dl-flag {
  display: inline-block;
  padding: 3px 8px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-secondary);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.03em;
  color: var(--text-secondary);
}
.cart-dl-flag--out { color: #b42318; border-color: #f1c9c4; background: #fdf3f2; }

/* The edit controls. Indented to the width of the product shot plus its
   gap (60 + 16), so the stepper starts on the same line as the name
   above it rather than under the bottle. */
.cart-dl-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-left: 76px;
}
.cart-dl-qty { flex: 0 0 auto; }
.cart-dl-qty .qty-minus,
.cart-dl-qty .qty-plus { width: 30px; height: 30px; }
.cart-dl-qty .qty-middle,
.cart-dl-qty .qty-input { height: 30px; }
.cart-dl-qty .qty-input { width: 24px; font-size: 13px; }
.cart-dl-qty .qty-minus[disabled],
.cart-dl-qty .qty-plus[disabled] { opacity: 0.32; cursor: default; }
.cart-dl-qty .qty-minus[disabled]:hover::before,
.cart-dl-qty .qty-plus[disabled]:hover::before { opacity: 0; }

.cart-dl-remove {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px; height: 30px;
  padding: 0;
  border: none;
  border-radius: var(--radius-sm, 6px);
  background: none;
  color: var(--text-muted);
  cursor: pointer;
  transition: color var(--transition), background var(--transition);
}
.cart-dl-remove svg { width: 16px; height: 16px; display: block; }
.cart-dl-remove:hover { color: #b42318; background: var(--bg-secondary); }
.cart-dl-remove:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 1px; }

@media (max-width: 640px) {
  /* No room to indent past the shot on a narrow phone — the controls
     take the full width rather than being squeezed into what is left. */
  .cart-dl-controls { padding-left: 0; }
}

/* ── Empty ── */
.cart-drawer-empty {
  padding: 56px 8px;
  text-align: center;
}
.cart-drawer-empty-title {
  margin: 0 0 6px;
  font-size: 16px;
  font-weight: 800;
  color: var(--text-primary);
}
.cart-drawer-empty-body {
  margin: 0 0 20px;
  font-size: 14px;
  color: var(--text-secondary);
}

/* ── Summary (pinned) ── */
.cart-drawer-summary {
  padding: 18px 22px calc(18px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--border);
  background: var(--bg-primary);
}
.cart-drawer-summary[hidden] { display: none; }
.cart-drawer-rows { display: flex; flex-direction: column; gap: 7px; }
.cart-drawer-row {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  font-size: 13px;
  color: var(--text-secondary);
}
.cart-drawer-row--save { color: #15803d; font-weight: 600; }
.cart-drawer-total {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
  font-size: 17px;
  font-weight: 800;
  color: var(--text-primary);
}
.cart-drawer-warn {
  margin: 12px 0 0;
  font-size: 12px;
  font-weight: 600;
  color: #b42318;
}
.cart-drawer-checkout { margin-top: 14px; }
/* An <a> can't be :disabled, so the blocked state is drawn and the click
   is refused by pointer-events. tabindex="-1" + aria-disabled in the
   markup keep the keyboard and screen readers in step with that. */
.cart-drawer-checkout.is-disabled {
  opacity: 0.45;
  pointer-events: none;
}
@media (max-width: 640px) {
  .cart-drawer { width: 100vw; }
  .cart-drawer-lines { padding-inline: 16px; }
  .cart-drawer-head, .cart-drawer-summary { padding-inline: 16px; }
}

/* ============================================================
   PRODUCT REVIEWS
   ------------------------------------------------------------
   Read by anyone, written by signed-in customers. See js/product.js for
   the flow and supabase/015 for where "verified purchase" is decided
   (a Postgres trigger, not the client).
   ============================================================ */
/* The gap above this is set with the rest of the run below the product
   — see the shared clamp on .pd-tests. */

/* ── Summary: average, stars, histogram ──
   No box any more — asked for outright: a bordered, backgrounded card
   here read as a generic e-commerce widget bolted onto a page that
   otherwise never frames a block that way (compare .pd-tests-intro,
   .pd-block-title and friends, which all just sit in the page's own
   flow). This sits in that same flow instead — a thin vertical rule
   between the two halves is the only division, not a box around
   both. */
.reviews-summary {
  display: grid;
  grid-template-columns: auto 1px minmax(0, 1fr);
  gap: clamp(28px, 4vw, 56px);
  align-items: center;
  padding-bottom: 32px;
  margin-bottom: 32px;
  border-bottom: 1px solid var(--border-soft);
}
.reviews-divider {
  width: 1px;
  align-self: stretch;
  background: var(--border-soft);
}
.reviews-score { text-align: center; }
/* Mono and tabular, like every other precise figure on this page (a
   SKU, a quantity, an order number) — a rating is a measurement here,
   not a marketing number, and reads as one. */
.reviews-average {
  display: block;
  font-family: var(--font-mono, ui-monospace, Menlo, monospace);
  font-size: 52px;
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.reviews-score .stars-fractional { font-size: 21px; letter-spacing: 3px; margin: 12px 0 10px; }
.reviews-count {
  display: block;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.stars { display: inline-flex; gap: 2px; margin: 8px 0 0; }
.star { font-size: 15px; line-height: 1; color: var(--border); }
/* Amber, the one colour on the page that isn't the monochrome palette —
   a filled star has to read as filled at a glance, and weight alone
   doesn't carry that at 15px. */
.star.is-on { color: #f59e0b; }

.reviews-bars { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.reviews-bar-row { display: flex; align-items: center; gap: 12px; }
.reviews-bar-label {
  display: flex; align-items: center; gap: 2px;
  width: 20px;
  font-size: 12px; font-weight: 700; color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.reviews-bar-label span { font-size: 10px; color: var(--text-muted); }
.reviews-bar {
  flex: 1;
  height: 4px;
  border-radius: 999px;
  background: var(--bg-secondary);
  overflow: hidden;
}
.reviews-bar-fill {
  display: block; height: 100%; border-radius: 999px;
  background: #f59e0b;
  /* A beat of motion on first paint only (width is set once, from the
     server's own figures, and never changes underneath it) — the fill
     draws in rather than simply appearing, the one place in this
     summary given a little movement instead of sitting static. */
  animation: reviews-bar-grow 0.7s cubic-bezier(0.2, 0.7, 0.3, 1) backwards;
}
@keyframes reviews-bar-grow { from { transform: scaleX(0); transform-origin: left; } }
@media (prefers-reduced-motion: reduce) {
  .reviews-bar-fill { animation: none; }
}
.reviews-bar-count {
  width: 24px; text-align: right;
  font-size: 12px; color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

/* ── A review ── */
.reviews-list { display: flex; flex-direction: column; }
.review { padding: 22px 0; border-bottom: 1px solid var(--border-soft); }
.review:first-child { padding-top: 0; }
.review-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 10px;
}
.review-head .stars { margin: 0; }
.review-author { font-size: 14px; font-weight: 700; color: var(--text-primary); }
.review-verified {
  padding: 3px 9px;
  border-radius: 999px;
  background: #eaf5ee;
  color: #15803d;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.review-date { margin-left: auto; font-size: 12px; color: var(--text-muted); }
.review-body {
  margin: 0;
  font-size: 15px;
  line-height: 1.65;
  color: var(--text-secondary);
  /* Reviews are user text: a 2000-character run with no spaces must wrap
     rather than push the column sideways. */
  overflow-wrap: anywhere;
}


/* ── Can't-write-yet prompt ──
   Two states share this panel: signed out (with a sign-in button) and
   signed in without a qualifying order (.review-locked, no button —
   there is no control that resolves it here, only buying the thing). */
.review-signin {
  padding: 28px;
  border: 1px dashed var(--border);
  border-radius: var(--radius-lg);
  background: var(--bg-secondary);
  text-align: center;
}
.review-signin-title {
  margin: 0 0 6px;
  font-size: 16px;
  font-weight: 800;
  color: var(--text-primary);
}
.review-signin-body {
  margin: 0 auto 18px;
  max-width: 420px;
  font-size: 14px;
  line-height: 1.6;
  color: var(--text-secondary);
}
.review-signin-btn { max-width: 240px; margin: 0 auto; }
/* No button follows the copy in this variant, so the body's own bottom
   margin — sized to sit above one — becomes a hanging gap. */
.review-locked .review-signin-body { margin-bottom: 0; }

/* ── Write form ── */
.review-form {
  margin-top: 28px;
  padding: 24px;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
}
.review-form-title {
  font-size: 16px;
  font-weight: 800;
  color: var(--text-primary);
  margin-bottom: 10px;
}

/* ── What a review earns ──
   Sits between the form's title and its stars, where it is read on the
   way into the form rather than discovered after it. Set on the same
   green the free-shipping and credit figures use elsewhere, so the one
   thing on this form that is worth money looks like the other things on
   the site that are.

   Deliberately not a badge or a callout box: the form is already three
   controls and a heading inside a bordered card, and a fourth boxed
   element would make the offer look like an advertisement sitting in
   the form rather than a fact about it. */
.review-reward {
  margin: 0 0 16px;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--text-secondary);
}
.review-reward strong { color: #15803d; font-weight: 700; }
.review-stars-input { display: flex; gap: 4px; margin-bottom: 18px; }
.review-star-btn {
  padding: 2px;
  background: none;
  border: none;
  cursor: pointer;
  font-size: 26px;
  line-height: 1;
  color: var(--border);
  transition: color 0.15s ease, transform 0.15s ease;
}
.review-star-btn.is-on { color: #f59e0b; }
.review-star-btn:hover { transform: scale(1.12); }
.review-star-btn:focus-visible { outline: 2px solid var(--text-primary); outline-offset: 2px; }
.field-hint {
  display: block;
  margin-top: 6px;
  font-size: 12px;
  color: var(--text-muted);
}

@media (max-width: 640px) {
  .reviews-summary { grid-template-columns: minmax(0, 1fr); gap: 20px; }
  /* The vertical rule has nothing to divide once the two halves stack —
     a horizontal one in its place, ahead of the histogram, reads the
     same way stacked as it did side by side. */
  .reviews-divider { width: 100%; height: 1px; }
  .review-date { margin-left: 0; width: 100%; }
}

/* ============================================================
   CHECKOUT — saved-address picker and promo row
   ============================================================ */
.addr-picker {
  display: grid;
  gap: 10px;
  margin-bottom: 22px;
}
.addr-option {
  display: flex;
  align-items: flex-start;
  gap: 11px;
  padding: 13px 15px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: border-color 0.2s ease, background 0.2s ease;
}
.addr-option:hover { border-color: var(--text-muted); }
.addr-option.is-chosen { border-color: var(--text-primary); background: var(--bg-secondary); }
.addr-option input { margin-top: 3px; flex: 0 0 auto; }
.addr-option-body { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.addr-option-label {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-muted);
  width: 100%;
}
.addr-option-line { font-size: 14px; color: var(--text-primary); }
.addr-option-default {
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-secondary);
}
/* ── "Use a different address" is a button, not a third row ──
   The saved addresses ABOVE it are answers to "which of these";
   this option is a different kind of choice — "none of the above,
   I'll type one" — so it reads as a control you press rather than
   another card in the same stack: compact, self-sized, no address
   line to line up with the ones it sits under. Selected gets the same
   filled treatment the pay-method and payment-method radios use
   elsewhere on this page, so "this is the active choice" looks the
   same everywhere it appears. */
.addr-option--new {
  width: fit-content;
  padding: 9px 16px 9px 14px;
  gap: 8px;
  border-radius: 999px;
}
/* The radio is still what the browser and a screen reader see, but a
   button doesn't show a dot next to its own label — visually hidden,
   not removed, the way the rest of the site hides a real control behind
   a styled one. */
.addr-option--new input {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.addr-option--new .addr-option-body { flex-wrap: nowrap; }
.addr-option--new .addr-option-line {
  font-size: 13px; font-weight: 600; color: var(--text-secondary);
}
.addr-option--new.is-chosen {
  border-color: var(--text-primary);
  background: var(--text-primary);
}
.addr-option--new.is-chosen .addr-option-line { color: var(--bg-primary); }

/* The delivery row is gone from the form entirely — express is the only
   service and the Shipping line in the summary is where its price
   lives. .delivery-row, .delivery-fixed and .delivery-note went with it;
   see the note in js/checkout.js where the row used to be built. */

.promo-row { margin: 16px 0 4px; }

/* Field and button on one line, the button sized to its own text so the
   field takes whatever is left. align-items:stretch, not center, so the
   button matches the input's height exactly at any font size rather than
   being nudged to look like it does. */
.promo-entry { display: flex; align-items: stretch; gap: 8px; }
.promo-entry[hidden] { display: none; }
.promo-entry .co-input { flex: 1 1 auto; min-width: 0; }
.promo-redeem {
  flex: 0 0 auto;
  padding-inline: 18px;
  font-size: 14px;
  white-space: nowrap;
}
.promo-redeem[hidden] { display: none; }

/* The redeemed code, once it is one. A quiet green chip rather than a
   banner: it confirms without competing with the order summary beside
   it, which is where the money is actually shown. */
.promo-applied {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px;
  margin-top: 10px;
  padding: 10px 12px;
  border: 1px solid rgba(34, 197, 94, 0.35);
  border-radius: var(--radius-md);
  background: rgba(34, 197, 94, 0.09);
}
.promo-applied[hidden] { display: none; }
.promo-applied-code {
  font-size: 13px; font-weight: 700; color: #15803d;
  letter-spacing: 0.01em;
}
.promo-remove {
  border: none; background: none; padding: 0;
  font-family: inherit; font-size: 12.5px; font-weight: 600;
  color: var(--text-muted); cursor: pointer;
  text-decoration: underline; text-underline-offset: 2px;
}
.promo-remove:hover { color: var(--text-primary); }

/* Green by default — the hint only speaks when something worked. The
   rejected state below repaints it, and is the only red on this step. */
.promo-row-hint {
  margin: 6px 0 0;
  font-size: 12px;
  color: #15803d;
  font-weight: 600;
}
.promo-row-hint[hidden] { display: none; }

@media (max-width: 640px) {
  /* The button drops under the field rather than squeezing it to a few
     characters — a promo code is long enough to need the width. */
  .promo-entry { flex-direction: column; align-items: stretch; }
}

/* ============================================================
   ADMIN — order lifecycle + CoA tab
   ============================================================ */
/* ── .order-lifecycle, not .order-actions ──
   The panel's dispatch/cancel/refund controls used to share the account
   page's own .order-actions class, which meant this divider leaked onto
   the customer's Invoice/Order again row — a border no later
   account-page rule ever reset, since that one only sets
   display/gap/flex-wrap. It was scoped to `.ledger-entry .order-actions`
   to contain the leak.

   Now that the panel's cards ARE the account page's cards (see
   adminCardHtml in panel/admin.js), that scoping no longer distinguishes
   anything — both live inside .order-card. So the admin block carries
   its own name instead, and .order-actions goes back to meaning exactly
   one thing on both screens: the small buttons on the total row.

   It sits under .order-foot, so the rule above it is what separates the
   money from the controls that change it. */
.order-lifecycle {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border-soft);
}
/* Same amber box as .order-await below — one entry field and one
   button, no carrier field (every order ships AusPost; there is nothing
   to pick). */
.order-track {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 8px;
  align-items: center;
  padding: 10px 12px;
  border: 1px solid #f0d488;
  border-radius: var(--radius-md);
  background: #fefbf0;
}
.order-track .field-input { height: 36px; font-size: 13px; }
.order-action-note {
  margin: 8px 0 10px;
  font-size: 12px;
  color: var(--text-muted);
  line-height: 1.5;
}
/* The confirmation replaces the buttons in place, so the thing being
   confirmed stays on screen above it. */
.order-actions--confirm {
  padding: 14px;
  border: 1px solid #f1c9c4;
  border-radius: var(--radius-md);
  background: #fdf3f2;
}
.order-confirm-title { margin: 0 0 4px; font-size: 14px; font-weight: 800; color: #b42318; }
.order-confirm-body { margin: 0 0 12px; font-size: 13px; line-height: 1.5; color: var(--text-secondary); }
.order-actions--confirm .field-input { height: 36px; font-size: 13px; margin-bottom: 10px; }

/* ── Awaiting a bank transfer / stock ──
   The amber "needs a look" box. Left side is whatever this instance of
   it needs read out — the reference and amount to match an arriving
   transfer against, or the description an operator wrote for a stock
   order — right side is the one or two actions that resolve it, sized
   and aligned like every other button here (the tracking box's own
   "Mark Shipped", the customer order's "Payment Received").

   The reference/value pair is set in the monospace the rest of the site
   reserves for a string somebody has to read character by character —
   .order-await-info's own text stays proportional around it so the two
   read as label vs. value, not as one run-on sentence. */
.order-await {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
  padding: 10px 12px;
  border: 1px solid #f0d488;
  border-radius: var(--radius-md);
  background: #fefbf0;
}
.order-await-info { font-size: 13px; color: #7a5a10; }
.order-await-info strong {
  font-family: var(--font-mono, ui-monospace, Menlo, monospace);
  font-size: 14px; letter-spacing: 0.04em; color: #5c430c;
}
.order-await .btn-cart,
.order-track .btn-cart {
  width: auto; height: 36px; min-height: 36px; padding-inline: 16px; font-size: 13px;
}

/* ============================================================
   ADMIN — the Flow cards
   ------------------------------------------------------------
   Stock orders, stock arrivals and customer orders all render as
   .order-card, the account page's own Orders-tab card (js/account.js
   orderCard). Everything that shapes them — the border, the header row,
   the section titles, the dotted leaders, the contents grid, the foot —
   is that card's, defined once further up this file.

   What is here is only the delta: the amber "needs attention" state,
   the room the header needs for a second badge, and the two columns a
   stock movement has that an order does not.
   ============================================================ */
/* Amber, exactly the treatment .ledger-entry.is-pending-order carried —
   a pending stock order and an order awaiting a transfer are the same
   kind of "waiting on something outside this screen". */
.order-card.is-pending-order { border-color: #f0d488; background: #fefbf0; }
/* .ledger-history is already a 10px-gapped column, so the card's own
   account-page margin would stack a second gap on top of it. */
.ledger-history .order-card { margin-bottom: 0; }

/* ── Which kind, at a glance ──
   A thin solid bar down the left edge — not a fade, not a background
   wash — naming a second fact the status pill doesn't: on a stock card
   (Inflow), which KIND of movement this is (green for a stock order
   still on its way in, orange for an arrival already landed); on a
   customer order (Outflow), where it stands (green once it's closed
   out — shipped, cancelled or refunded, three different roads to the
   same "nothing further to do"; orange while it still owes something —
   a dispatch, or a payment to approve or decline). One edge for both
   tabs now that they sit as peers, so the same bar reads as the same
   kind of signal on either screen. Readable scrolling down a long
   column of cards without reading either
   one's own text.

   overflow:hidden is what lets the bar's own end follow the card's
   rounded corner exactly, rather than needing a second radius value
   hand-tuned to match --radius-lg and kept in sync with it by hand.
   Scoped to .admin-order-card, never bare .order-card, so none of this
   reaches the customer's identical-looking card on the account page. */
.admin-order-card { position: relative; overflow: hidden; }
.admin-order-card.accent-left::before,
.admin-order-card.accent-right::before {
  content: '';
  position: absolute;
  top: 0; bottom: 0;
  width: 4px;
}
.admin-order-card.accent-left::before { left: 0; }
.admin-order-card.accent-right::before { right: 0; }
.admin-order-card.accent-green::before { background: #15803d; }
.admin-order-card.accent-orange::before { background: #a06a12; }

/* The header can carry a qualifier badge beside the status pill
   ("Awaiting stock" on an order that is also paid). The account page's
   side only ever holds one child, so it never needed to say this. */
.admin-order-card .order-summary-side {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  justify-content: flex-end;
}

/* ── The whole-card collapse (admin only) ──
   Whichever card has adminCardHtml's `listKey` (every real Inflow/
   Outflow/Reviews item, not a preview) gets [data-toggle-section] on
   its own .order-summary, making the entire header row — not just the
   circle — the tap target. See wireCollapsibleSections in
   panel/admin.js, which the circle relies on for its click to bubble to
   rather than carrying a listener of its own. */
.admin-order-card .order-summary[data-toggle-section] {
  cursor: pointer;
}
/* Same border as .order-card-toggle right beside it, so the status
   label and the expand/condense circle read as one matched pair rather
   than one bordered control next to one plain wash of colour. */
.admin-order-card .order-status {
  border: 1px solid var(--border);
}
/* Same 24px .order-status was given above, so the two sit at identical
   diameter regardless of either one's own content. */
.order-card-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px;
  flex-shrink: 0;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: var(--bg-primary);
  color: var(--text-secondary);
  font-size: 15px; font-weight: 700; line-height: 1;
  transition: border-color var(--transition), color var(--transition);
}
.admin-order-card .order-summary[data-toggle-section]:hover .order-card-toggle {
  border-color: var(--border-accent); color: var(--text-primary);
}

/* The foot's buttons are the panel's own, and there are up to three of
   them — sized down from the account page's two so a card's total and
   its controls still share one line at panel widths. */
.admin-order-card .order-foot .btn-ghost {
  padding: 8px 14px; font-size: 13px;
}
.btn-ghost--danger { color: #b42318; }
.btn-ghost--danger:hover { border-color: #e5b4ae; background: #fdf3f2; }

/* ── Every card's own two headings, made tappable ──
   Otherwise .pd-block-title exactly — this only resets the <button>
   chrome sitting under that type (border, background, the browser's own
   button font) and sits the arrow right beside the label rather than
   letting it float off somewhere else.

   font-family:inherit alone, deliberately NOT the font shorthand:
   `font` resets font-SIZE and font-WEIGHT along with the family, and
   those two are exactly what .pd-block-title sets (12px/700) — the
   shorthand was quietly overriding both with the button's own default
   size and weight the moment the two rules tied on specificity, which
   is why a collapsed heading was reading in the page's plain body type
   instead of the small bold caps every other section title uses.
   font-family is the only sub-property a <button> actually needs reset
   (the browser's own UI font, not the site's), so it's the only one
   named here.

   inline-flex rather than a full-width block: the label and its arrow
   should sit together at the left, exactly where .pd-block-title's own
   text starts, not spread across the card's whole width with the arrow
   stranded at the far edge. margin restated at the same 14px .pd-block-
   title itself carries, so the two are interchangeable in every layout
   that already expects the plain version. */
.admin-collapsible-toggle {
  display: inline-flex; align-items: center; gap: 6px;
  background: none; border: none; padding: 0; margin: 0 0 14px;
  font-family: inherit;
  cursor: pointer; text-align: left;
}
.admin-collapsible-toggle:hover { color: var(--text-secondary); }
.admin-collapsible-toggle:focus-visible {
  outline: 2px solid var(--text-primary); outline-offset: 2px; border-radius: 2px;
}
/* Right-pointing at rest, rotated down once the section is open — the
   standard disclosure idiom, so nobody has to read the label a second
   time to know what the arrow next to it means. */
.admin-collapsible-arrow {
  width: 14px; height: 14px; flex-shrink: 0;
  transition: transform var(--transition);
}
.admin-collapsible-arrow.is-open { transform: rotate(90deg); }

/* ── A collapsed section takes up a heading's worth of room, not a
   section's ──
   .order-section + .order-section's own 36px is right when the section
   ABOVE is showing its content — that gap separates two blocks of
   information. It reads as a mistake when that section is collapsed to
   a single line: two headings sitting 36px apart look like something is
   missing between them, not like the normal gap under one heading
   (.pd-block-title's own 14px, which .admin-collapsible-toggle already
   carries as its margin-bottom). Zeroed here rather than restating that
   14px, since adding a second number on top of the button's own would
   double it. More specific than the plain sibling rule by one class, so
   it wins regardless of source order. */
.order-section.is-collapsed + .order-section { margin-top: 0; }

/* ── A stock movement's contents table ──
   Product, Size and the signed Change it made — no Result column any
   more (asked for outright), so at every width this is narrower than
   the base grid it borrows from (.order-contents' own, defined for the
   customer's card and shared here for the header row, dividers, name
   column and tabular figures). Restated at both tiers rather than left
   to inherit the base track count, or the row would keep the base
   grid's trailing track sized for a column that no longer exists —
   a right-hand gap of blank space nothing is placed into. */
.order-contents--ledger .order-contents-head,
.order-contents--ledger .order-contents-row {
  grid-template-columns: 1fr 72px;
}
@media (min-width: 641px) {
  .order-contents--ledger .order-contents-head,
  .order-contents--ledger .order-contents-row {
    grid-template-columns: 1fr 72px 72px;
  }
}
.order-contents-delta {
  font-variant-numeric: tabular-nums; font-size: 14px; font-weight: 700;
}

/* ── A customer order's contents table, in the admin panel ──
   Product, Size, Qty and one money column — the per-unit Price and the
   Status/pre-order badge are gone (asked for outright), and Price now
   labels what used to be the line Total, in its place. Same reasoning
   as .order-contents--ledger above: restated at both tiers rather than
   left to inherit the customer card's own 6-track grid, which would
   otherwise leave two tracks — sized for the per-unit Price and the
   Rating column — with nothing placed into them. */
.order-contents--admin-order .order-contents-head,
.order-contents--admin-order .order-contents-row {
  grid-template-columns: 1fr 72px;
}
@media (min-width: 641px) {
  .order-contents--admin-order .order-contents-head,
  .order-contents--admin-order .order-contents-row {
    grid-template-columns: 1fr 72px 72px 72px;
  }
}

/* ── The count badge ──
   One rule for every tab — see countBadge() in panel/admin.js, which is
   the only thing that renders it. Amber, the same colour every "waiting
   on something" state in this panel already uses, so a badge reads as
   the same kind of fact as the pill on the card it is counting. Never
   drawn at zero.

   Carries NO margin of its own. .admin-tab is a flex row that supplies
   the label-to-badge gap itself via `gap` — so the space between the
   badge and the pill's right edge is exactly that container's own
   padding-right, by construction rather than by two separate numbers
   (a margin and a padding) happening to agree. A badge glued to its own
   margin would only stay flush with the edge for as long as nobody
   changed the padding on one pill and forgot the other. */
.admin-tab-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 18px; height: 18px; padding: 0 5px;
  border-radius: 999px;
  background: #a06a12; color: #fff;
  font-size: 11px; font-weight: 800; font-variant-numeric: tabular-nums;
  /* Sits with the label rather than inheriting the tab's own tracking,
     which is wide enough to push a two-digit number off centre. */
  letter-spacing: 0;
}

.review-mod-stars { color: #a06a12; letter-spacing: 2px; font-size: 14px; }
.review-mod-stars .is-off { color: var(--border); }
/* The thing being judged, so it gets prose treatment rather than a
   table cell. overflow-wrap because a review is free text and can carry
   an unbroken run of characters wider than the card. */
.review-mod-body {
  font-size: 14px; line-height: 1.7; color: var(--text-primary);
  white-space: pre-wrap; overflow-wrap: anywhere;
  padding: 12px 14px;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-md);
  background: var(--bg-secondary);
}
.review-mod-body.is-empty { color: var(--text-muted); font-style: italic; }

/* ── Phone: give the card back the width the nesting takes ──
   A Flow card sits two boxes deep now rather than four (container →
   card — the tab and sub-tab pills no longer wrap the content in a
   bordered frame of their own), but the card's own 22px padding is still
   real width spent before the contents table gets any, and at 375px that
   is enough on its own to squeeze a product name, a figure and a result
   uncomfortably. Tightened here, at the width where it stops being
   breathing room and starts being the reason the table does not fit. */
@media (max-width: 640px) {
  /* Fits more of the five tabs and their badges on one line before they
     wrap — purely a density preference now, not a structural need:
     .admin-tabs already wraps cleanly (flex-wrap:wrap, no frame to
     break), so a wrap here costs nothing but a second row. */
  .admin-tab { padding: 8px 13px; gap: 5px; font-size: 13px; }
  .admin-tab-count { min-width: 16px; height: 16px; font-size: 10px; }

  .admin-order-card { padding: 16px 14px; }
  .admin-order-card .order-summary { align-items: flex-start; }
  /* Total on one line, the controls on the next, rather than a button
     row squeezed against a figure. */
  .admin-order-card .order-foot { flex-direction: column; align-items: stretch; gap: 12px; }
  .admin-order-card .order-foot .order-actions { justify-content: flex-start; }
  /* The tracking row stacks: a 36px input sharing a 240px line with a
     carrier chip and a button leaves no room to read a number back. */
  .order-track { grid-template-columns: minmax(0, 1fr); }
  .order-track .btn-cart { width: 100%; }
}

.coa-comps { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 4px; }
.coa-comp {
  padding: 4px 10px;
  border-radius: 999px;
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  font-size: 12px;
  color: var(--text-secondary);
}
.coa-purity { color: #15803d; font-weight: 700; }
.coa-actions { display: flex; gap: 14px; margin-top: 12px; }
.coa-comp-row {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) auto;
  gap: 8px;
  align-items: center;
  margin-bottom: 8px;
}
.coa-comp-row .field-input { height: 36px; font-size: 13px; }


@media (max-width: 1024px) {
  .order-track { grid-template-columns: minmax(0, 1fr); }
  .coa-comp-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}

/* ── Back-in-stock signup (product page) ──
   Shown only for a size that is sold out but still carried. See
   refresh() in js/product.js. */
.pd-notify {
  margin-top: 18px;
  padding: 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--bg-secondary);
}
.pd-notify[hidden] { display: none; }
.pd-notify-title {
  margin: 0 0 12px;
  font-size: 14px;
  font-weight: 700;
  color: var(--text-primary);
}
.pd-notify-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: stretch; }
/* .field-input's own padding (12px block) is sized for a full-height form
   field elsewhere on the site and runs taller than .btn-cart's fixed
   36px — stacked side by side here, that left the email field visibly
   overhanging the button both above and below it. Matched to the
   button's own box exactly: same 36px height, padding trimmed to
   horizontal-only since the fixed height now does what the vertical
   padding was doing. */
.pd-notify-form .field-input {
  height: 36px;
  padding: 0 14px;
}
.pd-notify-form .btn-cart { width: auto; padding-inline: 20px; }
.pd-notify-msg { margin: 10px 0 0; }
@media (max-width: 640px) {
  .pd-notify-form { grid-template-columns: minmax(0, 1fr); }
}

/* ============================================================
   LAB RESULTS — the certificates viewer
   ------------------------------------------------------------
   Product first, then variants. The page opens on a grid of compounds
   that actually have published documents; choosing one replaces the grid
   with its certificates, where the product's own sizes become a filter
   rather than a second level of navigation.

   Certificates are photographs of paper. Everything here is built to let
   that paper be the only bright rectangle in view — the surrounding
   chrome is deliberately quiet, and the one place colour appears is the
   purity figure, which is the number people came to read.
   ============================================================ */

/* ── Lab Results: the compound grid ───────────────────────────
   A grid of product tiles, one per compound with a published
   certificate — the same component the product catalog uses
   (.product-card et al.), so this page and the catalog read as two
   views of the same products rather than two different ones.

   Identical to .products-grid: same track width, same --catalog-grid-w
   container, same breakpoints — so a cert tile lands exactly as wide,
   and the grid exactly as aligned, as the product grid above it. */
.certs-grid {
  display: grid;
  grid-template-columns: repeat(4, calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3));
  gap: var(--grid-gap);
  padding: 0 var(--grid-edge);
  width: var(--catalog-grid-w);
  margin: 0 auto;
}
/* js/certifications.js toggles the whole grid against its "nothing
   matches" sibling with `hidden` rather than rebuilding either — needs
   this override for the same reason .product-card[hidden] does. */
.certs-grid[hidden] { display: none; }
@media (max-width: 1585px) {
  .certs-grid {
    grid-template-columns: repeat(2, 1fr);
    width: 100%;
    max-width: 1120px;
  }
}
@media (max-width: 640px) {
  .certs-grid { grid-template-columns: 1fr; }
}

/* ── The cert tile's header ──
   .card-header shortened: a product card reserves room for a 2-line
   name PLUS a line of size/price beneath it; a cert tile has no size or
   price, so the box only has to reserve a 2-line name. 22px × 1.1
   line-height × 2 lines = 48.375px (measured), + the name's own 1px
   margin-bottom + the same 4px padding-bottom the product card's header
   uses — so a one-line name still bottom-anchors exactly where a
   two-line one would, the same trick .card-header does, just without
   the meta line's share of the box. */
.cert-card-header {
  height: 53.375px;
}

/* ── The symbol, missing ──
   A generic "image unavailable" mark, not the bottle mockup — the slot
   showcases the product's own mark, and a bottle standing in for a
   missing one implies a photo that isn't there. Sized to sit inside
   .card-img-wrap the same way the real symbol does (see the .card-img-wrap
   img rule it can't itself match, being an <svg>-wrapping <span> rather
   than an <img>). */
.card-img-wrap .cert-symbol-placeholder {
  width: 40%;
  height: 40%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
}

/* ============================================================
   PRODUCT PAGE — the Lab results section
   ------------------------------------------------------------
   Every certificate for this product, whatever size is selected: most
   assays are run on the compound rather than the vial, so filtering by
   the chosen size would hide the document that actually certifies it.
   ============================================================ */
.pd-tests-intro {
  max-width: 60ch;
  margin: 0 0 18px;
  font-size: 14.5px; line-height: 1.6; color: var(--text-secondary);
}
.pd-tests-list {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--grid-gap, 12px);
  /* Double the FBT tile's own square — a local name, not a redefinition of
     --fbt-square itself, since a custom property can't reference its own
     prior value on the same element without becoming a cycle. */
  --pd-test-square: calc(var(--fbt-square) * 2);
}
/* Twice the height of an FBT tile (see --pd-test-square above), now that
   two of these sit per row instead of four narrow ones. min-height, not
   height: a certificate listing several components must still be free to
   grow past it rather than clipping. */
.pd-test {
  display: flex; align-items: stretch; gap: 14px;
  /* Was the full --pd-test-square (two FBT tiles tall), which made each
     certificate row a screenful of its own and pushed the reviews below
     the fold on every product. Halved: still tall enough to read the
     document's shape, short enough that the list scans as a list. */
  min-height: calc(var(--pd-test-square) / 2);
  padding: 12px;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-md);
  background: var(--bg-primary);
  color: inherit; text-decoration: none;
  transition: border-color var(--transition), box-shadow var(--transition);
}
.pd-test:hover { border-color: var(--border-accent); box-shadow: var(--shadow-xs); }
/* Grows with the row. At the old fixed 46×65 the thumbnail was a stamp in
   the corner of a box four times its height — and this is a photograph of
   the document the section exists to show, so the space is better spent
   making it legible than left empty beside it. A4 portrait, which is what
   the certificates actually are. */
/* Both dimensions stated, derived from the row's own height rather than
   left to `aspect-ratio` — on a replaced element `width:auto` resolves to
   the image's INTRINSIC width first, and the ratio then computed a height
   from that, which made the row as tall as a wide photo demanded (a 365px
   row came out 506px). Deriving width from the known height instead means
   the picture can never size the box that is supposed to be sizing it.

   Minus the row's own chrome: 12px of padding and 1px of border, top and
   bottom. Boxes here are border-box, so the shot has to fit INSIDE the
   square rather than adding to it — at the full 24px it pushed the row
   two pixels past the tile it is supposed to match. A4 portrait, which is
   what the certificates are. */
.pd-test-shot {
  flex: 0 0 auto;
  align-self: center;
  /* Derived from the row's own (now halved) height, minus its 12px
     padding and 1px border top and bottom — so the shot fits INSIDE the
     box rather than setting it. */
  --pd-shot-h: calc(var(--pd-test-square) / 2 - 26px);
  height: var(--pd-shot-h);
  width: calc(var(--pd-shot-h) / 1.414);
  object-fit: cover; object-position: top;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: #fff;
}
.pd-test-text { min-width: 0; align-self: center; display: flex; flex-direction: column; gap: 3px; }
.pd-test-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.pd-test-size { font-size: 14px; font-weight: 800; color: var(--text-primary); }

.pd-test-comps { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; }
.pd-test-comps strong { color: #15803d; font-weight: 800; }
.pd-test-date { font-size: 11.5px; color: var(--text-muted); }
/* Phone-tier only, both of them — display:none here takes them out of
   the accessibility tree at every width above it, which is right:
   "Tap to expand" names a gesture a mouse does not have, and the NEW
   flag was asked for on the phone layout. See the ≤768px block. */
.pd-test-cta { display: none; }
.pd-test-new { display: none; }
/* (.pd-tests-all removed with the markup — the list already showed every
   certificate this product has, so the link went nowhere new.) */

/* ============================================================
   ACCOUNT — order status, tracking, details
   ============================================================ */
/* `pending` (amber) now covers every "something is owed" state — a paid
   order reading "Needs Shipping", one still waiting on stock, and one
   waiting on a bank transfer are the same kind of fact — so .paid has no
   remaining caller (see js/account.js's STATUS map) and is dropped
   rather than kept as a rule nothing ever selects. */
.order-status.dead { background: #fdf0ee; color: #b42318; }

/* The Shipping row's own value — a tappable tracking number once one
   exists (js/account.js's shippingValueHtml), styled as one more
   .order-detail-val rather than the standalone green box this
   replaces (a whole separate section under the total, removed —
   Shipping is a row inside Order details now, same as Placed or
   Delivery). No font of its own any more (that used to be monospace,
   asked to be undone) — it inherits the row's plain text exactly like
   every other value. The colour stays the row's own --text-secondary
   at rest and only underlines on interaction, matching every other
   plain text link in this card; the arrow is kept, and its own note
   ends by pointing at where it shows up a second time
   (.order-contents-review-link). */
.order-detail-track-link { color: var(--text-secondary); text-decoration: none; }
.order-detail-track-link:hover { text-decoration: underline; text-underline-offset: 2px; }

/* Order details and Order contents: plain sections, not their own
   nested disclosures — the card itself is already one. Told apart by
   a section title (.pd-block-title, same as Profile's Contact /
   Delivery addresses headings) and even spacing between them, rather
   than a click each to reveal. 36px — 1.5× the original 24px, asked
   for outright — now that Order contents carries a header row of its
   own, which read as crowding straight into Order details above it at
   the old gap. */
.order-section + .order-section { margin-top: 36px; }

/* ── Key connects to value, left to right ──
   A dotted leader (the classic table-of-contents/receipt device),
   a genuine third flex child sitting between the key and the value in
   the actual DOM order — not a ::after, which lives INSIDE whichever
   element's rule creates it rather than beside it, so a pseudo-element
   here could only ever end up inside the key's own box or the row's
   (after both real children, i.e. past the value — the wrong side,
   which is what this replaces). A real element is what lets flex-grow
   claim the leftover space between two flex-shrink:0 neighbours. */
.order-detail-row {
  display: flex; align-items: baseline;
  /* 16px — double the row's original 8px gap — asked for outright, so
     the leader sits twice as far from the key and value text either
     side of it as it used to. */
  gap: 16px; padding: 5px 0; font-size: 13px;
}
/* The section title's own margin-bottom (.pd-block-title, 14px) already
   sets the gap above the first row — this row's own top padding was
   adding a further 5px on top of it, so Order details opened 19px
   below its heading while Order contents (whose header row carries no
   padding-top of its own) opened at 14px. Zeroed here, on the first
   row only, so that one gap matches while every row-to-row gap below
   it keeps its full rhythm. */
.order-detail-row:first-child { padding-top: 0; }
.order-detail-key { flex: 0 0 auto; color: var(--text-muted); }
.order-detail-leader {
  flex: 1 1 auto;
  min-width: 12px;
  /* var(--border-soft), dashed — as visible as Order contents' own row
     dividers (same colour), just dashed rather than solid so it still
     reads as a leader connecting a label to its value rather than a
     rule dividing two rows. */
  border-bottom: 1px dashed var(--border-soft);
  /* Sits on the text baseline rather than the row's vertical centre —
     a leader drawn mid-height of a 13px line reads as floating above
     the words either side of it. */
  transform: translateY(-0.3em);
}
/* ── flex: 0 1, not 0 0 ──
   The value was allowed to grow and forbidden to shrink, which is fine
   until a row carries something long: a full delivery address, a Stripe
   `pi_…` reference, a cancellation reason written as a sentence. A
   flex-shrink of 0 means the item never gets narrower than its
   max-content width, so at phone widths those rows pushed straight
   through the card's right edge and gave the whole page a horizontal
   scrollbar. Shrinking is what lets the text wrap instead, and
   overflow-wrap covers the one case wrapping alone cannot — a single
   unbroken token (that Stripe id) longer than the column. */
.order-detail-val {
  flex: 0 1 auto; text-align: right; color: var(--text-secondary);
  min-width: 0; overflow-wrap: anywhere;
}

/* Order contents — a real table, left-aligned, no dividers. Rows are
   told apart by the gap between them rather than a hairline under each
   one, and the item name carries the emphasis (bold, larger) since it's
   the one thing on the row someone is actually scanning for.

   Every non-Item column is a FIXED width, not `auto` — .order-contents-
   head and each .order-contents-row are separate grid containers (one
   per DOM element), and an `auto` track is sized independently within
   each one: the header's own "Qty" text and a row's own "2" have no
   reason to agree on a width, which is why headers and values used to
   land in different places from each other, and rows of different
   quantities from each other. A fixed px value is the one track size
   every one of these independent grids computes identically, which is
   what actually lines a column up — `auto` never guaranteed that here,
   it only happened to look close.

   Item / Total / Rating at the phone tier (quantity and size stay
   folded into the name line, .order-contents-name-meta below); Size,
   Qty and the per-unit Price all get their own columns from 641px up
   — see the media query further down for the rest of that split and
   its own column widths. Total (renamed from what used to just be
   "Price" before the per-unit column existed) sits left of Rating —
   asked for outright — in the markup order in js/account.js, which
   this grid just has to match. */
.order-contents-head,
.order-contents-row {
  display: grid;
  grid-template-columns: 1fr 72px 144px;
  gap: 4px 18px;
  align-items: center;
  text-align: left;
}
.order-contents-head > * ,
.order-contents-row > * { text-align: left; }
.order-contents-head {
  font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--text-muted);
  padding-bottom: 8px;
  /* A line under the headers, asked for outright — the row dividers
     further down already separate one product from the next; this is
     the same device marking the header off from the data it labels.

     Matches .order-contents-row's own margin-top/padding-top exactly
     (both halved together — see that rule's own note) so every row,
     including the first, sits the same distance below the divider
     above it as above the divider below it. */
  margin-bottom: 7px;
  border-bottom: 1px solid var(--border-soft);
}
/* One product per row, told apart by a hairline now rather than
   spacing alone. 7px, half the original 14 — asked for outright, to
   thin the row down — matched by .order-contents-head's own
   margin-bottom above so the rhythm (and the first row's centring
   between its two dividers) stays consistent. */
.order-contents-row + .order-contents-row {
  margin-top: 7px;
  padding-top: 7px;
  border-top: 1px solid var(--border-soft);
}
/* Matched to Qty/Size/Price's own size and colour (14px, --text-
   secondary) — asked for outright, so the product name reads as one
   more data column like the rest of the row. Weight went back to 700
   (also asked for outright, after a pass at 400): Size carries the
   same 700 now too, so the two travel together — see .order-contents-
   size below. */
.order-contents-name {
  display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
  font-size: 14px; font-weight: 700; color: var(--text-secondary);
}
/* Size and "x" quantity are now inside .order-contents-name-line itself
   (js/account.js), so they inherit the name's own weight and colour —
   only the pre-order note is still a muted secondary line. */
.order-contents-name em {
  font-style: normal; font-weight: 500; font-size: 12.5px; color: var(--text-muted);
}
/* Item reads as "Item" on the phone tier, where it's carrying the size
   and quantity too and so is genuinely a catch-all; "Product" everywhere
   wider, once those have their own columns and this one is only ever
   the product's name. Both labels are always in the markup — see the
   qty/size columns' own note above for why swapping on a CSS breakpoint
   beats re-rendering. */
.order-contents-head-desktop { display: none; }
/* The dedicated Size/Qty/Price columns and their header cells are
   built in the markup at every width, but only ever shown at one
   width or the other — see the 641px-up rule below for where they
   turn on. */
.order-contents-head-qty, .order-contents-head-size, .order-contents-head-unit-price,
.order-contents-qty, .order-contents-size, .order-contents-unit-price {
  display: none;
}
@media (min-width: 641px) {
  /* Item keeps the flexible track. Size, Qty and the per-unit Price all
     share 72px — asked for outright, back from a couple of passes
     shrinking Qty specifically (72 -> 54 -> 40.5). Rating is wider
     still: it has to fit "Claim Your 5% Credit" (measures ~133px at
     this weight/size) on one line, not just a number or a short link
     label. */
  .order-contents-head,
  .order-contents-row {
    grid-template-columns: 1fr 72px 72px 72px 72px 144px;
  }
  .order-contents-head-mobile { display: none; }
  .order-contents-head-desktop { display: inline; }
  .order-contents-head-qty, .order-contents-head-size, .order-contents-head-unit-price,
  .order-contents-qty, .order-contents-size, .order-contents-unit-price {
    display: block;
    font-variant-numeric: tabular-nums;
  }
  .order-contents-qty, .order-contents-size, .order-contents-unit-price {
    color: var(--text-secondary);
    font-size: 14px;
  }
  /* Size carries the same 700 weight Product does — asked for
     outright — Qty and the two money columns stay their own regular
     weight. */
  .order-contents-size { font-weight: 700; }
  /* Size and Price stay left-aligned (the base rule already covers
     that). Qty splits the two — asked for outright — but not by simply
     centring the figure in the FULL 40.5px column: that centres it on
     the column's own midpoint, not on "Qty" itself, and the header
     text (left-aligned, flush with the column's start) is nowhere
     near that midpoint — "Qty" measures ~24px, so its own centre sits
     at column-start + 12px, well short of the column's centre at
     20.25px.

     Giving the value box the SAME width as the header's rendered text
     (24px, a fixed number since "Qty" is a fixed string) and leaving
     it at the grid item's default start position — an explicit width
     stops a grid item from stretching to its track — means centring
     TEXT-ALIGN inside that narrower box lands on the header's own
     centre exactly, not the column's. */
  .order-contents-head-qty { text-align: left; }
  .order-contents-qty { width: 24px; text-align: center; }
  /* Now redundant with the columns beside it — hidden rather than
     removed from the markup, so nothing has to re-render when the
     viewport crosses back down over the breakpoint. */
  .order-contents-name-meta { display: none; }
}
/* ── The phone tier's own column widths ──
   Below 641px the table is Item / Total / Rating, and those two fixed
   tracks were carrying their desktop widths: 72 + 144 + two 18px gaps is
   252px of the row spoken for before the product name gets any. Inside
   the account page's card that left the name about 30-80px depending on
   the device, and inside the admin panel's extra nesting it left less
   than the name's own shortest word — so every row wrapped to three or
   four lines and the table pushed past the card.

   Narrowed to what each column actually needs at this width: a money
   figure, and a rating that is now allowed to wrap onto a second line
   rather than reserving a full line's worth of width for a link it only
   sometimes shows. */
@media (max-width: 640px) {
  .order-contents-head,
  .order-contents-row {
    grid-template-columns: minmax(0, 1fr) 64px 92px;
    gap: 4px 10px;
  }
  .order-contents-review-link { white-space: normal; font-size: 12px; }
  .order-contents-name { font-size: 13px; }
}
.order-contents-preorder { color: #a06a12 !important; }
/* The line total (qty x unit price) — the class name predates the new
   per-unit Price column (.order-contents-unit-price) that now sits
   beside it; kept rather than renamed since every rule and the markup
   both still agree on it. */
.order-contents-price {
  font-variant-numeric: tabular-nums; font-size: 14px; color: var(--text-secondary);
}
/* Plain tappable text, not a button — asked for outright. Reviewing is
   "write a sentence", not an action that needs its own pill and border
   to look important; the same distinction .op-link draws elsewhere on
   the site between a real button and a line of text that happens to be
   a link. Green (matching .order-contents-rating-value below), since
   the text now names the reward directly rather than just the action —
   a line that's been reviewed and a line still inviting one read as
   the same kind of claim about credit. nowrap is safe again: the
   Rating column (144px) is sized to fit "Claim Your 5% Credit"
   (~133px) on one line, rather than the longer sentence this replaced
   that needed to wrap. */
.order-contents-review-link {
  font-size: 12.5px; font-weight: 700;
  color: #15803d;
  text-decoration: underline;
  text-decoration-color: transparent;
  text-underline-offset: 3px;
  white-space: nowrap;
  transition: text-decoration-color var(--transition);
}
.order-contents-review-link:hover,
.order-contents-review-link:focus-visible { text-decoration-color: currentColor; }
/* A reviewed line's rating: five stars, filled to the exact score
   (2.5 stars fills two and a half of them) rather than one star
   standing in for a number — asked for outright. Two five-star rows,
   stacked: the bottom one bare and muted, the top one gold and
   clipped to a percentage width, the classic fractional-star technique
   (see .stars-fractional-fill's width, set inline per row from the
   actual rating). */
.order-contents-rating-value {
  display: inline-flex; align-items: center; gap: 5px;
  font-variant-numeric: tabular-nums;
}
/* 1.5x the base .stars-fractional size (13px -> 19.5px) — asked for
   outright, now that the stars carry the score alone (the figure
   beside them is gone). Scoped here rather than raised on the shared
   rule, which the product page's review summary also uses at its own
   size. align-items:center on the row above (.order-contents-row)
   still centres this whole cell against Product/Size/Qty/Price's own
   text regardless of the stars' size. */
.order-contents-rating-value .stars-fractional { font-size: 19.5px; letter-spacing: 1.5px; }
.stars-fractional {
  position: relative;
  display: inline-block;
  line-height: 1;
  font-size: 13px;
  letter-spacing: 1px;
  white-space: nowrap;
  /* The ★ glyph's own ink sits above the geometric centre of its em
     box (a font-metrics quirk of the character, not of this layout) —
     measured at roughly 0.1em high across sizes, which is what left
     these stars looking mis-centred against a plain-text sibling in
     the same row even though both boxes were genuinely centred by
     align-items. Nudged down by that same fraction so the glyphs'
     actual ink lines up instead of just their boxes. */
  transform: translateY(0.1em);
}
.stars-fractional-track { color: var(--border); }
.stars-fractional-fill {
  position: absolute;
  top: 0; left: 0;
  overflow: hidden;
  white-space: nowrap;
  /* The gold itself is a gradient, not a flat colour, so the shine
     sweep below has something to animate — three gold stops either
     side of one pale-gold band read as solid gold at rest (the band is
     the same width as the gap between sweeps), and only move as the
     sweep itself. background-clip:text masks that gradient to the
     star glyphs exactly, so the sweep can never spill past their
     shape onto the space around them. */
  background-image: linear-gradient(100deg,
    #f59e0b 0%, #f59e0b 42%, #fde68a 50%, #f59e0b 58%, #f59e0b 100%);
  background-size: 300% 100%;
  background-position: 0% 0;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
/* The sweep itself: one pass, left to right, the moment these ratings
   first come into view (js/account.js's IntersectionObserver adds this
   class once, to every rating on the tab at the same time, so a whole
   page of them catches the light together rather than one at a time
   as each happens to cross into the viewport). Plays once per class
   add — it is not a loop — which is what keeps a "satisfying" effect
   from wearing that out into a distraction on a tab someone leaves
   open. */
.stars-fractional-fill.is-shining {
  animation: order-stars-shine 1.3s cubic-bezier(0.3, 0, 0.2, 1) 1;
}
@keyframes order-stars-shine {
  from { background-position: 200% 0; }
  to   { background-position: -100% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .stars-fractional-fill.is-shining { animation: none; }
}
.order-actions { display: flex; gap: 8px; flex-wrap: wrap; }

/* ── Reviews tab (account.html) — one card per review the customer
   has left, what it says and what it earned. Same box treatment as
   .order-card, since both are "one past action of yours" cards inside
   the same account panels. */
.myreview-card {
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  padding: 20px 22px;
  margin-bottom: 16px;
  background: var(--bg-primary);
  box-shadow: var(--shadow-sm);
}
.myreview-head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 12px; margin-bottom: 10px;
}
.myreview-product { font-weight: 700; color: var(--text-primary); }
.myreview-product:hover { text-decoration: underline; text-underline-offset: 2px; }
.myreview-size { margin-left: 8px; font-size: 12.5px; font-weight: 500; color: var(--text-muted); }
.myreview-stars { display: inline-flex; gap: 2px; flex-shrink: 0; }
.myreview-body {
  margin: 0 0 14px;
  font-size: 14px; line-height: 1.6; color: var(--text-secondary);
  overflow-wrap: anywhere;
}
.myreview-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
/* The state pill and what it earned travel together on the right, so
   the row reads date … state + credit rather than three separate things
   spread across it. */
.myreview-foot-side { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.myreview-date { font-size: 12px; color: var(--text-muted); }

/* ── Why a review was not published ──
   Amber rather than red: this is an explanation, not a warning about
   something the reader did wrong, and the copy inside it goes out of its
   way to say the rating was not the reason. */
.myreview-notice {
  margin: 0 0 14px;
  padding: 12px 14px;
  border: 1px solid #f0d488;
  border-radius: var(--radius-md);
  background: #fefbf0;
}
.myreview-notice-title {
  margin: 0 0 6px;
  font-size: 13px; font-weight: 800; color: #7a5a10;
}
.myreview-notice-body {
  margin: 0 0 6px;
  font-size: 12.5px; line-height: 1.6; color: #7a5a10;
}
.myreview-notice-body:last-child { margin-bottom: 0; }

/* Sits under the reward line on both review forms. Muted and small — it
   is a condition of posting, not a selling point, and it should not
   compete with the credit line above it. */
.review-moderation-note {
  margin: -4px 0 14px;
  font-size: 12px; line-height: 1.6; color: var(--text-muted);
}
.myreview-credit { font-size: 12.5px; font-weight: 700; color: #15803d; }
.myreview-credit.is-none { color: var(--text-muted); font-weight: 500; }

/* Purchases still open to review — the account page's own entry point
   into the exact write flow the product page offers (.review-form and
   everything inside it, unchanged). Each row names the one thing a
   product page leaves implicit: which product this is about. */
.pending-reviews { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; }
.pending-review {
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  padding: 16px 18px;
  background: var(--bg-primary);
}
.pending-review-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.pending-review-name { font-weight: 700; color: var(--text-primary); }
.pending-review-name em { font-style: normal; font-weight: 500; color: var(--text-muted); margin-left: 6px; }
.pending-review-meta { font-size: 12.5px; color: var(--text-muted); white-space: nowrap; }
.pending-review-toggle { margin-top: 12px; max-width: 160px; }
/* .btn-ghost sets its own display:inline-flex, which — being an author
   rule — outranks the browser's built-in [hidden]{display:none}, a
   user-agent-level rule that always loses ties regardless of source
   order. Without this the button stayed visibly on screen (just inert)
   after being hidden. */
.pending-review-toggle[hidden] { display: none; }
/* .review-form already carries its own border/padding (product page) —
   this only tightens the gap above it now that it sits inside another
   bordered card rather than directly under the page's review summary. */
.pending-review-form .review-form { margin-top: 16px; }

/* ============================================================
   POLICY PAGES — terms, privacy, shipping, returns, contact
   ------------------------------------------------------------
   Long-form legal copy. The one job here is readability: a measure short
   enough to track across, headings that separate clauses without shouting,
   and no decoration competing with text somebody has to actually read.
   ============================================================ */
.policy { max-width: 72ch; }
.policy-updated {
  margin: 0 0 26px;
  font-size: 12px; font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-muted);
}
.policy h2 {
  margin: 38px 0 12px;
  font-size: 19px; font-weight: 800; letter-spacing: -0.015em;
  color: var(--text-primary);
}
.policy h2:first-of-type { margin-top: 30px; }

.policy p { margin: 0 0 14px; font-size: 15.5px; line-height: 1.7; color: var(--text-secondary); }
.policy ul { margin: 0 0 16px; padding-left: 22px; }
.policy li { margin-bottom: 8px; font-size: 15.5px; line-height: 1.65; color: var(--text-secondary); }
.policy li::marker { color: var(--text-muted); }
.policy strong { color: var(--text-primary); }
.policy a { color: var(--text-primary); text-underline-offset: 2px; }

/* Who the customer is dealing with. Boxed because it is reference rather
   than prose — the one block on these pages people scan for rather than
   read through. */
.policy-identity {
  margin: 26px 0 8px;
  padding: 20px 22px;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-lg);
  background: var(--bg-secondary);
}
.policy-identity h2 { margin: 0 0 14px; font-size: 15px; }
.policy-id-list {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 7px 20px;
  margin: 0;
  font-size: 14px;
}
.policy-id-list dt { color: var(--text-muted); }
.policy-id-list dd { margin: 0; color: var(--text-primary); font-weight: 600; }

/* ── The identity block as a business card (Contact page) ──
   The plain .policy-identity above is a grey reference box at the foot
   of a policy page. On Contact it is the first thing under the intro and
   it is answering "who am I writing to", which is a card, not a
   footnote.

   The treatment is the footer's, not a new one: the mark on black, the
   same full invert (the asset is solid black on transparent), over the
   same magenta/amber/violet/cyan aurora the offer card paints. Two
   places on the site put the wordmark on a dark ground and they should
   look like the same object. */
.policy-identity--card {
  position: relative;
  overflow: hidden;
  border: none;
  padding: 26px 28px;
  /* An actual payment card's proportions — 85.60 × 53.98 mm is 1.586:1 —
     so the box reads as the object it is imitating rather than as a
     banner that happens to carry a logo.

     Capped narrower than the 629px measure the prose runs to: aspect
     ratio means width sets height, and at full measure this would stand
     ~400px tall, which is a poster. 440px lands it near a real card's
     apparent size on screen.

     Column flex with space-between is what puts the mark on the top line
     and the details in the bottom-left corner, with the empty middle
     doing the work the old rule was doing badly. */
  width: min(100%, 440px);
  aspect-ratio: 1.586;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  /* The near-black the artwork's own darkest region settles to. It shows
     for the moment before the image lands, and along the rounded corners
     where the image is clipped — a pale value there would read as a
     white fringe picking out each corner. */
  background-color: #050805;
  /* contact_card_background.webp — the site's own artwork rather than a
     second set of gradients invented here. Layer order matters: CSS
     paints the FIRST-listed image on top, so the darkening pass leads
     and the artwork sits under it. That pass is what guarantees the
     white type below keeps its contrast wherever the aurora's bright
     green band happens to fall across the card.

     Measured, not eyeballed: the artwork's bright green band runs under
     the top-right of the card, where "AUSTRALIA" sits. Rasterising the
     image at this exact crop and sampling under each run of type puts
     the weakest pairing (that tag, white at 62%) at 4.68:1 with a
     0.34→0.52 overlay — over WCAG AA's 4.5 but with almost no margin.
     0.42→0.58 takes it to 5.14:1 and the section heading to 5.10:1,
     which holds up whatever the crop does, and the aurora is still
     plainly the artwork rather than a dark wash. */
  background-image:
    linear-gradient(rgba(4, 8, 4, 0.42), rgba(4, 8, 4, 0.58)),
    url('contact_card_background.webp');
  /* cover + centre: the image is a 16:9 landscape and this card is a
     short wide strip, so cover crops top and bottom and keeps the
     aurora's sweep running across the full width rather than
     letterboxing it. */
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  box-shadow: 0 18px 44px rgba(5, 8, 5, 0.32);
}

/* The 440px cap above is a DESKTOP figure — it keeps the card from
   standing as tall as a poster at the page's full 629px measure. Below
   768px the surrounding column is already narrower than that cap, so it
   should do nothing there — except the column crosses back OVER 440px
   between roughly 488px and 768px (a landscape phone, a small tablet),
   where the cap started binding again and left the card visibly short
   of the container's right edge while its left edge sat flush, asked
   about outright. Dropping to a flat 100% here removes the cap for the
   whole tier, so the card fills the column at every width in it and
   both edges match, the same as every other block in .static-page. */
@media (max-width: 768px) {
  .policy-identity--card { width: 100%; }
}

/* Double the space between the contact card and the "Before you write"
   heading under it, asked for outright.

   38px was not this element's own doing: .policy-identity's margin-bottom
   is 8px, .policy h2's margin-top is 38px, and adjoining siblings collapse
   to the LARGER of the two — so the heading's own margin was setting the
   gap and the card's was doing nothing. Setting 76px here is what makes
   the card win that collapse, at exactly twice what was visible before.

   Scoped to --card, so the plain grey .policy-identity box at the foot of
   the other policy pages keeps its own spacing. */
.policy-identity--card { margin-bottom: 76px; }

/* Wordmark left, locale right, on one line — the top edge of a card.
   No rule beneath it: the card's own shape and the gap the flex column
   opens up already separate this line from the details below, and a
   hairline across it was cutting one object into two panels. */
.identity-card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  flex: 0 0 auto;
}

.identity-card-logo {
  height: 26px;
  width: auto;
  display: block;
  /* Same reason as .footer-brand-img: the asset is solid black on
     transparent and would be invisible here. It is pure black-and-white,
     so a full invert is the whole fix rather than an approximation. */
  filter: invert(1);
}

/* Small caps set wide — the "issued by" line of a card, not a label. */
.identity-card-tag {
  flex: 0 0 auto;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.62);
}

/* The details, bottom-left, where a card carries them. Stacked rather
   than the two-column grid .policy-id-list uses on the policy pages: at
   a card's width the label column and a full email address fight for the
   same line, and label-over-value is how the printed object reads anyway.

   flex:0 0 auto so this block sits on the card's bottom edge and the
   slack collects in the middle, rather than the list stretching into it. */
.policy-identity--card .policy-id-list {
  display: flex;
  flex-direction: column;
  flex: 0 0 auto;
  font-size: 13.5px;
  /* Shrunk 20%, anchored at the group's own bottom-left corner — asked
     for outright. transform:scale (rather than shrinking the font-size
     and every dt/dd spacing value individually) is what keeps that
     corner exactly where it already sits while everything else in the
     group scales toward it: transform-origin:left bottom makes that
     corner the one point scale() doesn't move, so the block shrinks up
     and to the right from there instead of shrinking from its centre
     (the default) or its top-left. */
  transform: scale(0.8);
  transform-origin: left bottom;
}
.policy-identity--card .policy-id-list dt {
  color: rgba(255, 255, 255, 0.5);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  /* Rendered gap runs ~7px over this value (line-height overflow), so
     -1px lands the actual gap at 6px. */
  margin-bottom: -1px;
}
.policy-identity--card .policy-id-list dd {
  color: #fff;
  /* Property-to-property spacing lives here instead of in a uniform
     flex gap, since gap can't hold two different values for the two
     relationships. Rendered gap runs ~7px over this value, so 13px
     lands the actual gap at 20px. */
  margin-bottom: 13px;
}
.policy-identity--card .policy-id-list dd:last-child { margin-bottom: 0; }

/* The monogram, bottom-right — where a payment card puts its scheme
   mark. Inter 700 at -0.02em, scaled up to card-mark size.

   That was copied from .card-name to give the two one typographic
   voice, and the two have since parted: .card-name moved to the
   Helvetica stack the product page's title uses. This one deliberately
   did not follow — it is a mark on an identity card, not a product
   name, and it sits beside the Inter wordmark above it rather than
   beside a grid of products. Stated explicitly so the pair is not
   "resynced" later on the strength of a comment.

   NOT uppercased, though .card-name is: the mark is "Pa", and a
   text-transform would render it "PA" — a different mark. Everything
   else about the treatment carries over.

   Absolute rather than a third flex row: the card's column layout pins
   the wordmark to the top and the details to the bottom, and adding a
   sibling would have to fight that for the same corner the details
   already own. */
.identity-card-mono {
  position: absolute;
  right: 24px;
  bottom: 22px;
  font-family: 'Inter', sans-serif;
  font-size: 64.5px;
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1;
  color: #fff;
  /* Held back from full white: at this size a solid mark would outweigh
     the wordmark it belongs to. Enough presence to read as printed on
     the card, not enough to compete for the first look. */
  opacity: 0.42;
  transition: opacity 0.12s ease-out;
  user-select: none;
  cursor: default;
  pointer-events: auto;
}

.identity-card-mono:hover {
  opacity: 1;
}

/* The ≤640px override that used to live here (right:20px; bottom:80px;
   font-size:38px) is gone — asked for outright, "looks exactly the
   same as it does on Desktop sizes." bottom:80px on a card whose own
   height is width/1.586 (the aspect-ratio above) is most of that
   card's height on a narrow phone, which is what put "PA" reading as
   vertically centred instead of pinned to the bottom-right corner the
   way it is everywhere else — .identity-card-mono's own base rule
   (right:24px; bottom:22px; font-size:64.5px) now carries every width,
   scaling only through the card's own responsive width/aspect-ratio,
   same as the rest of the card already did. */
.policy-identity--card .policy-id-list dd a {
  color: #fff;
  text-decoration: underline;
  text-decoration-color: rgba(255, 255, 255, 0.4);
  text-underline-offset: 3px;
  transition: text-decoration-color var(--transition);
}
.policy-identity--card .policy-id-list dd a:hover {
  text-decoration-color: #fff;
}

/* The ≤640px override that used to live here (padding:22px 20px 20px,
   plus hiding .identity-card-tag outright) is gone — asked for
   outright, "looks exactly the same as it does on Desktop sizes." The
   tag hiding was the "Australia text is missing" bug directly; the
   padding change was the other half of the card's proportions coming
   out wrong at this width. Desktop's own 26px 28px padding and visible
   tag now carry every width unconditionally. */

/* The ACL statement on the returns page. It sits above everything else
   because it outranks everything else. */
.policy-callout {
  margin: 24px 0 8px;
  padding: 18px 20px;
  border: 1px solid var(--ok-border);
  border-radius: var(--radius-lg);
  background: var(--ok-bg);
}
.policy-callout p { margin: 0; color: #14532d; }
.policy-callout strong { color: #14532d; }


.policy-table { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 480px; }
.policy-table th {
  text-align: left; padding: 10px 14px;
  font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--text-muted); background: var(--bg-secondary);
  border-bottom: 1px solid var(--border);
}
.policy-table td {
  padding: 11px 14px; vertical-align: top;
  border-bottom: 1px solid var(--border-soft);
  color: var(--text-secondary);
}
.policy-table tbody tr:last-child td { border-bottom: none; }
.policy .scroller {
  overflow-x: auto;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-md);
  margin: 0 0 18px;
}

/* ── Address autocomplete (checkout) ──
   Our own dropdown rather than Google's widget — see the header of
   js/places.js for why the drop-in element was not used. The list is a
   sibling of the address input inside .pa-ac, which is the positioning
   context; the input itself keeps every style it already had, since it
   is still the form's own field. */
.pa-ac { position: relative; }

.pa-ac-list {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  z-index: 30;
  margin: 0;
  padding: 4px;
  list-style: none;
  max-height: 260px;
  overflow-y: auto;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  /* Deeper than a card's: this floats over the form rather than sitting
     in it, and needs to read as a layer above. */
  box-shadow: 0 12px 32px rgba(10, 10, 12, 0.14);
}
.pa-ac-list[hidden] { display: none; }

.pa-ac-item {
  padding: 9px 11px;
  border-radius: 6px;
  font-size: 14.5px;
  line-height: 1.4;
  color: var(--text-primary);
  cursor: pointer;
}
/* One highlight for both routes in, so keyboard and mouse never disagree
   about which row is current: js/places.js sets .is-active on arrow keys
   and the pointer sets it on hover. */
.pa-ac-item:hover,
.pa-ac-item.is-active {
  background: var(--bg-secondary);
}

@media (prefers-reduced-motion: reduce) {
  .pa-ac-list { scroll-behavior: auto; }
}

/* ── Contact form ──
   NOT the Contact page, which has no form on it any more — this is the
   suggest-a-peptide / contact modal built by PA.contactModal in
   js/ui.js, which is now the only thing wearing this class.

   The Contact page's own .contact-grid / .contact-card / .contact-note /
   .contact-ok went with the two info boxes and the in-page message form
   when those were removed; nothing else referenced them. */
.contact-form { margin-top: 8px; max-width: 520px; }
.contact-form textarea.field-input { resize: vertical; min-height: 120px; font-family: inherit; }

/* ============================================================
   MOBILE
   ------------------------------------------------------------
   Everything below is inside a max-width query, so no rule here can
   reach a desktop viewport. The desktop layout is finished and is not
   to move; this block only repairs what stops working once the screen
   is narrow.
   ============================================================ */

@media (max-width: 640px) {
  /* ── Type that collapsed to 7.8px ──
     Three elements size themselves off
     calc(clamp(32px, 5vw, 72px) * 0.9 * 1.15 * 0.82 * 0.661 / 2.3).
     That chain is fine while the clamp is tracking the viewport, but
     below 640px 5vw drops under the 32px floor and the whole expression
     freezes at a constant — 32 × 0.9 × 1.15 × 0.82 × 0.661 ÷ 2.3, which
     is 7.8px. Not small: unreadable, and it was the RESEARCH USE
     disclaimer among them.

     Floored here rather than by changing the formula, because the
     formula is what keeps these in proportion to their headings on
     desktop and that relationship is correct there. Below the floor the
     proportion has already stopped meaning anything — the numerator
     stopped moving — so a readable absolute size loses nothing. */
  .research-banner--capped .research-banner-text,
  .research-banner--capped .process-mega-sub,
  .op-card-sub {
    font-size: 13px;
    line-height: 1.6;
  }

  /* The order-process card's own heading and the featured-products
     title bottom out at ~27px, which is a reasonable phone headline —
     left alone. */
}

@media (max-width: 1024px) {
  /* ── The hamburger's hit area ──
     The icon is 26×18: three 2px bars with 8px between them, and it
     should stay exactly that size — it is drawn to match the cart and
     account glyphs beside it. 18px of height is not a tap target
     though, and this is the control every other page on a phone is
     reached through.

     Enlarged with a pseudo-element rather than by padding the button:
     padding would move the absolutely-positioned bars inside it, and
     the bars are what the icon IS. This lays a 44×40 catch area over
     the same 26×18 drawing, centred, painting nothing.

     .nav-toggle is display:none above this breakpoint, so this cannot
     reach desktop even if the query did. */
  .nav-toggle::after {
    content: '';
    position: absolute;
    top: -11px;
    bottom: -11px;
    left: -9px;
    right: -9px;
  }

  /* Same treatment for the wordmark, which is 22px tall and is the way
     back to the home page. NOT applied to .nav-icon — those are already
     42×42, and an overlay hanging 10px past each would overlap its
     neighbour and start swallowing the other icon's taps. */
  .nav-logo { position: relative; }
  .nav-logo::after {
    content: '';
    position: absolute;
    inset: -10px;
  }
}

/* ============================================================
   MOBILE — round two
   Same rule as the block above: everything here is inside a
   max-width query and cannot reach a desktop viewport.
   ============================================================ */

/* The two phrasings of the featured-products title. Desktop shows the
   full one; the phone tier below swaps them. */
.mega-short { display: none; }

@media (max-width: 1024px) {
  /* ── The offer pill was invisible because it was sitting ON the cart bar ──
     At this width .cart-float-bar goes full-bleed (left/right 16px,
     bottom 16px) and the dock sits at bottom 24px — so the 152x48 pill
     landed inside the bar's own footprint, 740-788 against the bar's
     738-788. It was never hidden; it was camouflaged against a dark bar
     of the same size in the same place, and it covered that bar's call
     to action in return.

     Lifted clear of the bar's full height plus a gap. Applied whether or
     not the bar is present — with an empty cart the pill simply sits a
     little higher, which costs nothing. */
  .promo-dock { bottom: 82px; }

  /* ── Expanded, the card takes the page ──
     A fixed-width panel anchored to one corner is a desktop shape. On a
     phone there is no "beside it" for it to sit next to, so the open
     state spans the viewport with an even margin and stops being a
     corner object at all. Anchored to both edges rather than sized and
     positioned, so the margins cannot disagree. */
  .promo-card[data-state="open"] {
    position: fixed;
    left: 16px;
    right: 16px;
    bottom: 16px;
    width: auto;
  }
}

@media (max-width: 640px) {
  /* Hero buttons and cascade sizing at this tier now live in the
     PHONE HERO block (max-width: 768px), which fully replaced the
     scale-and-negative-margin approach that used to live here — see that
     block for both. */

  /* The slider stays on phones — three tabs across, short labels (see the
     op-seg-label rules). The panel below it is a single column here; the
     two-up grid it uses on wider screens has no room. */
  .op-panel { grid-template-columns: minmax(0, 1fr); }
  .op-panel .op-step + .op-step {
    margin-top: 22px;
    padding-top: 22px;
    border-top: 1px solid var(--border-soft);
  }

  /* ── Titles and copy, up a step ──
     Both bottom out at the clamp's floor here (see the note in the first
     mobile block), so they were sized for a viewport that had stopped
     being measured. Set outright at a size that reads as a section
     heading on a phone rather than as body text in bold. */
  .op-heading {
    font-size: 30px;
    letter-spacing: -0.03em;
  }
  .op-card-sub {
    font-size: 15px;
    line-height: 1.6;
  }

  /* ── Featured products ── */
  .mega-full { display: none; }
  .mega-short { display: inline; }
  .research-banner--capped .process-mega-heading {
    font-size: 32px;
    letter-spacing: -0.03em;
  }
  /* The disclaimer ran edge to edge, which made it read as chrome rather
     than as a sentence. Its own inset, on top of the band's. */
  .research-banner--capped .research-banner-text {
    padding-inline: 12px;
  }
  .research-banner--capped .research-banner-inner {
    padding-inline: 8px;
  }
}

/* The two phrasings of the fold disclaimer and the slider labels. Desktop
   shows the full ones; the queries below swap them. display:none keeps
   the hidden twin out of the accessibility tree as well as the layout, so
   each is announced exactly once. */
.rb-short,
.op-seg-label-short { display: none; }

/* ============================================================
   MOBILE — round three
   All inside max-width queries; desktop is untouched.
   ============================================================ */

@media (max-width: 1024px) {
  /* ── The menu hangs off the bar, not below it ──
     `top: calc(100% + 8px)` left a strip of page showing between the nav
     and the panel it opens, which reads as two unrelated objects rather
     than one drawer coming out of the bar. */
  .nav-right { top: 100%; }

  /* Square where it meets the menu. The bar keeps its rounded lower
     corners as a floating pill; with a full-width panel butted against
     its underside those curves cut into the panel's top edge and left two
     notches of page showing through. */
  .nav.has-open-menu,
  .nav:has(.nav-right.active) {
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
  }
  .nav-right {
    border-top-left-radius: 0;
    border-top-right-radius: 0;
    border-top: none;
  }

  /* ── Weight ──
     The nav row is bold because it is a row of short labels competing
     with a wordmark. A vertical list of full-width rows is not competing
     with anything, and at 20px the bold read as shouting. The bar itself
     keeps its weight — this is scoped to the dropdown. */
  #primary-nav .nav-links a,
  #primary-nav .nav-links button,
  #primary-nav .nav-icon::after {
    font-weight: 500;
  }

  /* ── Account sits on the same rails as every other row ──
     .nav-account is inline-flex so the icon hugs its glyph in the bar.
     Inside the menu that made it the one row that stopped short of both
     edges, with its divider hanging in mid-air. */
  #primary-nav .nav-account,
  #primary-nav .nav-account > a { display: block; width: 100%; }

  /* ── The signed-in menu, folded into the drawer ──
     Signed in, js/nav.js swaps the account link for a pill that opens a
     floating panel. A floating panel anchored to a row inside another
     floating panel is one too many; in the drawer it becomes part of the
     list instead — full width, no card of its own, no shadow, and its
     groups separated by the same rules the links above use. */
  #primary-nav .nav-account-pill {
    display: flex;
    width: 100%;
    justify-content: flex-start;
    gap: 10px;
    padding: 16px 24px;
    border: none;
    border-bottom: 2px solid rgba(0, 0, 0, 0.12);
    border-radius: 0;
    background: none;
    font-size: 20px;
    font-weight: 500;
  }
  #primary-nav .nav-account-pill-name { max-width: none; font-size: inherit; }
  #primary-nav .nav-account-pill-chev { margin-left: auto; }
  #primary-nav .nav-account-menu {
    position: static;
    width: 100%;
    min-width: 0;
    margin: 0;
    border: none;
    border-radius: 0;
    box-shadow: none;
    background: var(--bg-secondary);
  }
  #primary-nav .nav-account-menu-head,
  #primary-nav .nav-account-menu a,
  #primary-nav .nav-account-menu button {
    padding-inline: 24px;
  }
  #primary-nav .nav-account-menu-group + .nav-account-menu-group {
    border-top: 1px solid var(--border);
  }

  /* ── The wordmark ──
     It sat hard against the bar's left padding with its optical left edge
     — the P's stem — flush to the same line as the rows below. A little
     air on the left and a floor under it so the mark is centred in the
     bar's own height rather than sitting on its baseline. */
  .nav-logo { padding-left: 4px; }
}

@media (max-width: 1024px) {
  /* ── The order-process slider stays horizontal ──
     Stacking three tabs turned a control into a list and cost three rows
     of height. They fit side by side well below this width — what stops
     fitting first is the LABEL, not the tab, so the labels shorten and
     the row survives. */
  .op-slider { grid-template-columns: repeat(3, 1fr); }
  .op-slider-thumb {
    width: calc((100% - 2 * var(--op-track-pad)) / 3);
    height: auto;
    top: var(--op-track-pad);
    bottom: var(--op-track-pad);
    transform: translateX(calc(var(--op-active, 0) * 100%));
  }
  .op-seg { padding-inline: 10px; }
  .op-seg-label { font-size: 12.5px; line-height: 1.25; }
  .op-seg-step { font-size: 10.5px; }
}

@media (max-width: 640px) {
  /* Phone only. Three full labels will not fit across a phone, so the
     LABEL gives rather than the row — two words each keeps the control
     one line instead of three. Tablet and desktop keep the full wording:
     they have the width for it, and the short forms read as an
     abbreviation when there is no reason to abbreviate. */
  .op-seg-label-full { display: none; }
  .op-seg-label-short { display: inline; }
}

@media (max-width: 640px) {
  /* Asked for outright: a step up from the 30px set in round two. */
  .op-heading { font-size: 34px; }
}


@media (max-width: 640px) {
  /* ── The black band, shortened ──
     The full sentence is four lines of 15px type on a phone, which reads
     as a wall rather than as a notice — and the part that has to land is
     the first three words. The full text is still on every policy page
     and in the footer band, so nothing is lost by holding it back here.

     The promo bar above it is a separate thing and is handled below. */
  .rb-full { display: none; }
  .rb-short { display: inline; }
  .research-banner--fold { padding: 16px 20px; }
  .research-banner--fold .research-banner-text {
    font-size: 14px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    white-space: normal;
  }
}

@media (max-width: 640px) {
  /* ── The standing-offer strip stops being scrunched ──
     Three offers and two rules on a 375px row is about 12px of type with
     no space around it — legible in the sense that the glyphs are there,
     not in the sense that anyone reads it. One offer at a readable size
     beats three at an unreadable one — so only one is ever LAID OUT at
     a time — but rather than permanently picking the first and burying
     the other two, all three now take turns in that one slot: stacked
     directly on top of each other (position:absolute inset:0, all three
     sharing the track's own box) and cross-faded by
     js/partials.js:initPromoRotator, which toggles .is-active on
     exactly one at a time on an interval. The stacking is what lets the
     fade cross rather than cut — a plain display:none swap has no
     "between" state to transition through.

     .promo-track needs an explicit height for this: absolutely
     positioned children don't contribute to a parent's auto height, and
     with all three out of flow there is nothing left in flow to hold
     the row open. 100% resolves against .promo-bar's own fixed
     var(--promo-h), same source the un-rotated row used before.

     Inactive items are opacity:0 AND aria-hidden — a screen reader
     should hear the one offer actually on screen, not three stacked
     sentences read back to back. initPromoRotator keeps aria-hidden in
     sync with .is-active as it advances, and clears it (along with the
     rotation itself) if the window grows past this breakpoint. */
  .promo-track {
    height: 100%;
    position: relative;
    gap: 0;
    justify-content: center;
  }
  .promo-item {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.7s ease;
  }
  .promo-item.is-active {
    opacity: 1;
    pointer-events: auto;
  }
  .promo-sep { display: none; }
  .promo-item { font-size: 13px; letter-spacing: 0.01em; }
}

/* Motion that exists only to cycle text nobody asked to see move is
   exactly what this setting is for — freezes on whichever offer
   initPromoRotator last showed rather than continuing to cross-fade. */
@media (max-width: 640px) and (prefers-reduced-motion: reduce) {
  .promo-item { transition: none; }
}

/* Reversed spin for the narrowest tier. Declared at top level because it
   is referenced from inside a media query by name.

   This replaced a scaleY(-1) version. That did turn the glyphs over, but
   a mirror flips them rather than rotating them — every character came
   out reversed as well as inverted, which reads as a rendering fault
   rather than as a deliberate direction. Negating the rotation reverses
   the travel and leaves the type the right way up. */
@keyframes heroRingSpinReverse {
  from { transform: rotate(0deg); }
  to   { transform: rotate(-360deg); }
}

@media (max-width: 1024px) {
  /* ── The email field was 248px tall ──
     `flex: 1 1 248px` on .promo-popup-input is a WIDTH basis — it exists
     so the placeholder "researcher@institution.edu.au" is not cut off on
     the row layout. flex-basis applies to the main axis, and the rule
     above turns this form into a column, so that 248 stopped being a
     width and became a height: one text input as tall as the card.
     Basis released here; the field is content-height like any other. */
  .promo-popup-input { flex: 0 0 auto; }
}

/* ============================================================
   PHONE HERO — 768px and below
   ------------------------------------------------------------
   Every piece here is deliberately FIXED, not fluid: a chosen px size
   and a chosen offset, not a vw or a scale(). The brief was specific —
   the cascade keeps one radius and one screen position at every width
   in this tier, truncated by .hero's own overflow:hidden rather than
   shrunk, and the title/buttons hold their own fixed offsets rather
   than reflowing with content height. A single breakpoint replaces what
   used to be three overlapping ones (1024px's scale-down, a 640px
   "25% bigger" step, and a further 640px "ring behind the nav" pass) —
   above 768px the desktop/tablet layout is untouched; at or below it,
   the whole hero switches over in one step. */
@media (max-width: 768px) {
  /* ── The cascade, straddling the nav ──
     700px: at exactly this breakpoint's own width that leaves ~34px of
     margin either side, so the full lower semicircle sits inside the
     screen with a little air around it — see the brief's "pick a size
     where the whole bottom half perimeter is visible". Below 768px
     there is nothing to recompute: the box stays exactly 700px and
     exactly centred, and narrower phones simply crop its left and right
     thirds against .hero's own overflow:hidden. Everything inside the
     assembly (disc, ring, bottle tiles) is a ratio of --hero-orb-size
     (see .hero-orb-disc, .hero-orb-cols), so fixing this one number
     keeps every gap in the same proportion it has at any other size —
     nothing further to tune for that.

     Centred on the nav bar's own BOTTOM edge, not just started there:
     translate(-50%, -50%) puts the wrap's own centre — not its top-left
     corner — on that line, which is what actually puts half the ring
     above it (hidden behind the bar, which sits at a much higher
     z-index) and half below (on screen). The previous version of this
     rule only translated X, so the wrap's top edge sat ON the line and
     the whole ring showed up below it with nothing hidden behind the
     bar at all. --nav-h is published by js/partials.js from the bar's
     measured height; the fallback is only for the frames before that
     lands. */
  .hero-orb-wrap {
    /* 465px is the midpoint of the two sizes this has been: the 630px
       that swamped a real phone and the 300px that replaced it. The
       min() against the svh term is what keeps that from re-breaking the
       thing 300px was fixing — the ring's lower edge and the copy below
       it compete for the same vertical budget, so on a short viewport
       (an iPhone SE, or any phone with Safari's toolbars showing) the
       ring takes a proportion of the screen instead of a fixed 465px it
       has no room for. Width is not a term in it: the ring is the same
       size at 320px and 768px wide, as specified.

       Redeclared here even though .hero (below) already sets it: a
       custom property assigned directly on an element always wins over
       one only inherited from an ancestor, and the base (unconditional,
       non-phone) rule for .hero-orb-wrap sets its own --hero-orb-size
       directly on itself. Without this line that direct 547.4px would
       keep beating whatever .hero tries to hand down at any width.

       MUST match .hero's value exactly. This one sizes the drawn circle;
       .hero's is what .hero-content::before reads to place the copy
       below it, and .hero-content is a child of .hero rather than of
       this element, so the two are read independently and only agree
       because they are written the same. They were briefly not: this
       stayed at 60svh when .hero went to 48svh, and the ring's lower
       edge ended up 42px INTO the title, because the spacer had shrunk
       by 0.5932 x the difference while the circle it is clearing had
       not. Change both or neither. */
    --hero-orb-size: min(465px, 48svh);
    position: absolute;
    left: 50%;
    /* + 10.41px, asked for outright: translate the whole cascade down
       enough to close the gap to the "Peptides" title below it by
       half. Measured against the real rendered page — cascade bottom
       to title top ran 20.8125px, so half of that closed is 10.40625px
       of extra travel. Added ONLY here, not to the spacer that
       actually positions .hero-copy (a few rules down, its own
       separate calc() off the same promo-h/nav-h/orb-size terms) —
       that spacer's height doesn't read the cascade's rendered
       position at all, only these same input variables independently,
       so the title and everything below it hold still exactly where
       they already were while only the cascade above them moves.
       Since neither term this adds to varies with viewport WIDTH
       (svh/nav-h are height-driven, per the note on --hero-orb-size
       above), the new gap holds at any window width, not just the one
       this was measured at. */
    /* + one tenth of the cascade's own diameter, on top of the 10.41px
       named above — asked for outright ("move product cascade down ...
       by about ten percent of its diameter"). --hero-orb-size IS that
       diameter (see the note on it above), so the shift scales with the
       circle rather than being a pixel value that only holds at the one
       viewport height it happened to be measured at.

       The same term is added to .hero-content::before's height below,
       which is what actually positions the title/description/buttons —
       that spacer reads these same variables independently rather than
       measuring the cascade's rendered position, so without a matching
       addition there this circle would simply move down INTO the copy.
       "Moving everything below it down with it an equal distance" is
       that second addition, not a consequence of this one. */
    /* The trailing −14px is asked for outright: move the cascade up by
       that much at every phone width.

       Deliberately NOT mirrored in .hero-content::before below, unlike
       the two --hero-orb-size terms either side of it — those are
       repeated there so the copy tracks the circle. This one is not, so
       the circle rises and the title stays put, which opens the gap
       between the ring's lower edge and "Peptides" by exactly 14px.
       That is the point of the request: it is the crowding between
       those two that is being relieved, not the position of the copy. */
    top: calc(var(--promo-h) + var(--nav-h, 71px) + 10.41px + var(--hero-orb-size) * 0.1 - 14px);
    transform: translate(-50%, -50%);
    width: var(--hero-orb-size);
    height: var(--hero-orb-size);
    margin: 0;
    z-index: 0;
  }

  /* ── More air between the ring of text and the cascade ──
     .hero-orb's own ratio (0.857143 of --hero-orb-size, everywhere
     else) shrunk here, not the ring or the disc — those stay exactly
     where the maths at the top of this file puts them, so the ring's
     own size and position on screen are untouched. Only the bottle
     circle pulls in, which is what actually opens the gap: at 700px it
     was 0.89841 − 0.857143 = 0.04127 of the wrap's radius, ~14px:
     tight enough to read as the ring crowding the cascade rather than
     framing it. 0.80 roughly doubles that to ~34px. */
  .hero-orb {
    width: calc(var(--hero-orb-size) * 0.80);
    height: calc(var(--hero-orb-size) * 0.80);
  }

  /* Travelling the other way — see the keyframes above. animation-name
     rather than a static transform: an animated transform replaces a
     declared one outright, so anything the spin needs to carry has to
     live inside the keyframes or it is discarded on the first frame. */
  .hero-orb-ring { animation-name: heroRingSpinReverse; }

  /* --hero-orb-size lives here rather than on .hero-orb-wrap itself,
     even though the wrap is its only reader below 1024px — .hero-copy
     needs to read the same number for its own margin-top (see below),
     and a custom property declared on one element is invisible to a
     sibling branch of the tree; .hero is the nearest ancestor both
     .hero-orb-wrap and .hero-content (.hero-copy's parent) share, so
     declaring it here is what lets both actually see it. (An earlier
     pass declared it on .hero-orb-wrap directly, which left
     .hero-copy's margin-top calc() referencing an undefined variable —
     an invalid value, which CSS drops the WHOLE declaration for rather
     than partially applying, so the margin silently came out as 0.)

     .hero-orb-wrap is absolutely positioned (above), out of flow — it
     no longer has any say in .hero's own height, which is why that has
     to be set outright below instead of left to add up on its own. */
  .hero {
    /* ── 48svh, down from 60 ──
       The cascade's diameter is the single biggest term in this
       column's height (it contributes 0.5932 × its own size to
       .hero-content::before below — half the backing disc, plus the
       0.1 travel), so it is the only lever big enough to buy the
       bottom clearance the padding below now guarantees.

       Measured, not guessed. On an iPhone 14 Pro (393×852) with
       Safari's chrome expanded the small viewport is ~737px, and at
       60svh the View Tests button's bottom edge landed 33px above it —
       under Safari's floating toolbar, which overlays roughly 76px of
       that. At 48svh it lands 85px clear, which puts it above the
       toolbar rather than behind it. */
    --hero-orb-size: min(465px, 48svh);
    /* min-height, not the fixed height this carried while the copy was
       pinned to the screen's bottom edge (see .hero-copy below for why
       that pinning is gone). One svh is still the FLOOR — a phone with
       room to spare should not show a hero shorter than its own screen
       — but it is no longer a ceiling: the copy is top-anchored now, at
       a position fixed relative to the ring rather than to the bottom
       of whatever box this is, so there is nothing left for a fixed
       height to protect. A cascade plus copy plus buttons too tall for
       the shortest real screens simply makes the section — and the
       page under it — taller instead of clipping or overlapping, which
       a FIXED height would do the instant its own children stopped
       being allowed to shrink (see .hero-content::before).

       ── lvh, not svh, and definitely not dvh ──
       All three are stable during a scroll except dvh, so dvh is out
       first: a hero whose height tracked the chrome would re-lay-out
       the whole document below it mid-scroll.

       That leaves which fixed one. svh is the viewport at its SMALLEST,
       with the browser chrome fully expanded — and that is what made
       "Your Order Process" peek in at the bottom and appear to move.
       The hero was 737px on an iPhone 14 Pro while the screen, once
       Safari's chrome collapsed, showed ~852px: the hero simply ran out
       ~115px before the bottom of the glass, and the next section
       showed through the gap. As the chrome animates between its two
       states that gap opens and closes, which is the "moving" half.

       This used to be 100lvh, on the reasoning that a hero tall enough
       to fill the LARGEST viewport can never let the next section peek
       in above the fold. True, and it bought that at the cost of the
       thing the visitor actually needs: at 100lvh the bottom of the
       hero — the two buttons — sits underneath Safari's bottom bar for
       as long as that bar is showing, which is the entire time before
       anyone scrolls.

       svh is the viewport at its SMALLEST, chrome showing, which is the
       only height guaranteed to be on screen at load. Sizing the hero to
       it puts the buttons above the bar in the state a visitor arrives
       in.

       Nothing peeks in underneath, because the hero is a flex child of
       .home-fold now (see the note there) and that wrapper is the 100lvh
       box. The leftover between svh and lvh belongs to the research
       banner, which is exactly the strip the chrome was covering.

       No box height is set here at all any more. The hero is a grid item
       of .home-fold (see the note there) and its row is `1fr`, so its
       height is one large viewport less the band — which below 1040px,
       where the band is display:none, is the whole screen.

       What svh governs at this width is the hero's INTERNAL spacing, not
       its box: the padding and the gaps between the copy, the cascade
       and the buttons are computed against the small viewport, so the
       content column is sized to the height that is guaranteed visible
       with Safari's bottom bar showing. The box may be taller than that;
       the content is not. */
    min-height: auto;
    /* ── Guaranteed room under the last button ──
       Was a flat `padding: 0`, which left the buttons' clearance to
       whatever the flex leftover happened to be — and on a screen where
       the content fills the column that leftover is zero, so the last
       button sat against the bottom edge with only its own 16px margin.

       This states the clearance instead of hoping for it. It is INSIDE
       min-height (border-box), so it shrinks the content band rather
       than making the section taller, and it stacks with .hero-actions'
       own 16px margin-bottom for ~78px of real space on a device with a
       home indicator.

       The safe-area inset is added to a flat 28px rather than replacing
       it, for two different bottom bars: env() covers the home
       indicator, and the flat part is what covers a browser toolbar
       floating over the content — iOS 26/27's translucent bar is drawn
       OVER the page rather than insetting it, so svh does not reserve
       space for it and env() does not report it. A phone with neither
       still gets a sensible 28px rather than a gap sized for hardware
       it does not have. */
    padding: 0 0 calc(28px + env(safe-area-inset-bottom, 0px));
  }

  /* .hero-content is already display:flex; flex-direction:column (see
     the base rule) — flex:1 is what makes it fill .hero's height rather
     than sitting at its own content's on a screen taller than the ring
     plus the copy needs, so the leftover space collects at the bottom
     of the section (below the buttons) instead of nowhere in
     particular. min-height:0 overrides the flex default of `auto`,
     which is a FLOOR at the item's content height and would otherwise
     stop this shrinking below that on a screen too short for
     everything — not that it needs to shrink any more (see the rigid
     spacer below), but leaving flex's own default floor in place here
     would still fight .hero's min-height for no reason. */
  .hero-content {
    flex: 1;
    /* min-height: 0 overrides the flex default of `auto`, which resolves
       to the item's own content height and is a FLOOR — so .hero-content
       sat at its natural 621px inside a 555px hero and passed no
       pressure down to the shrinkable gap in it, which is why that gap
       stayed at full size and the buttons still overflowed after .hero
       was given a fixed height. With the floor released the item is
       bounded by the hero, and the gap inside it finally has something
       to shrink against. */
    min-height: 0;
  }

  /* ── The headline, a fixed distance below the cascade ──
     margin-top, not position:absolute — this is what lets .hero-copy
     stay in normal flow, so a phone too short for everything to fit
     simply scrolls instead of overlapping.

     The nav-line the cascade is centred on, plus that circle's own
     radius (0.98635 of --hero-orb-size is the white backing disc, the
     widest element in the assembly — see .hero-orb-disc), plus a flat
     60px of air so the title reads as clear of the cascade rather than
     crowding it. This number no longer has to also leave room for the
     buttons below — .hero-actions' own margin-top:auto (see below)
     claims whatever is left in the screen for that, independently of
     how tall this gap or the copy itself is. Reading off
     --hero-orb-size rather than a second hard-coded number is what
     keeps this gap in the same proportion to the cascade if that
     variable is ever changed again. */
  .hero-copy {
    /* Anchored from the top, immediately under the spacer below — asked
       for outright ("move the buttons title and description all up to
       the position just below the bottom point of the text ring"). This
       replaces an earlier margin-top:auto that instead pinned the whole
       copy+actions group to the SCREEN's bottom edge, leaving a big gap
       between the ring and the title on anything taller than the
       shortest phone this was tuned against; that gap is what is being
       asked to close here. */
    margin: 0;
    /* Never shrink. Flex items shrink by default, and min-height:auto
       does not reliably hold a text block at its content height once
       the container is over-constrained — so on a short screen this box
       was being squeezed and its last line spilled out from under the
       buttons below it. The gap above (.hero-content::before) is the
       one thing in this column that is allowed to give; pinning
       everything else is what makes that unambiguous. */
    flex: 0 0 auto;
    padding-inline: 24px;
    position: relative;
    z-index: 2;
  }
  /* The spacer that keeps the copy off the cascade — and now the thing
     that actually SETS the copy's position, since .hero-copy is
     top-anchored (margin:0) rather than pushed down by an auto margin.
     Its own height is the nav line plus the ring's own radius (see
     .hero-orb-disc for the 0.98635 — this is "the bottom point of the
     text ring"), plus a flat 24px — the "bit of set vertical space"
     asked for outright, up from an earlier 10px that read as the title
     crowding the ring rather than sitting clear of it. */
  .hero-content::before {
    content: '';
    display: block;
    /* RIGID (0 0), not shrinkable — the opposite of an earlier version
       of this rule, and deliberately: this spacer's whole job is to
       hold the copy at a fixed distance below the RING, which is
       absolutely positioned and does not itself shrink under pressure.
       A shrinkable spacer could compress below that distance on a short
       screen while the ring stayed put, which is exactly what put the
       title overlapping the cascade the one time this was tried. Rigid,
       the two always agree; a screen too short for the result just
       makes .hero taller than one viewport (min-height, above) rather
       than either overlapping or clipping. */
    flex: 0 0 auto;
    /* The trailing + --hero-orb-size * 0.1 is the cascade's own extra
       travel (see .hero-orb-wrap's `top` above), repeated here so the
       copy and the buttons below it move down by exactly the same
       distance the circle did and every gap between them is unchanged. */
    height: calc(var(--promo-h) + var(--nav-h, 71px) + (var(--hero-orb-size) * 0.98635 / 2) + 24px + var(--hero-orb-size) * 0.1);
  }

  /* A flat size, not the clamp(40px, 8vw, 80px) chain the desktop title
     uses — that chain is what "resizes with the window" the brief asks
     this NOT to do. 54px (that formula's own value at exactly 768px)
     was the first thing tried here, and it was wrong: "Peptides" is one
     word with no space to wrap at, and at 54px its own ink runs to
     343px — wider than the content box on a 320px phone (its 24px
     side padding included) — so it overflowed rather than wrapping,
     and .hero's overflow:hidden clipped both ends of it instead of
     letting it shrink to fit. 40px is measured to clear a 320px phone
     with room to spare (272px available, ~254px of ink), and, per the
     brief, stays exactly 40px at 768px too — one flat size for the
     whole tier, not a second breakpoint-within-a-breakpoint. */
  .hero-title {
    font-size: 40px;
    /* Tightened leading and margin. The hero's total height is what
       decides whether the buttons are on screen at all (see .hero's
       min-height note), and this block plus the subtitle below is the
       biggest single term in it — 114px of title on a 628px screen was
       most of why "View Tests" fell off the bottom. */
    line-height: 1.05;
    margin-bottom: 12px;
  }

  /* ── "Your Order Process" matched to "Tested & Delivered" ──
     .hero-title-subtitle ("Tested & Delivered", above) is 0.82em of
     .hero-title's own 40px — 32.8px — flat across this whole tier since
     .hero-title itself is flat here. .op-heading's base rule (desktop)
     is a clamp()-driven formula meant to size it off .hero-title-subtitle
     too, but the clamp bottoms out well above 32.8px at these widths
     (it was already overridden separately at 640px and below, at two
     different flat values, neither matching this one either) — so it
     needs its own flat override for the request to hold across the
     whole ≤768px tier, not just the part of it above 640px. Asked for
     outright, and set after the 640px-and-below rules in the cascade so
     it wins there too, rather than only down to 641px. */
  .op-heading {
    font-size: 32.8px;
    letter-spacing: -0.035em;
  }
  /* ── The order-process description matched to the hero's own ──
     .hero-subtitle-short (the main description) is a flat 16px across
     this whole tier. Same reasoning as .op-heading above: .op-card-sub's
     base formula and its 640px-and-below overrides (13px, then 15px)
     all land somewhere else, so this is set flat and after them to hold
     for the full ≤768px range. */
  .op-card-sub {
    font-size: 16px;
    line-height: 1.6;
    /* Breaks the box out to a fixed 400px — the middle of a measured
       381-420px range that wraps this sentence into three specific
       lines ("…to the" / "…exactly what" / "…each stage.") at 16px —
       centred regardless of .op-card's own 22px padding (--op-card-pad),
       which .op-card-sub sits inside and which alone leaves only 336px
       here, short of the 381px minimum. Same margin-left = margin-right
       = 50% - 200px (half of 400px) solution as .hero-subtitle uses for
       the same reason: with both margins pinned to a fixed value, auto
       width comes out to exactly 400px on its own, independent of
       whatever width the padded containing block actually resolves to
       — so it doesn't matter here that .op-card also carries zoom:0.9,
       which scales this 400px (a pre-zoom, logical CSS value) down to a
       360px rendered box without changing the wrap: zoom scales the
       type and the box by the same factor, so the ratio between them —
       what actually decides where words break — is unaffected. Safe to
       break out past the padding at all: .op-card is deliberately
       frameless (no border/shadow), so there is no visible edge for a
       wider box to visually cross. */
    margin-left: calc(50% - 200px);
    margin-right: calc(50% - 200px);
  }

  /* The tables and their disclaimers (.op-bulk, its th/td, the
     .op-pay-table/.op-bulk--rows cell override, .op-bulk-notes) were
     briefly unified to .op-step-body's 15.5px here and back out again
     — asked for outright. Removing the override just hands each back
     to its own base-rule size (13.5px cells, 11px column heads, 12.5px
     notes) rather than restating those numbers; .op-step-body needed
     nothing removed since its target and its base rule were the same
     15.5px both times. */

  /* Same reasoning: five wrapped lines at the desktop 15.81px/1.7 came
     to 130px. At 14.5px/1.5 that is ~109px for the same five lines,
     which is 21px straight off the hero's height without the copy
     getting hard to read. margin-bottom goes entirely — .hero-actions
     supplies the gap under this block now. */
  .hero-subtitle {
    font-size: 14.5px;
    line-height: 1.5;
    margin-bottom: 0;
  }

  /* ── The short phrasing ──
     The full sentence is three claims — sourced across Australia,
     independently tested, research use only — in enough words to read
     as a paragraph rather than a subtitle. The short form keeps all
     three claims, said once each. display:none, not just hidden text,
     so a screen reader announces exactly one version rather than both
     in sequence. */
  .hero-subtitle-full { display: none; }
  .hero-subtitle-short {
    display: inline;
    /* 20% down from the flat 20px this was — asked for outright, scoped
       to the short span rather than raised on .hero-subtitle itself,
       since that rule is shared with .hero-subtitle-full and this is
       not meant to touch the full sentence shown above this
       breakpoint. */
    font-size: 16px;
  }
  /* The width that makes THIS sentence, at 16px, break into the three
     lines specified: "…independently" / "…Strictly" / "for research
     use." — re-measured for the new size (checked every integer width
     from 200 to 340), not scaled down from the old 20px numbers by eye.
     The exact wrap holds for any width from 292px to 314px; 303px is
     the middle of that range, for the most margin against a font a
     different device substitutes.

     .hero-copy's own 24px side padding leaves 302px here, which by
     coincidence already sits inside the valid range at this smaller
     size — unlike at 20px, where 302px undercut the 365px minimum. The
     break-out is kept anyway rather than dropped, both for margin
     against a device whose padding resolves slightly differently and
     to land exactly on the range's own midpoint (303px) instead of
     whatever the padding leaves. .hero-subtitle itself (the <p>) carries
     the constraint, since .hero-subtitle-short is inline and an inline
     element does not respond to its own width. */
  .hero-subtitle {
    /* Breaks the box out to a fixed 303px — the middle of the measured
       292-314px range that wraps the short copy into the three target
       lines at 16px — centred on .hero-copy's own centre line no matter
       how wide its 24px padding leaves this element's containing block
       (W, resolved here through the % in each calc(), same basis both
       sides). Solving margin-left = margin-right = 50% - 151.5px (half
       of 303px) makes the box's rendered width fall out to exactly
       303px on its own — width: auto, no max-width needed — because
       with both margins pinned, auto width is just container-width
       minus both margins, and the W terms cancel algebraically. */
    margin-left: calc(50% - 151.5px);
    margin-right: calc(50% - 151.5px);
  }

  /* ── The two buttons ──
     Normal flow, not position:fixed — asked for outright ("the two
     buttons move with the page", meaning scroll away with it like
     everything else, not float over it forever.

     width: min(303px, calc(100vw - 48px)), centred — capped at 303px,
     the same fixed width .hero-subtitle-short's box resolves to just
     above (the description's own max), asked for outright: on any
     phone wide enough, the buttons stop growing at 303px rather than
     continuing to fill the row the way the old 90% did. Below that, the
     min() falls back to the viewport width less .hero-copy's own 48px
     of side padding (24px each edge) — the same 24px the description's
     own box deliberately breaks OUT of, but the buttons don't: they
     shrink with it instead, staying inside that padding on a narrow
     phone rather than crowding the edge the way the description does.

     100% isn't usable here for that fallback: .hero-copy carries no
     width of its own — it's a flex item under .hero-content's
     align-items:center, sized by shrink-to-fit around its children —
     so a percentage on .hero-actions would resolve against whatever
     that circular sizing happens to produce, not against a stable
     24px-padded box. 100vw is the one thing here with a size that
     doesn't depend on any of this column's own content, which is
     exactly what a fixed 48px can be subtracted from reliably.

     .hero-actions is align-items:stretch (see the 1024px rule), so an
     explicit width still has to be given back on the container itself
     for it to read as a pair of buttons rather than two full-bleed
     bars — min() serves that role exactly like the old 90% did. The
     safe-area inset is added on top of a flat 24px rather than
     replacing it, so there is real air below View Tests on a phone
     with a home-indicator bar as well as one without. */
  .hero-actions {
    width: min(303px, calc(100vw - 48px));
    align-self: center;
    flex: 0 0 auto;
    /* Up from 12px — asked for outright, "a tiny bit farther" from the
       copy above. */
    margin-top: 20px;
    margin-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  }

  /* ── Dismissed: docked to the right edge, on its side ──
     The pill leaves the bottom-right corner and becomes a tab on the
     right edge, rotated a quarter turn anticlockwise so the edge that
     was its bottom now faces the screen edge and sits flush against it.

     The dock is the anchor (right edge, middle); the card does the
     rotation. The negative right offset is half the difference between
     the pill's length and its depth — 152 and 48 — which is exactly the
     distance the rotated box's right edge falls short of the screen's
     without it.

     ── Centred on the SMALL viewport, not on 50% ──
     `top: 50%` looks like it means "halfway down the screen" and does
     not. A position:fixed element resolves percentages against the
     initial containing block, which on a mobile browser is the LARGE
     viewport — the one measured with the chrome retracted. That figure
     never changes as the chrome comes and goes, so with Safari's bottom
     bar up the pill sat half the bar's height BELOW the centre of what
     the visitor could actually see, and only looked centred in the state
     where the bar was hidden.

     50svh is the small viewport — chrome showing, the least screen there
     ever is — so the pill is centred in the area that is guaranteed
     visible. When the bar retracts it reads slightly high rather than
     slightly low, which is the better way to be wrong: a tab a little
     above centre is still fully on screen and fully tappable, where one
     below centre creeps toward the bar that is about to cover it.

     vh first as the fallback for anything that does not know svh; on
     those engines it is the behaviour this always had. */
  .promo-dock {
    right: 0;
    left: auto;
    bottom: auto;
    top: 50vh;
    top: 50svh;
  }
  .promo-card[data-state="pill"] {
    /* On its side the pill's LENGTH is the axis the label runs along and
       its DEPTH is the axis across it. At 152 the label had far more room
       at its two ends than it had above and below, so the type sat in a
       long trough. 120 brings the end gaps down to roughly the ~15px the
       depth already gives it, and the two read as one even margin.

       Both numbers are named once and the offset below is derived from
       them — it is exactly how far the rotated box would otherwise fall
       short of the screen edge, so it has to move whenever they do. */
    --pill-len: 120px;
    --pill-depth: 48px;
    width: var(--pill-len);
    height: var(--pill-depth);
    right: calc((var(--pill-len) - var(--pill-depth)) / -2);
    bottom: auto;
    top: 0;
    transform: translateY(-50%) rotate(-90deg);
    transform-origin: center;
  }
}

/* The pill's two phrasings — full by default, short at the tier where the
   pill turns on its side. */
.promo-pill-short { display: none; }
.promo-pill-full { text-transform: uppercase; }

@media (max-width: 768px) {
  .promo-pill-full { display: none; }
  .promo-pill-short { display: inline; }
  /* Bigger and set in caps. On its side the pill's length is measured
     against the screen's height, where there is far more room than the
     bottom-right corner ever had — so the label can afford the size that
     makes it legible at a glance rather than the size that fitted a
     corner. */
  .promo-pill-text {
    font-size: 17px;
    font-weight: 800;
    letter-spacing: 0.08em;
    text-transform: uppercase;
  }
}

@media (max-width: 640px) {
  /* ── "Most Popular", sized against the window ──
     A vw-based size so the title grows with the screen instead of sitting
     at one number chosen for one phone. Capped so a tablet-sized phone in
     landscape does not run it into the nav.

     max-width holds it just inside the product grid below it: the grid is
     the full content column here, and a title exactly as wide reads as a
     fourth column rather than as a heading over three. */
  .research-banner--capped .process-mega-heading {
    font-size: min(13.5vw, 62px);
    max-width: 92%;
    margin-inline: auto;
    line-height: 1.02;
  }

  /* ── The black band, solid and readable ──
     Was 14px letterspaced caps at 65% white on black, which is a caption
     pretending to be a notice. Full white, heavier, and larger — this is
     the one line on the page that has to survive being skimmed. */
  .research-banner--fold .research-banner-text {
    font-size: 15px;
    font-weight: 700;
    letter-spacing: 0.08em;
    color: #fff;
  }
  .research-banner--fold .research-banner-text strong { color: #fff; }
}

/* Removed at 1040px and thinner, asked for outright — the hero already
   carries the same claim in .hero-subtitle-short ("...Strictly for
   research use."), so this band directly under the hero's buttons was
   saying it twice in the first screen and a half. Its own breakpoint
   rather than folded into the 768px or 1024px blocks above/below: 1040
   is neither of those numbers, so it needs a rule of its own regardless
   of how close it sits to them. display:none rather than visibility, so
   it also drops out of the accessibility tree instead of leaving a
   silent gap for a screen reader to land in. */
@media (max-width: 1040px) {
  .research-banner--fold { display: none; }
}

@media (max-width: 1024px) {
  /* ── The fold disclaimer was truncated across the whole tablet band ──
     .research-banner-text is nowrap at base — a hangover from the
     title-alignment trick the CAPPED variant used to need — and the full
     sentence stops fitting on one line at about 1023px. Below 640 the
     short form takes over and it is fine again, so the clipping was
     invisible from either end and only showed in between: measured, it
     was cut from 641px all the way to 1023px.

     Wrapping is the whole fix. The band is not height-constrained, and a
     notice that runs to two lines is a notice; one whose last third is
     off the edge is not. */
  .research-banner--fold .research-banner-text { white-space: normal; }
}

/* ── reCAPTCHA ──
   The floating badge Google's script injects (bottom-right of the
   viewport, every page, permanently) is hidden above the phone tier —
   it named nothing on this site and sat over content on small screens.
   Google's terms allow hiding it ONLY if the required attribution
   appears somewhere the visitor will actually see, which is
   .recaptcha-notice below, once per form that uses a token: js/ui.js's
   two modals and the promo popup. Removing the text without the badge,
   or the badge without the text, are both against those terms — the
   pair has to move together.

   Shown again at ≤768px — asked for outright — while the attribution
   text stays visible at every width regardless (asked for outright
   too, "keep the form disclaimers though"): showing both the badge AND
   the text is always compliant with the terms above, which only bar
   showing NEITHER, so this doesn't reopen the problem the badge was
   hidden for in the first place. */
.grecaptcha-badge { visibility: hidden; }
@media (max-width: 768px) {
  .grecaptcha-badge { visibility: visible; }
}

.recaptcha-notice {
  color: var(--text-muted);
  font-size: 12px;
  line-height: 1.4;
  margin-top: 12px;
}
.recaptcha-notice a { color: inherit; text-decoration: underline; }

.promo-popup-recaptcha {
  font-size: 12px;
  line-height: 1.25;
  color: rgba(255, 255, 255, 0.72);
  text-align: left;
  /* 2px, matching .promo-popup-disclaimers' own `gap` exactly — this line
     is one more disclaimer, not a new block, and it was previously spaced
     4x further from the list above it than the list's own lines are from
     each other. */
  margin: 2px 0 0;
}
.promo-popup-recaptcha a { color: inherit; text-decoration: underline; }

/* ============================================================
   PHONE — 768px and below, round four
   ------------------------------------------------------------
   Everything here is inside a max-width query and cannot reach a
   desktop viewport. Grouped by what it changes rather than by page,
   because most of it is one rule applied to the three grids at once
   (catalog, lab results, home featured) which are deliberately
   identical objects at this width.
   ============================================================ */
@media (max-width: 768px) {

  /* ── The floating cart bar is gone ──
     A persistent bar pinned across the bottom of a phone costs a strip
     of every screen on the site and duplicates the cart icon already in
     the nav, which never scrolls away. display:none rather than
     removing it from js/nav.js: the same element is still wanted above
     this width, and the script that builds it has no width opinion. */
  .cart-float-bar { display: none !important; }

  /* ── Nav drawer, flat at the bottom ──
     The 1024px rule already squares off the TOP two corners so the
     drawer meets the bar cleanly; the bottom two were still the pill's
     18px, which read as the last row being a different shape from the
     rows above it. Flat all round here. */
  .nav-right {
    border-radius: 0;
  }

  /* ══ GRIDS — catalog, lab results, home featured ══
     One geometry for all three. The rules below are the whole of
     "product bottles keep one size and one position, frames keep one
     height, only the whitespace either side of the image resizes". */

  /* Edge inset matched to the SEARCH BAR's, which is the narrowest
     thing on the catalog page and the one the cards visibly disagreed
     with: the toolbar is full-bleed with --grid-edge (12px) of its own,
     while the cards sat inside .container--catalog's 24px on top of the
     grid's own 12px — 36px, three times the search field's. The
     negative margin cancels the container's padding so the grid can set
     the inset itself; width:auto releases the fixed track width the
     desktop rule pins. */
  .products-grid,
  .certs-grid,
  .featured-products {
    grid-template-columns: 1fr;
    gap: var(--grid-gap);
    margin-inline: calc(-1 * var(--pa-container-pad, 24px));
    width: auto;
    max-width: none;
    padding-inline: var(--grid-edge);
  }

  /* The frame's height stops tracking its width.
     .card-img-wrap is aspect-ratio:1/1 everywhere else, which is what
     made the image (and the whole card) grow and shrink with the
     window. Pinned instead to the height that ratio produces at the
     DESKTOP card width — the same calc the grid's own track uses — so
     the frame is exactly the size it is on desktop and stays there at
     every width in this tier. */
  .product-card .card-img-wrap,
  .cert-card .card-img-wrap {
    aspect-ratio: auto;
    height: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3);
  }

  /* The image itself is sized off that now-constant height (the base
     rule's height:88.587% resolves against this box), so it needs
     nothing further to stay constant. What DOES have to go is the
     figure's 27.5% side padding: a percentage of the card's width, it
     shrank the space available to the image on a narrow phone until
     max-width:100% clamped the image itself — the one thing that must
     not resize. Zero side padding hands the full width to the flex
     centring instead, which is what makes the whitespace either side of
     the bottle the only thing that changes as the window narrows. */
  .product-card .card-img-wrap figure,
  .cert-card .card-img-wrap figure {
    padding: 5px 0;
  }

  /* Same reasoning for the lab-results placeholder mark, which is sized
     in percentages of the wrap: the height half is now constant but the
     width half would still track the card. Both pinned to what 40% of
     the frame above resolves to. */
  .card-img-wrap .cert-symbol-placeholder {
    width: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.4);
    height: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.4);
  }

  /* ── All three grids: smaller bottle/symbol, tighter frame ──
     .products-grid (catalog) was deliberately excluded from this when
     it was first written for the other two, and now brought in to
     match — asked for outright, "the size of the product images ...
     the proportions of everything" the home page grid has, applied to
     the catalog too. 0.7 shrinks the pinned frame (and with it the
     image, which is sized in % of the frame) by about 30%; the
     figure's top/bottom padding drops from 5px to 1px on top of that
     (halved again from an intermediate 2px, asked for outright), so
     the whitespace above and below the bottle shrinks by more than
     the image itself does. The placeholder mark is re-derived from the
     same 0.7-scaled frame height so it keeps tracking it. */
  .products-grid .card-img-wrap,
  .featured-products .card-img-wrap,
  .certs-grid .card-img-wrap {
    height: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.7);
  }
  .products-grid .card-img-wrap figure,
  .featured-products .card-img-wrap figure,
  .certs-grid .card-img-wrap figure {
    padding: 1px 0;
  }
  .certs-grid .card-img-wrap .cert-symbol-placeholder {
    width: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.7 * 0.4);
    height: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.7 * 0.4);
  }
  /* The real symbol image (as opposed to the placeholder mark above,
     for a product with none published yet) is an ordinary
     .card-img-wrap img — the same element and base sizing a bottle
     photo uses (height:88.587% of the frame, object-fit:contain) — so
     shrinking it 20% specifically on this grid means overriding that
     height here: 88.587% × 0.8 = 70.87%. width stays auto and
     object-fit:contain still holds the aspect ratio, so the image
     shrinks uniformly in both dimensions rather than just getting
     shorter. Scoped to .certs-grid only — bottle photos on the other
     two grids are untouched, asked for outright ("on the lab results
     page too"). */
  .certs-grid .card-img-wrap img {
    height: 70.87%;
  }
  /* Doubles the grid's own edge inset (var(--grid-edge), 12px) on all
     three grids now, so the catalog sits exactly as far from the
     screen edge as the other two — it was the one left at the plain
     var(--grid-edge) inset. */
  .products-grid,
  .featured-products,
  .certs-grid {
    padding-inline: calc(var(--grid-edge) * 2);
  }

  /* Four cards, not eight — asked for outright. index.html renders all
     eight of FEATURED_COUNT regardless of width (it's a fixed JS
     constant, "two full rows" on the desktop 4-column grid); at one
     column here that would be eight stacked cards, so the back four are
     hidden rather than the count itself made width-aware. display:none
     takes them out of the accessibility tree too, same as every other
     phone-only trim on this page. */
  .featured-products .product-card:nth-child(n + 5) {
    display: none;
  }

  /* ── Page titles ──
     Was clamp(25.7px, 4.82vw, 68.5px), which bottoms out at its 25.7px
     floor across this whole tier — a section-heading size for what is
     the page's own name. A flat 44px instead, and no truncation: the
     title wraps and grows UPWARD out of its own box the way a product
     name does, rather than being cut. */
  .catalog-hero-title {
    font-size: 44px;
    line-height: 1.02;
    white-space: normal;
    overflow-wrap: anywhere;
  }

  /* ── The search/sort band matches the grid it sits above ──
     .page-toolbar-inner's padding-inline (var(--grid-edge), 12px) was
     solved for the grid's OLD inset — doubled here to the grid's new
     one (see .products-grid etc.'s own padding-inline above) so the
     search field's left edge and the sort field's right edge line up
     with the cards below, asked for outright ("the same width as the
     new grid item widths").

     .page-toolbar's own padding-block (space above the search field
     and below the sort field, since they stack in one column at this
     tier) is set to that same 24px — asked for outright, "equal to
     the spacing from the new left edge of the product grid item and
     the left edge of the screen." */
  .page-toolbar-inner {
    padding-inline: calc(var(--grid-edge) * 2);
  }
  .page-toolbar {
    /* 14px, matching .catalog-toolbar's own `gap` exactly — asked for
       outright: below 640px that toolbar is flex-direction:column, so
       the gap IS the space between the search field and the sort field,
       and this padding is the space above the first and below the
       second. They were 24px against the gap's 14px, so the band read
       as looser at its two ends than in its middle. The number is
       restated rather than shared through a custom property because the
       gap lives on the CHILD (.catalog-toolbar) and a parent cannot read
       a property its own child declares — if that gap ever changes, this
       has to change with it. */
    padding-block: 14px;
  }

  /* ── Sort control ──
     The label goes: "Sort by" and a box reading "Most Popular" say the
     same thing twice, and on one phone-width row that repetition costs
     the box its width. The box takes the whole row instead — it no
     longer needs to carry the wording itself either way, since
     js/catalog.js's "Sorting By " prefix (once applied to the selected
     option here) was removed outright, asked for. */
  .sort-field label { display: none; }
  .sort-field { width: 100%; }
  .sort-field .select-native { width: 100%; min-width: 0; }

  /* ── The peptide-suggestion frame ──
     The prompt above the button is a sentence explaining a button whose
     own label already says it. On a phone it is two lines of that. */
  .process-actions-prompt { display: none; }

  /* ── The Browse Products / Suggest a peptide frame, aligned ──
     .process-actions's own padding-inline (var(--grid-edge), the
     desktop rule) doesn't break out of its container the way the
     product grids now do — it just adds its 12px on top of the
     container's already-24px padding, landing the frame's border 36px
     from the screen edge against the grid's 24px. Zeroed here rather
     than doubled: this element isn't full-bleed like the grids are, so
     matching means REMOVING its own extra inset and letting the
     container's 24px alone govern it, not adding a second 24px on top
     of that. Asked for outright: "the same width and aligned with the
     product item border above it" — on both the home page (under
     .featured-products) and the catalog (under .products-grid), since
     both now share this same class. */
  .process-actions {
    padding-inline: 0;
  }

  /* ── Browse Products / Suggest a peptide, styled like Add to Cart ──
     .hero-btn here becomes .btn-cart's own look — solid fill, 36px
     tall, uppercase, letter-spaced — asked for outright, "styled
     exactly the same as add to cart." Vertical spacing from the frame
     is preserved rather than left to re-centre around the now-shorter
     button: the frame was 104px around a 60px button (22px clear
     above and below); at 36px tall, holding that same 22px means the
     frame itself has to come down to 80px (36 + 2 × 22), not stay at
     104px and let flex centring hand the button MORE clearance than it
     has right now. */
  .process-actions-frame {
    height: 80px;
  }
  /* --wide (the catalog's "Suggest a peptide" variant) carries its own
     min-height:104px and padding:20px 24px, sized for the prompt-beside-
     button layout that .process-actions-prompt's display:none (above)
     already emptied out — both would otherwise keep the frame at its
     old height regardless of the plain rule just above. Reset to the
     same 80px/0 the plain frame uses now that only the button is left
     inside it. */
  .process-actions-frame--wide {
    min-height: 80px;
    padding: 0;
  }
  .process-actions-frame .hero-btn,
  .process-actions-frame--wide .hero-btn {
    /* 100% (flush with the frame's own left/right edges) had 0px of
       horizontal clearance against the 22px the button already has
       above and below — asked for outright to make all four equal.
       calc(100% - 44px) takes 22px off each side; the frame's own
       flex centring (align-items:center; justify-content:center) does
       the rest, so the button ends up centred with 22px clear on
       every edge instead of touching two of the four. */
    width: calc(100% - 44px);
    height: 36px;
    min-height: 36px;
    padding: 0 16px;
    border: none;
    border-radius: var(--radius-md);
    background: var(--cta);
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }

  /* ── Offer popup, expanded: the whole screen ──
     A corner card is a desktop shape. The 1024px rule already spans it
     edge to edge with a 16px margin; at phone width even that reads as
     a panel floating on a page nobody can use behind it, so it becomes
     the page instead — full bleed, square, and scrolling internally if
     the copy runs past the viewport. */
  .promo-card[data-state="open"] {
    position: fixed;
    inset: 0;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    width: auto;
    height: auto;
    max-height: none;
    border-radius: 0;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    /* flex-end, not center — asked for outright, "the discount content
       at the bottom of the screen." The card's own padding (--promo-pad,
       the base rule) still keeps the content clear of the true screen
       edge; this only moves where the leftover vertical space collects
       — above the content now, instead of split evenly above and
       below it. */
    justify-content: flex-end;
  }
  /* The height the script pins for the minimise transition is measured
     against the corner card; full-screen it would clip the copy. */
  .promo-card[data-state="open"] .promo-card-full {
    height: auto;
  }

  /* "Earn 10% Off Your First Order" — 25% bigger than the base clamp,
     asked for outright, now that the popup fills the whole screen and
     has the room to carry a larger headline. Each bound scaled by the
     same 1.25 so the responsive curve keeps its shape. */
  .promo-popup-heading {
    font-size: clamp(35px, 5vw, 55px);
  }

  /* ── "No, thanks" becomes a real button ──
     A tappable line of underlined text reads fine sitting under a corner
     card; full-screen, beside a solid SEND ME MY CODE button, it read as
     an afterthought rather than the form's other real choice.

     Every property below is .promo-popup-submit's own, verbatim — the
     14px padding from that button's ≤1024px rule, and the type treatment
     (13px / 700 / 0.04em / uppercase), the radius, the missing border and
     the hover from its base rule — so the two are the SAME button at the
     same size, asked for outright. Only the two colours are swapped:
     black ground, white text, against the submit button's white-on-dark.

     width:100% is the one addition with no counterpart on the submit
     button, and it is what makes "the same size" true rather than
     approximate: the submit button is a flex item in a column-direction
     .promo-popup-form (the ≤1024px rule), so `align-items: stretch`
     already fills the row for it. This button is a SIBLING of that form,
     not a child, and a block-level <button> shrink-to-fits its own text
     rather than filling its container — so without this it came out
     exactly as wide as the words "I'm fine thanks" happened to be.

     margin-top matches the form's own `gap` (10px) exactly — the same
     vertical distance the email field and the submit button already sit
     apart, asked for outright. */
  .promo-popup-decline {
    /* Back on — see the note on the base rule for why it is off above
       this width. */
    display: block;
    margin-top: 10px;
    width: 100%;
    padding: 14px;
    border: none;
    border-radius: var(--radius-md);
    background: #000;
    color: #fff;
    text-align: center;
    text-decoration: none;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    transition: opacity var(--transition);
  }
  .promo-popup-decline:hover {
    color: #fff;
    text-decoration: none;
    opacity: 0.85;
  }
  .promo-decline-full { display: none; }
  .promo-decline-short { display: inline; }

  /* ── Account nav: Sign out moves into Overview ──
     The tab row's own Sign out (.account-nav .account-signout) is one
     of the "top selectable items" — hidden here so the row is just the
     four real sections. The counterpart under Store credit
     (js/account.js's overviewHtml) takes over in its place. */
  .account-nav .account-signout { display: none; }
  .account-overview-signout { display: block; }

  /* ── Contact info rows, left-aligned ──
     The desktop row is space-between: label pinned left, value flush
     against the row's own right edge. Email — the one plain-text value
     among the row (the others are inputs, which visually read as a
     column because .field-input fills the remaining flex space) — sits
     wherever its own text width happens to end, which almost never
     lines up with the inputs' left edge above and below it. Fixing the
     label column to one width and switching to flex-start puts every
     value's left edge at the same x, input or plain text alike. */
  .info-row { justify-content: flex-start; gap: 16px; }
  .info-label { min-width: 96px; flex-shrink: 0; }
  .info-value { text-align: left; }

  /* ── Add new address, as an accordion ──
     #addr-form is the panel that sits permanently open on desktop; here
     it starts closed and only .is-open (toggled by js/account.js's
     addr-toggle listener) shows it again. */
  .addr-toggle { display: flex; }
  #addr-form { display: none; }
  #addr-form.is-open { display: block; }

  /* ── Google sign-in preview, clear of the promo bar ──
     Desktop keeps the top-right corner placement (asked for outright,
     see the note on .gsi-preview) that deliberately overlaps both the
     standing-offer strip and the nav. On a phone the corner sat right
     under the promo bar's own text, close enough to read as one
     overlapping mess rather than two things stacked. Dropping the top
     offset below --promo-h clears the strip; the unchanged z-index
     (102, still above .nav's 100) is what keeps it layered over the nav
     bar rather than pushing content down to make room for it. */
  .gsi-preview { top: calc(var(--promo-h) + 12px); }

}

/* ── The pill's dismiss X ──
   Overlaid on the pill's leading end rather than nested inside it (a
   button inside a button is invalid markup — see the note in
   js/promo.js). z-index above the pill so the X takes its own taps; the
   pill's own click still fires everywhere else.

   TOP LEVEL, not inside the phone query above — which is where this
   first went, and the display:none default went with it, so above
   768px there was nothing hiding the button and a stray X was painted
   over the bottom-left corner of the EXPANDED card. The X belongs to
   the collapsed pill at every width, so both halves of that rule
   (hidden by default, shown by the [data-state="pill"] rule below) have
   to be unconditional. */
.promo-pill-x {
  position: absolute;
  z-index: 2;
  width: 30px;
  height: 30px;
  display: none;              /* shown only in the pill state, below */
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.32);
  color: #fff;
  cursor: pointer;
}

/* The X is wanted at EVERY width — the brief asks for it in the
   bottom-right corner pill as well as the rotated right-edge one — so
   its visibility rule sits outside the phone block above. Only its
   placement differs between the two, since the rotated pill's own
   transform turns "left" into "bottom". */
.promo-card[data-state="pill"] .promo-pill-x {
  display: flex;
}

/* ── The pill, put on a real formula ──
   Every number below is solved from the same equation, once per tier
   (the desktop label and the rotated one are different pixel widths):

     margin + X + gap + textInk + margin = pill length

   with margin fixed to what the DEPTH axis already gives the text for
   free — .promo-card-pill centres the label vertically inside the
   pill's own height with no CSS of its own needed for that half, so
   reusing that same number for the horizontal margin is what makes
   "space before X and after the text" equal to "space above and below
   the text", asked for outright. Solving for the pill's own LENGTH
   (rather than fitting numbers into the two lengths this already had)
   is what makes the result exact instead of approximate — 152px and
   120px were "stated rather than derived" to begin with (see the
   comment on the collapsed dimensions above), so there was nothing
   original to preserve by keeping them.

   Desktop — "GET 10% OFF" (see the uppercase note on .promo-pill-full)
   at 14px/600 measures 91.04px of ink; the pill's own 48px depth minus
   the label's 16px line box leaves 16px above and below it:
     margin 9 + X 30 + gap 8 + text 91.04 + margin 16 = 154.04 → 154px

   That leading 9, not 16, is not a free variable any more — the pill's
   left end is a true semicircle of radius 24 (half the 48px depth), and
   the X disc is itself a circle, 30px across (radius 15). Left at 16px
   its centre sat at 16 + 15 = 31px from the pill's own left edge, 7px
   off the curve's own centre at 24px — so the ring of dark background
   showing between the disc and the pill's edge was thicker at the top
   and bottom of the curve than at its leading point. Centring the disc
   ON the curve's centre (radius 24 − disc radius 15 = 9px of left
   margin) is what makes that ring one constant width all the way round
   the curve, rather than a lens shape that happens to touch it at one
   point. The trailing margin is untouched — it answers to the text's
   own vertical margin (16, see above), a different equality — so only
   the leading term and the total it feeds into move.

   Mobile — "10% OFF" at the rotated tier's 17px/800/uppercase measures
   82.27px; 48px depth minus its 19.5px line box leaves 14.25px, rounded
   to a flat 14 (a quarter of a pixel is not a rendering difference
   anyone will see):
     margin 14 + X 30 + gap 8 + text 82.27 + margin 14 = 148.27 → 148px

   The gap (X to label) is the SAME 8px both tiers — asked for
   outright ("the X needs to be closer… same distance as mobile") —
   which only holds because it is one of the terms SOLVED for here
   rather than left as a side effect of unrelated padding. */
.promo-card[data-state="pill"] {
  width: 154px;
}
.promo-card[data-state="pill"] .promo-pill-x {
  left: 9px;
  top: 50%;
  transform: translateY(-50%);
}
.promo-card[data-state="pill"] .promo-card-pill {
  padding-left: 47px;   /* margin 9 + X 30 + gap 8 */
  padding-right: 16px;
}

@media (max-width: 768px) {
  /* Rotated pill: the card is turned -90deg as a whole, so its own
     "left" now points at the floor. The X is placed against the card's
     left end all the same — after the rotation that is the BOTTOM of
     the tab as the reader sees it, which is the end nearest the corner
     the pill grew out of. Counter-rotated so the glyph itself stays
     upright rather than lying on its side with the label. */
  .promo-card[data-state="pill"] {
    /* Re-solved again, each term asked for outright in its own pass:
       the X-to-label gap cut to two thirds a second time (14 → 9.33 →
       6.22), and the trailing margin — OFF to the tab's far edge, which
       is what "the top edge" becomes once the -90deg rotation below
       turns this card's own RIGHT into the reader's TOP (see the note on
       the pill-x placement) — held at its +20% (14 × 1.2 = 16.8):
         margin 14 + X 30 + gap 6.22 + text 82.27 + margin 16.8 = 149.29px. */
    --pill-len: 149.29px;
    /* width restated explicitly, not left to the original mobile rule's
       own `width: var(--pill-len)` (further up this file) to carry it:
       that rule and the desktop `width: 161px` above are equal
       specificity, and the desktop one comes LATER in the file — so
       without this, it would win the width property outright at every
       width, including this one, regardless of which custom property
       the now-ignored var() was pointed at. */
    width: var(--pill-len);
  }
  .promo-card[data-state="pill"] .promo-pill-x {
    left: 14px;
    top: 50%;
    transform: translateY(-50%) rotate(90deg);
    /* Circle removed, asked for outright — the X sits directly on the
       pill's own fill now, nothing drawn behind it. The 30×30 box
       itself is kept as the tap target (still comfortably touch-sized)
       even though nothing marks its edges any more. */
    background: none;
  }
  /* Bold to match the label's own weight (17px/800 — see the pill-
     length formula's comment above) — a stroke, not a font-weight,
     since the X is drawn geometry (see the note on the SVG in
     js/promo.js), so "bold" means thickening the stroke rather than
     switching a font axis. 1.6 was sized to read clearly at 12×12
     against a filled dark circle; without that circle behind it the
     mark needs to carry its own visual weight, and 2.6 is what reads
     as bold at this size against the text beside it. */
  .promo-card[data-state="pill"] .promo-pill-x path {
    stroke-width: 2.6;
  }
  .promo-card[data-state="pill"] .promo-card-pill {
    /* margin 14 + X 30 + gap 6.22 = 50.22; trailing margin 16.8 — see the
       formula on --pill-len above for where both numbers come from. */
    padding-left: 50.22px;
    padding-right: 16.8px;
  }
}

/* ── The expanded card's minus, top-right ──
   Replaces the "No, thanks" line the desktop card used to carry under
   its form, asked for outright. Same job (dismiss() → the pill, the
   offer is paused, not spent), different reading: a minus in a corner is
   window chrome and says "put this away", where a line of text under the
   form reads as an answer to the offer.

   Deliberately the same 30px disc as the pill's × (.promo-pill-x above):
   the two are the same control at two moments — minimise the card, then
   dismiss the pill — and they occupy roughly the same region of the
   screen one after the other. Anything else would read as two unrelated
   buttons trading places.

   Absolutely positioned against .promo-card, which is the only element
   in this component with a stable box: pinned to the card's own corner,
   it cannot be pushed around by the copy rewrapping under it, and it
   contributes nothing to the height js measures to pin the open state. */
.promo-minimise {
  position: absolute;
  z-index: 2;
  top: 14px;
  right: 14px;
  width: 30px;
  height: 30px;
  display: none;              /* shown only above 768px, open, below */
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.32);
  color: #fff;
  cursor: pointer;
  transition: background var(--transition);
}
.promo-minimise:hover { background: rgba(0, 0, 0, 0.52); }
.promo-minimise:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }

/* Only while the card is OPEN, and only above the phone tier — the two
   halves of "desktop gets a minus, the phone gets its button". min-width
   769 rather than a not-max-768: the brief draws the line at 768
   INCLUSIVE on the phone side, and 769 is the next pixel up. */
@media (min-width: 769px) {
  .promo-card[data-state="open"] .promo-minimise { display: flex; }
}

/* ── Phone: a crossfade, not a morph ─────────────────────────
   Asked for outright — dismissing the full-screen sheet "should simply
   fade out onto the background page with no movement animation
   whatsoever or transformation into the pill version".

   Desktop's card and pill are one box changing size in one place, so
   morphing between them describes something true. On a phone they are a
   full-screen sheet and a tab on the right edge: a morph there is a
   screenful of artwork sliding and shrinking across the page to become
   something a tenth its size somewhere else, which is a great deal of
   movement to say "closed", and it makes the pill look like a squashed
   version of the sheet rather than the separate standing offer it is.

   Everything below exists to make the swap POSITIONLESS. The geometry
   transitions are replaced by an opacity one on the card, and the two
   faces' scale transforms are cancelled, so the only property that
   animates at this width is opacity. js/promo.js (swapState) then flips
   the state while the card is at zero opacity, so the geometry change
   happens on a frame with nothing visible on it.

   These sit at the end of the promo rules on purpose: several of the
   declarations they replace are attribute-plus-class selectors that
   would otherwise outrank a bare .promo-card here regardless of the
   media query, so each is restated at its own specificity and later in
   the file. */
@media (max-width: 768px) {
  .promo-card,
  .promo-card[data-state="open"],
  .promo-card[data-state="pill"] {
    transition: opacity 0.26s ease;
  }

  /* Both faces cross-fade in place. The base rules scale them about the
     card's bottom-right corner so the content converges with the frame —
     there is no converging frame here, so there is nothing for them to
     travel with. */
  .promo-card-full,
  .promo-card-pill {
    transition: opacity 0.2s ease;
  }
  .promo-card[data-state="open"] .promo-card-full,
  .promo-card[data-state="pill"] .promo-card-full,
  .promo-card[data-state="open"] .promo-card-pill,
  .promo-card[data-state="pill"] .promo-card-pill {
    transform: none;
  }
  /* Restated because the base rule pairs this transform with its own
     shorter transition, and dropping the transform alone would leave the
     0.12s opacity curve on one face and 0.2s on the other. */
  .promo-card[data-state="open"] .promo-card-full {
    transition: opacity 0.2s ease;
  }

  /* The fading frame itself. pointer-events:none as well as opacity:0 so
     the page underneath is live the instant the sheet starts to go —
     fading out "onto the background page" means the background page,
     not a transparent sheet still eating taps for a quarter second. */
  .promo-card.is-fading {
    opacity: 0;
    pointer-events: none;
  }

  /* The reduced-motion rule further up this file is overridden by
     everything above (later in the file, same or higher specificity), so
     it is restated here. js/promo.js already skips the fade entirely
     under this setting — crossfades() checks the same query — and this
     is the paint side of the same decision: the swap is instantaneous,
     which is what the setting asks for. */
  @media (prefers-reduced-motion: reduce) {
    .promo-card,
    .promo-card[data-state="open"],
    .promo-card[data-state="pill"],
    .promo-card-full,
    .promo-card-pill,
    .promo-card[data-state="open"] .promo-card-full {
      transition: none;
    }
  }
}

@media (max-width: 768px) {
  /* One container inset for the whole tier. The 640px rule already set
     24px; extending it up to 768 means the grids' own negative margin
     above has exactly one number to cancel rather than two (40px between
     641 and 768, 24px below it), which is what let that margin be a
     constant instead of a second media query. */
  .container { padding: 0 24px; }

  /* ── Product page bottle, smaller ──
     Below 1024px the frame is already a plain square that fills its
     column (see the max-width:1024px rules above) with 48px of padding
     on every side — nothing in that padding is direction-specific, so
     it's the space above/below the bottle as much as it is the space
     either side of it. A fixed, smaller box (was however wide the
     stacked single column happened to be) shrinks the bottle itself;
     less padding inside that box shrinks the band around it to match,
     rather than leaving the old 48px looking oversized next to the now-
     smaller photo. margin-inline:auto centres the smaller box, which
     doubles its distance to the screen edge as a direct consequence —
     centring a 200px box inside a ~342px column already clears twice
     the container's own 24px on each side without a separate rule.

     .pd-gallery itself still carries the 16:12 band from the
     max-width:1024px rule above, sized off the FULL column width — at
     200px square the frame no longer fills that band, so without this
     override the flex centring that already lives on .pd-gallery would
     leave empty space above and below the frame inside its own
     now-oversized band, on top of the smaller frame. Cancelling the
     ratio lets the band shrink to the frame's own height instead. */
  .pd-gallery { aspect-ratio: auto; }
  /* ── The frame is one product-grid card wide, and square ──
     Asked for outright: "the same amount of width a product grid item
     has on the products page". That width is not a number to copy — it
     falls out on its own. On the catalog at this tier .products-grid is
     one column, breaks out of .container's 24px padding with a matching
     negative margin, and puts 24px (var(--grid-edge) × 2) back as its
     own inset, so a card is exactly the viewport less 48px. The product
     page's .container carries that same 24px padding and .product-detail
     is a single column here, so width:100% on this frame resolves to
     precisely the same measure — no shared variable needed, and it
     stays matched if that container inset is ever changed.

     Square rather than the 2:3 band first specified: at 2:3 the frame
     came out 218px tall on a 375px phone, shorter than the bottle it
     has to hold (see .pd-gallery img below, ~264px), so the photo would
     have overflowed or been cropped by the frame's own overflow:hidden.
     Confirmed as square outright in answer to exactly that.

     aspect-ratio, not a height: the frame's width already tracks the
     viewport, so a fixed height would only be square at one width.
     The max-width/height/margin-inline this replaces were the old fixed
     200px box (see the note above about the smaller bottle), all of
     which have to go for width:100% to mean anything. */
  .pd-gallery-frame {
    width: 100%;
    max-width: none;
    height: auto;
    aspect-ratio: 1 / 1;
    margin-inline: 0;
    /* Enough room inside the square for the bottle below at its full
       height: 24px a side leaves 279px of content box on a 375px phone
       against the photo's ~264px, so the frame never clamps it via the
       max-height:100% the base .pd-gallery img rule carries. */
    padding: 24px;
  }

  /* ── The bottle: the catalog grid's, 20% bigger ──
     Asked for outright, "the same size as it is on the products page on
     mobile but 20 percent bigger". Every term is the grid's own, in the
     order the grid applies them, rather than the 263.76px they resolve
     to on any one phone — the whole point of the phone-tier grid rules
     is that this photo is a CONSTANT size at every width in the tier
     (see .products-grid .card-img-wrap above), so restating the same
     expression is what keeps the two in step if any of it moves:

       (1120px - 2 x --grid-edge - 2 x --grid-gap) / 3   the card frame's
                                                         desktop measure,
                                                         pinned as the
                                                         frame's HEIGHT
                                                         at this tier
       x 0.7        the phone tier's shrink of that frame
       - 2px        the figure's own 1px top+bottom padding, which the
                    percentage below resolves against (border-box)
       x 0.88587    .card-img-wrap img's height, in % of that figure
       x 1.2        the 20% asked for here

     width:auto with object-fit:contain (base rule) keeps the 1:2 crop's
     own ratio, so the photo grows in both dimensions rather than just
     getting taller. max-height:none releases the base rule's clamp to
     the frame — the padding above is sized so it would not bind anyway,
     but leaving a cap in place that is only just clear of the value it
     is capping is how this silently stops matching the grid later. */
  .pd-gallery img {
    height: calc(((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.7 - 2px) * 0.88587 * 1.2);
    width: auto;
    max-height: none;
    max-width: 100%;
  }

  /* ══ FREQUENTLY BOUGHT TOGETHER — a horizontal run of product cards ══
     Asked for outright: the four tiles become a horizontally scrolling
     list, each item drawn as the product card the catalog and the home
     grid use, at 70% so the next one peeks in from the right edge.

     The scroller breaks out to the true screen edges and puts the
     container's own 24px back as padding, the same full-bleed idiom
     .products-grid uses one tier up in this file. That is what keeps the
     FIRST card's left edge on the page's own left margin ("it remains
     left aligned at the beginning") while the run still carries on past
     the right edge rather than stopping inside it.

     gap × 0.7 for the same reason the cards are: the space between two
     cards is part of what is being scaled, and leaving it at the full
     --grid-gap would read as the cards having been shrunk and then
     spread out. */
  .fbt-grid {
    display: flex;
    grid-template-columns: none;
    gap: calc(var(--grid-gap, 12px) * 0.7);
    overflow-x: auto;
    overflow-y: hidden;
    -webkit-overflow-scrolling: touch;
    scroll-snap-type: x proximity;
    margin-inline: calc(-1 * var(--pa-container-pad, 24px));
    padding-inline: var(--pa-container-pad, 24px);
    /* Matched to the padding above, and load-bearing: a snap container's
       snapport defaults to its PADDING box, so snapping the first card's
       start edge to it meant scrolling the strip 24px to put that card
       flush against the true screen edge — the exact opposite of the
       "left aligned at the beginning" this is supposed to hold. Insetting
       the snapport by the same 24px makes the resting position the one
       the padding already describes. Same fix, same reason, as
       .op-panels' own scroll-padding-inline on the home page. */
    scroll-padding-inline: var(--pa-container-pad, 24px);
  }

  /* zoom, not transform: scale() — the whole card has to be 70% for
     LAYOUT as well as paint, so four of them occupy 70% of the room four
     full-size cards would and the scroll length is right. A transform
     leaves the element's box at full size, so the cards would be drawn
     small with full-size gaps between them and the strip would scroll
     past a third of empty space at the end. zoom is what .op-card
     already uses on the home page for the same reason.

     The width is the catalog grid item's own, unscaled — one column,
     full-bleed, 24px a side, i.e. 100vw − 48px (see .products-grid in
     this same block). Viewport units are not themselves rescaled by
     zoom, so this resolves to the real 327px on a 375px phone and the
     zoom then renders it at 228.9px: exactly 70% of a product card,
     which is what was asked rather than 70% of some other measure.

     aspect-ratio/height cancel the desktop tile's forced square — the
     card is as tall as a product card's own parts make it now, not a
     square with the caption fitted into it. */
  .fbt-card {
    zoom: 0.7;
    flex: 0 0 auto;
    width: calc(100vw - 2 * var(--pa-container-pad, 24px));
    aspect-ratio: auto;
    height: auto;
    scroll-snap-align: start;
    /* The card's own chrome, restated from .product-card so the two are
       the same object: same border, same radius, same ground, same clip
       of the photo's top corners. */
    background: var(--bg-primary);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    overflow: hidden;
    /* .card-body (the quantity stepper and Add to Cart) has no
       counterpart here — "there are no buttons though" — but its 20px of
       bottom padding is what stops a product card's price sitting on the
       card's own edge. 12px here plus .fbt-body's 8px margin-bottom
       below reproduces exactly that 20px. */
    padding-bottom: 12px;
  }

  /* The photo well, matched to .card-img-wrap's phone-tier geometry
     term for term: the pinned frame height (the desktop card measure ×
     0.7 — see .products-grid .card-img-wrap in this same block), the
     figure's 1px band, and the bottle's own 88.587% height and 2.096%
     downward nudge. Restating the expressions rather than the pixels is
     what keeps this card the same object as a grid card if any of them
     is retuned. */
  .fbt-img {
    position: relative;
    flex: 0 0 auto;
    width: 100%;
    height: calc((1120px - 2 * var(--grid-edge) - 2 * var(--grid-gap)) / 3 * 0.7);
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--bg-primary);
  }
  /* .card-img-wrap::after — the faint sheen down the top of the well. */
  .fbt-img::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, transparent 38%);
    pointer-events: none;
  }
  .fbt-img figure {
    position: relative;
    z-index: 1;
    padding: 1px 0;
    box-sizing: border-box;
  }
  .fbt-img img {
    width: auto;
    height: 88.587%;
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    transform: translateY(2.096%);
  }

  /* .card-top's inset plus .card-header's fixed 82px, bottom-aligned —
     the block a product card reserves for its name and price, so a
     one-line name and a two-line one both sit their price on the same
     baseline and four cards in a row line up. */
  .fbt-body {
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    height: 82px;
    padding: 0 20px 4px;
    margin-bottom: 8px;
  }
  /* .card-name, verbatim. */
  .fbt-name {
    font-family: 'Inter', sans-serif;
    font-size: 22px;
    font-weight: 700;
    letter-spacing: -0.44px;
    line-height: 1.1;
    text-transform: uppercase;
    margin-bottom: 1px;
    color: #000;
  }
  /* .card-meta, verbatim — size left, price right. The "From" prefix
     goes: a card that names one size should not hedge about which price
     it is quoting, and the grid card it is copying does not. */
  .fbt-meta {
    display: flex;
    justify-content: space-between;
    font-family: 'Helvetica', 'Helvetica Neue', var(--font-sans);
    font-size: 21.6px;
    font-weight: 700;
    letter-spacing: -0.01em;
    line-height: 1.2;
    color: #4d4f52;
  }
  .fbt-meta-size { display: inline; flex: 0 1 auto; min-width: 0; }
  .fbt-from { display: none; }
  /* Inherit, not restate: .fbt-price carries its own smaller desktop
     type, and every one of those properties has to give way to the
     .fbt-meta row above or the price would be set differently from the
     size beside it. */
  .fbt-price {
    flex: 0 0 auto;
    text-align: right;
    font: inherit;
    letter-spacing: inherit;
    color: inherit;
  }

  /* ══ LAB RESULTS — one column, two rows, then the rest on request ══
     One per row, so a certificate row is exactly as wide as the square
     product frame at the top of the page (both are the container's own
     full width at this tier) — asked for outright. */
  .pd-tests-list { grid-template-columns: 1fr; }
  /* The third row onward, until the button below is pressed. The number
     here and TESTS_PREVIEW in js/product.js (which decides whether the
     button is rendered at all) are the same figure written twice — they
     have to move together. */
  .pd-tests-list:not(.is-expanded) .pd-test:nth-child(n + 3) { display: none; }
  .pd-tests-more {
    display: block;
    width: 100%;
    margin-top: var(--grid-gap, 12px);
    padding: 13px 16px;
    /* --border-soft, not --border: the same border a .pd-test row draws
       (see its own rule), so the button reads as the last item in the
       list it belongs to rather than as a heavier control sitting under
       it. Radius matches for the same reason. */
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-md);
    background: var(--bg-primary);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 14px;
    font-weight: 700;
    cursor: pointer;
    transition: border-color var(--transition), background var(--transition);
  }
  .pd-tests-more:hover {
    border-color: var(--border-accent);
    background: var(--bg-secondary);
  }

  /* ── The certificate row, retold ──
     The size line goes (asked for outright — .pd-test-top is the row that
     carried it, and it holds nothing else), "Tap to expand" is added
     under the date, and what is left is centred against the thumbnail
     rather than sitting at the top of the row.

     justify-content:center on the text column is what does that
     centring: the ≤1024px rule already sets align-items:center on the
     row, which centres the column's BOX against the shot, but the column
     is a flex column stretched to the row's height and its own content
     was still packed to the top of it. */
  .pd-test-top { display: none; }
  .pd-test-text { justify-content: center; }
  .pd-test-cta {
    display: block;
    margin-top: 2px;
    font-size: 11.5px;
    font-weight: 700;
    color: var(--text-secondary);
  }

  /* ── NEW, on anything tested in the last three months ──
     Asked for outright; isRecentTest() in js/product.js decides which
     rows get one. Pinned to the row's top-right corner rather than
     placed in the text column: the column is vertically centred now, so
     a flag inside it would ride up and down with however many lines of
     component list a certificate happens to have. The corner is the one
     place in the frame that is the same on every row.

     The row is already position:relative-free — .pd-test is a flex
     container with no positioning of its own — so it gets it here. */
  .pd-test { position: relative; }
  .pd-test-new {
    display: inline-block;
    position: absolute;
    top: 10px;
    right: 10px;
    padding: 3px 7px;
    border-radius: 999px;
    background: #15803d;
    color: #fff;
    font-size: 9.5px;
    font-weight: 800;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    line-height: 1.2;
  }

  /* ══ LAB RESULTS — one column, two rows, then the rest on request ══
     One per row, so a certificate row is exactly as wide as the square
     product frame at the top of the page (both are the container's own
     full width at this tier) — asked for outright. */
  .pd-tests-list { grid-template-columns: 1fr; }
  /* The third row onward, until the button below is pressed. The number
     here and TESTS_PREVIEW in js/product.js (which decides whether the
     button is rendered at all) are the same figure written twice — they
     have to move together. */
  .pd-tests-list:not(.is-expanded) .pd-test:nth-child(n + 3) { display: none; }
  .pd-tests-more {
    display: block;
    width: 100%;
    margin-top: var(--grid-gap, 12px);
    padding: 13px 16px;
    /* --border-soft, not --border: the same border a .pd-test row draws
       (see its own rule), so the button reads as the last item in the
       list it belongs to rather than as a heavier control sitting under
       it. Radius matches for the same reason. */
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-md);
    background: var(--bg-primary);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 14px;
    font-weight: 700;
    cursor: pointer;
    transition: border-color var(--transition), background var(--transition);
  }
  .pd-tests-more:hover {
    border-color: var(--border-accent);
    background: var(--bg-secondary);
  }

  /* ── The certificate row, retold ──
     The size line goes (asked for outright — .pd-test-top is the row that
     carried it, and it holds nothing else), "Tap to expand" is added
     under the date, and what is left is centred against the thumbnail
     rather than sitting at the top of the row.

     justify-content:center on the text column is what does that
     centring: the ≤1024px rule already sets align-items:center on the
     row, which centres the column's BOX against the shot, but the column
     is a flex column stretched to the row's height and its own content
     was still packed to the top of it. */
  .pd-test-top { display: none; }
  .pd-test-text { justify-content: center; }
  .pd-test-cta {
    display: block;
    margin-top: 2px;
    font-size: 11.5px;
    font-weight: 700;
    color: var(--text-secondary);
  }

  /* ── NEW, on anything tested in the last three months ──
     Asked for outright; isRecentTest() in js/product.js decides which
     rows get one. Pinned to the row's top-right corner rather than
     placed in the text column: the column is vertically centred now, so
     a flag inside it would ride up and down with however many lines of
     component list a certificate happens to have. The corner is the one
     place in the frame that is the same on every row.

     The row is already position:relative-free — .pd-test is a flex
     container with no positioning of its own — so it gets it here. */
  .pd-test { position: relative; }
  .pd-test-new {
    display: inline-block;
    position: absolute;
    top: 10px;
    right: 10px;
    padding: 3px 7px;
    border-radius: 999px;
    background: #15803d;
    color: #fff;
    font-size: 9.5px;
    font-weight: 800;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    line-height: 1.2;
  }

  /* ── Compound details: values on one left edge ──
     Was label-left / value-right with the two pushed apart
     (justify-content: space-between), so a one-word value and a
     forty-character sequence started at completely different x
     positions and the block read as ragged. A two-track grid instead:
     the values all begin on the same line, left-aligned, and read down
     as a column.

     42% of the 327px this column is on a 375px phone puts that line
     ~26px to the LEFT of the screen's centre — "slightly left of the
     center of the screen", asked for outright. A percentage rather than
     a pixel figure so it holds the same relationship at every width in
     the tier. */
  .pd-prop {
    display: grid;
    grid-template-columns: 42% 1fr;
    gap: 0;
  }
  .pd-prop-value { text-align: left; }
  /* The rules that close the block top and bottom, removed — asked for
     outright. Only those two: the lines BETWEEN rows are what separate
     one fact from the next and they stay. */
  .pd-props { border-top: none; }
  .pd-prop:last-child { border-bottom: none; }

  /* ── Compound details, moved below the lab results ──
     The move itself is js/product.js's (CSS `order` cannot reorder
     across two different parents — see the comment there). What is left
     for CSS is its spacing in that new home: down here it is one of the
     top-level sections, so it takes the same gap above them all, and it
     gives up the 28px every .pd-block carries below itself — which
     would otherwise sit between it and the reviews on top of their own
     gap. Neither applies on desktop, where the block is an ordinary
     member of the info column. */
  .pd-block--props {
    margin-top: clamp(40px, 4.5vw, 64px);
    margin-bottom: 0;
  }

  /* ── The order process, as swipeable cards ──
     The three-tab slider goes. Six subsections, one per screen, moved
     through by swiping — which is the gesture a phone already has, and
     needs no control of its own taking up a row above the content.

     display:contents on the panels is what makes six cards out of three
     panels: it removes the panel's own box from the layout so its two
     .op-step children become direct flex items of .op-panels, giving one
     track of six rather than three tracks of two. It also applies to the
     panels the tab script has marked [hidden] — deliberately: at this
     width there is no selected tab, every step is simply on the strip. */
  .op-slider { display: none; }

  .op-panels {
    display: flex;
    /* Releases the fixed height the script measures for the tab
       cross-fade (--op-panels-h), which is the tallest single panel and
       has no meaning once every step is its own card. */
    height: auto;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    gap: 16px;
    /* Vertical padding so the front card's shadow is not clipped by the
       strip's own overflow, which has to stay hidden on the Y axis to
       keep this a horizontal scroller. */
    padding-block: 10px;
    /* Stretch, not start: a card needs a real, bounded height for its
       own overflow-y:auto (below) to ever actually trigger. start
       instead gave every card its natural content height with nothing
       to overflow against, so a card taller than the space this strip
       actually has just had its bottom silently clipped by this
       element's own overflow-y:hidden one property up — the strip was
       doing the clipping .op-step was supposed to be doing itself. A
       short card now gets empty space below its content instead, which
       reads far better than a cut-off table. */
    align-items: stretch;
    /* True full-bleed, THEN a matching percentage padding put back —
       not to restore the old inset, but to open peek room on both
       sides of a centred card. That is the actual fix here: a card
       used to be flex:0 0 100%, exactly the strip's own width, so
       there was nowhere for a neighbour to be partly visible and
       nothing on screen said "more of this scrolls" — every screen
       just looked like a single static page. At 84% a sliver of the
       card on each side is unavoidably in view before a visitor
       touches anything, which is the whole ask.

       -69.66px, not calc(-1 * var(--pa-container-pad, 24px)): that
       formula assumed this strip sits directly inside a plain 24px-
       padded .container, the same as .products-grid/.certs-grid/
       .featured-products (whose own copy of this exact comment it was
       adapted from) — but this strip's real parent is .op-card, whose
       own padding is a DIFFERENT variable (--op-card-pad, 22px here)
       AND which carries zoom:0.9. -24px under-cancelled both of those
       at once, leaving the strip's edges about 41px short of the true
       viewport edge on both sides — enough that neither the "current
       card centred" nor the "neighbour peeking at the very edge" part
       of this design actually held. -69.66px is the number that
       measurably lands both edges exactly on the viewport's own (0 and
       100vw), checked at 320px and 390px alike — a derived formula
       would need to know exactly how zoom rescales an ancestor's own
       margin, which isn't documented behaviour to build on, so this is
       measured against the real rendered box instead, the same way
       every other zoom-adjacent number on this page has been.

       scroll-padding-inline matches, so the FIRST and LAST cards still
       snap centred with the same peek either side as every card in the
       middle, rather than snapping flush to the strip's own edge.

       var(--op-peek-pad), not a flat 8%: a flat percentage only equals
       the true peek space when the card's own width is ALSO a fixed
       percentage of the strip, which is what 84% + 8% + 8% summing to
       100% actually encoded — the whole thing was solved for that one
       card width. .op-step is a fixed px width now (see its own
       flex-basis, below), unrelated to any percentage of the strip, so
       8% stopped being the right number the moment that changed: 8%
       (~31px) was noticeably less than the ~75px this card width
       actually needs on each side, which is exactly the gap that left
       the browser unable to scroll far enough to ever centre the FIRST
       or LAST card — it hit the start/end of the scrollable range
       first and stopped short, off-centre by roughly that difference.

       --op-peek-pad is published by the same script that sets
       --op-heading-w (see index.html), measured directly against the
       real rendered page rather than expressed as a calc() here: a
       calc(100% - var(--op-heading-w)) version of this was tried first
       and measured WRONG once this element's own -69.66px margin-inline
       and .op-card's zoom:0.9 were both actually in play — "100%" was
       not resolving against the width this element's own containing
       block reports on its own. Measuring the real box directly sidesteps
       needing to know why. */
    margin-inline: -69.66px;
    padding-inline: var(--op-peek-pad, 8%);
    scroll-padding-inline: var(--op-peek-pad, 8%);
  }
  .op-panel,
  .op-panel[hidden] {
    display: contents;
  }
  /* ── Each step is a card you move THROUGH ──
     A bordered card, same as any other on the site — no scale and no
     fade on a card that isn't centred, asked for outright: a neighbour
     peeking in from the side should read as a normal card sitting to
     that side, not a smaller, faded preview of one. Swiping is what
     brings it into focus and sends the current card off the opposite
     side to peek in turn; the strip's own scroll handles that motion,
     nothing here needs to fake it with a transform.

     --op-prox is still written per card by the script at the bottom of
     index.html — 1 when the card is centred, falling to 0 a full
     card-width away — but only the shadow still reads it: the front
     card casts a real lifted shadow, and the rest flatten toward the
     slightest shadow a plain card resting on the page would have,
     rather than dropping to nothing. The value is declared here as
     well so a card still looks right before the first scroll event
     (and if the script never runs at all, every card simply renders as
     the front one). */
  /* ── "STEP 1" above each card's title ──
     The same eyebrow the tab selector puts above a tab's own label above
     this breakpoint, restated per card now that the selector is gone —
     asked for outright, "styled exactly how STEP [X] is in the tab
     selector". Every value below is copied from .op-seg-step (font-size,
     weight, tracking, uppercase, opacity) plus the colour .op-seg
     resolves to for the SELECTED segment (--text-primary, not the
     --text-muted an unselected one gets): every card down here is a card
     you are actually looking at, so the "current" treatment is the one
     that matches.

     text-transform is what turns the markup's "Step 1" into STEP 1 —
     same as the slider, which is also written in title case and set in
     caps by this same property.

     ── The two gaps ──
     Above it: nothing to set. This is now .op-step's FIRST child, so its
     own top edge sits exactly where .op-step-title's used to — .op-step's
     30px padding-top (below) and no margin of its own. That is the
     "spaced the same way from the top the current title is" half.

     Below it: .op-seg is a flex column with gap:3px between the step
     number and the tab's label, and .op-step is a flex column with
     gap:clamp(6px, 1vh, 10px) between all of ITS children. Flex gap
     cannot be overridden for one pair, so the difference is taken back
     as a negative margin: 3px minus whatever that clamp resolves to
     leaves exactly 3px of real space between this and the title,
     matching the slider, without disturbing the gap between the title
     and the body copy under it. */
  .op-step-num {
    display: block;
    font-size: 11.5px;
    font-weight: 700;
    letter-spacing: 0.13em;
    text-transform: uppercase;
    opacity: 0.7;
    color: var(--text-primary);
    margin-bottom: calc(3px - clamp(6px, 1lvh, 10px));
  }

  /* ── More air under the card's own title ──
     1.5x the space between .op-step-title and the body copy under it,
     asked for outright. That space is .op-step's flex `gap`
     (clamp(6px, 1vh, 10px) — 8.12px on a 812px-tall phone), and flex gap
     applies to every pair of children at once: raising it here would
     also push the body copy off the table below it, and the table off
     its notes. So the extra half is added as a margin on the title
     alone, the same device .op-step-num above uses to go the other way.

     × 0.5, giving gap + gap/2 = 1.5 × gap, and written as the same
     clamp() the gap itself is rather than the 4.06px it resolves to at
     one viewport height — the two have to move together, since this is
     specified as a multiple of that gap and not as a fixed distance.

     The title is followed by .op-step-body on all six cards (checked),
     so this only ever opens the one gap that was asked about. */
  .op-step-title {
    margin-bottom: calc(clamp(6px, 1lvh, 10px) * 0.5);
  }

  .op-step {
    --op-prox: 1;
    /* A fixed width, not a percentage of the strip — the rendered width
       of the words "Your Order Process", published as --op-heading-w on
       .op-card by the script at the bottom of index.html (see the
       comment there), times 1.1. Matching the heading exactly was the
       original request; the 10% was a later adjustment on top of it.
       84% is kept as the fallback for the moment before that script
       runs.

       The peek padding that centres the FIRST and LAST card (see
       .op-panels' --op-peek-pad below) is computed against this same
       ×1.1 width in index.html — the two have to move together, or
       the centring this fixes would go stale again the same way the
       84%→fixed-width change broke it the first time. */
    flex: 0 0 calc(var(--op-heading-w, 84%) * 1.1);
    scroll-snap-align: center;
    min-width: 0;
    box-sizing: border-box;
    /* Left/right are 1.5x the base 18px ("spacing of the content... and
       the left and right edges"), asked for outright and unchanged.

       The VERTICAL pair is 20px lighter than it was (top 30→20, bottom
       20→10), which takes exactly that much off every card in the strip.
       The figure is not arbitrary: it is the 19.56px the second transit
       disclaimer occupied before it was merged into the first (16.88px
       of line plus the list's own 3px row gap, measured), rounded to 20.

       Why padding rather than leaving the removal to do it: the strip is
       align-items:stretch, so every card is as tall as the TALLEST, and
       "Packing + Dispatch" was only tied for that job. With its extra
       line gone it dropped to 417px — exactly level with "Browse Our
       Catalog", which then pinned the strip at the same height. The
       space the disclaimer freed was real but invisible, spent holding
       the row level with a different card. Taking it off the padding
       applies it to all six at once, which is what actually makes the
       cards smaller. */
    padding: 20px 27px 10px;
    background: var(--bg-primary);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    box-shadow: 0 calc(2px + 2px * var(--op-prox)) calc(6px + 6px * var(--op-prox))
                rgba(10, 10, 12, calc(0.03 + 0.05 * var(--op-prox)));
    /* Short, and on the one property that actually still changes now
       (shadow) — this rides a scroll, so anything longer reads as lag
       rather than as easing. */
    transition: box-shadow 0.18s ease-out;
    /* visible, NOT auto — this used to be the card's own scrollbar, and
       it is the thing being removed: on a real phone the longest card
       (Packing + Dispatch) did not fit the one-screen budget the section
       was held to, so its content scrolled INSIDE the card while the
       page scrolled around it. A card is a fixed thing you swipe past,
       not a second scroller nested in the first. Every card now takes
       the height its own content needs (see .op-card's max-height and
       .op-panels' align-items, both below/above) and the section is as
       tall as the tallest card rather than as tall as one screen. */
    overflow-y: visible;
  }

  /* transform/opacity dropped: .op-step no longer sets either, so
     there's nothing left for reduced motion to cancel there — only
     the shadow's own transition, which still ties to scroll position
     and is worth freezing for the same reason every other
     scroll-driven transition on this page is. */
  @media (prefers-reduced-motion: reduce) {
    .op-step { transition: none; }
  }
  /* The divider the 640px rule draws between stacked steps is a
     same-panel device; side by side as cards there is nothing to
     divide. margin-top and border-top only — NOT padding-top: every
     .op-step now carries its own uniform 20px top padding as a card
     (see .op-step above), and this selector matches the second step of
     each original pair (Pay It Your Way, Packing + Dispatch, Arrival +
     Aftercare). Zeroing padding-top here wiped that base padding out
     from under exactly those three, so every second card in the strip
     had its title sitting flush against the top edge instead of clear
     of it like the other three. */
  .op-panel .op-step + .op-step {
    margin-top: 0;
    border-top: none;
    /* Restated at the card's own 30px (1.5x the original 20px, moving
       with it above) rather than left to fall through to the 640px
       rule's 22px, which was sized for a divider (see above) and has
       no reason left to govern this once the divider is gone —
       matching it here is what keeps every second card's title exactly
       as clear of the top edge as the other three, rather than leaving
       it a fixed 8px short. */
    padding-top: 30px;
  }

  /* Every .op-link CTA (Browse the catalog, See the lab results,
     Shipping & delivery, Returns & refunds, Leave a review) was
     removed here, then centred as a button before that — both undone.
     Back to plain text links, same underlined look as every width
     above this tier, asked for outright: "bring back the text CTAs".

     Leave a review specifically is removed again below, asked for
     outright a second time — the other four stay as plain text links. */
  .op-link.op-link--spaced { display: none; }

  /* Browse Our Catalog and Arrival + Aftercare, trimmed — asked for
     outright, "keep the essential information, just make more
     efficient". Same full/short swap every other phone-tier copy
     reduction on this page uses. */
  .op-body-full { display: none; }
  .op-body-short { display: inline; }

  /* ── The section lands right at one screen of scroll ──
     .op-bleed's own padding-top (base rule, --op-gap) is a DESKTOP
     figure — ~0.149 of the visible height above the fixed chrome,
     tuned to centre this band in roughly two screens' worth of space.
     .hero is min-height:100vh, so without an override here that gap
     stacks entirely AFTER one full screen of scroll: on a phone this
     read as a blank gap before the section, not "the next thing
     scrolling reveals" the way .research-banner--fold and the
     featured-products grid already are. Zeroed so this band's own top
     edge sits right at the one-viewport mark instead, asked for
     outright. padding-bottom is untouched — nothing here was asked
     about the space below it. */
  .op-bleed {
    padding-top: 0;
    /* ── And half the space BELOW, to "Most Requested Compounds" ──
       Asked for outright. The visible gap is not this padding on its own:
       .research-banner--capped adds 24px of its own padding-top under it,
       and its heading is absolutely positioned with bottom:0 inside a box
       exactly ONE line tall (.research-banner-inner, height 32.8px × 1.02
       — see the rules further down), so a two-line title spills its first
       line 33.456px back UP into this gap. Measured top of the title to
       bottom of the order-process card, the real space is therefore
       --op-gap + 24px - 33.456px, i.e. --op-gap - 9.456px.

       Halving THAT is what was asked, not halving this declaration: the
       padding that leaves is --op-gap minus half the real gap, which
       rearranges to (--op-gap + 9.456px) / 2.

       ── Resolved to a constant here, deliberately ──
       --op-gap is a fraction of (100vh − the fixed chrome), so this
       padding used to change with the viewport's HEIGHT: measured, it
       moved from 48.6px at 700px tall to 59.9px at 852px. That made the
       whole document below this band a second height-dependent term on
       top of the hero's own svh floor, so any change in viewport height
       re-laid-out everything under it — which on a phone is what
       "the Your Order Process title moves down as I scroll, pushing the
       content below it down too" actually is.

       A phone has no use for the tracking anyway. The desktop rule this
       overrides is centring a band in roughly two screens of space;
       down here the section starts at the one-viewport mark (padding-top
       is 0, just above) and this is simply the gap to the next heading.
       57px is what the old expression resolved to at 812px tall — the
       height it was originally tuned against — so the spacing is
       unchanged on the phone it was designed for and merely stops
       drifting on every other one. */
    padding-bottom: 57px;
  }

  /* One screen for the whole thing — heading, description and the card
     strip — so it can be centred on a phone and be complete. The strip
     takes whatever the head does not, which is what makes this hold
     regardless of how long the heading wraps. */
  .op-card {
    display: flex;
    flex-direction: column;
    /* Was 100dvh. The cap is gone, asked for outright: it is what forced
       .op-step to carry its own scrollbar, and on a phone whose screen
       was shorter than the longest card's content ("Packing + Dispatch")
       the result was a card frame with a scrolling region inside it.
       Nothing else here depended on the cap — the flex:1/min-height:0
       chain below (.op-content, .op-panels) simply resolves against the
       card's own content height instead of against one screen, and
       .op-panels' align-items:stretch still gives every card in the
       strip the same height as the tallest one, so the strip stays a row
       of equal cards rather than becoming a ragged one. */
    max-height: none;
    /* No overflow property here any more — it was the actual cause of
       the edge cards reading as cut off. .op-panels breaks out past
       .op-card's own padding to reach the true screen edges (see its
       margin-inline above), but overflow:hidden clips at .op-card's OWN
       box regardless of what escapes it, chopping the peeking neighbour
       off right at the old, narrower boundary. Setting only
       overflow-y:hidden doesn't dodge this either — per spec, an axis
       left at the visible default while the OTHER axis is anything
       else computes to auto, not visible, so the X axis would still
       clip (just with a phantom scrollbar instead of a border to blame
       it on).

       max-height:100dvh alone still does the real work of keeping this
       section to one screen: everything under it is a flex:1;
       min-height:0 chain (.op-content, .op-panels) that shrinks to
       whatever room max-height leaves rather than pushing the card past
       it. The vertical safety net that "overflow:hidden" was standing
       in for still exists one level down — .op-panels keeps its own
       overflow-y:hidden — so nothing here was actually relying on THIS
       element clipping vertically once that's accounted for. */
  }
  /* margin-top: 0 cancels the base rule's clamp(30px, 3.9lvh, 48px),
     which is sized for the desktop layout where .op-content follows
     .op-card-head directly and that margin is the only thing setting
     the gap under the slider.

     It was zeroed here because the journey indicator sat between the
     two at this tier and supplied the gap itself. That indicator is
     gone, so .op-card-head's own margin-bottom is what separates the
     heading from the strip now — one gap from one place, which is why
     this stays at 0 rather than going back to the clamp and stacking a
     second one on top of it. */
  .op-content { flex: 1; min-height: 0; display: flex; margin-top: 0; }
  .op-panels { flex: 1; min-height: 0; }


  /* ── The disclaimer above "Most Popular", removed ──
     Asked for outright. display:none on the whole line rather than just
     its two spans — nothing else in .research-banner-inner needs it to
     stay in flow once it's not rendering anything. */
  .research-banner--capped .research-banner-text { display: none; }

  /* ── "Most Popular" → "Most Requested Compounds" ──
     The short form existed because the full sentence didn't fit a
     phone at the size the title wants to be — asked for outright to
     show the full title here anyway. */
  .mega-full { display: inline; }
  .mega-short { display: none; }

  /* Flat 32.8px — .op-heading's own flat size across this whole tier
     ("Your Order Process"), asked for outright: "the font size of
     ... Your Order Process at all sizes in this range." Replaces the
     ≤640px rule's min(13.5vw, 62px) (up to 62px, nearly double this)
     for every width down here, not just 641–768. line-height restated
     too: the ≤640px rule's own 1.02 stopped applying above 640px once
     font-size no longer came from that rule either, and the height
     calc below assumes this exact value holds everywhere in ≤768. */
  .research-banner--capped .process-mega-heading {
    font-size: 32.8px;
    line-height: 1.02;
  }

  /* Centred, reversing the left-alignment this carried — asked for
     outright, and only at this tier: the base rule's left alignment
     still governs every width above it. margin-inline:auto is what
     actually centres the block (a max-width:92% box), text-align:center
     what centres the two lines inside it; the base rule's own
     margin-inline is auto already, but it is restated here because the
     rule this replaces had pinned it to 0.

     The wrap is untouched by this: the box keeps the same width it had
     (see the absolute left/right further down, both zeroed to the same
     value so centring is symmetric), and at 32.8px "Most Requested
     Compounds" still breaks after "Requested" on every width in this
     tier — the two-line shape and the type size are explicitly not part
     of what changes here. */
  .research-banner--capped .process-mega-heading {
    text-align: center;
    /* margin-inline stays 0 (the base rule's own value, restated here
       because the rule this replaces had set it explicitly for the
       left-aligned version). An auto margin cannot centre this element:
       it is absolutely positioned with both left and right set below, so
       the box's own position is already fully determined and an auto
       margin resolves against an over-constrained equation rather than
       centring anything. Equal left/right insets are what centre the
       BOX; text-align:center is what centres the two lines inside it. */
    margin-inline: 0;
  }

  /* ── Growing to two lines spills UPWARD, not down ──
     The disclaimer above is gone, so .process-mega-heading is the only
     thing left in .research-banner-inner — normal flow would still let
     a second line push everything below it (the product grid) down by
     exactly one more line's height, which is the thing being avoided:
     "without pushing content around above it or below it."

     .research-banner-inner is pinned to exactly one line's own height
     (the flat 32.8px above × 1.02) and given a positioning context;
     the heading is taken out of flow and bottom-anchored inside it. A
     single line fills that box exactly as before. A second line
     extends the heading upward past the box's own top edge instead of
     the box growing to fit it — nothing downstream of
     .research-banner-inner ever sees a size change, and nothing above
     it moves either, since position:absolute took the heading out of
     the flow that would have pushed on it. There is room for that
     overflow because .research-banner--capped's own padding-top
     (inherited from the shared .research-banner rule) sits directly
     above this box, unclaimed by anything else.

     overflow:visible on .research-banner--capped is the other half of
     that: the shared .research-banner base rule sets overflow-x:auto
     for the FOLD disclaimer's own benefit (a sentence that doesn't
     wrap, elsewhere), and per spec a non-visible overflow-x forces
     overflow-y to compute to auto too even though nothing set it —
     auto establishes a scroll/clip container on BOTH axes, which is
     what was clipping the heading's own upward spill (the screenshot:
     the second line's ascenders sliced clean off). --capped's own text
     always wraps rather than needing horizontal scroll protection, so
     it doesn't need what the fold variant does, and overriding back to
     visible here is what actually lets the overflow show. */
  .research-banner--capped {
    overflow: visible;
  }
  .research-banner-inner {
    position: relative;
    height: calc(32.8px * 1.02);
  }
  .research-banner--capped .process-mega-heading {
    position: absolute;
    /* Equal on both sides now that the title is centred (above). It was
       left:24px / right:0 — matching the product grid's own left edge
       (--grid-edge × 2) for a LEFT-aligned title, which is exactly the
       alignment that has just been reversed. Left in place it would
       offset the centre line by 12px, so the two lines would sit
       centred on something that is neither the grid nor the screen.
       24px on both sides keeps the grid's own inset as the box's
       measure while making the centring symmetric.

       width:auto is what lets those two insets actually decide the box.
       The base rule pins width:100%, which with both left and right set
       over-constrains the box — the browser then keeps `left`, ignores
       `right`, and the box runs 18px past where the right inset asked it
       to stop, putting its centre line 9px right of the screen's. Auto
       solves for the width instead, so the box is exactly the 327px
       between the two insets and its centre is the screen's. */
    left: 24px;
    right: 24px;
    width: auto;
    bottom: 0;
  }

  /* ── The footer is exactly one screen tall ──
     Asked for outright, along with the two things that make it possible:
     a smaller wordmark, and the black band ABOVE that wordmark as the
     thing that absorbs whatever is left over on a given device.

     min-height, not height: the footer's own contents (four link
     columns, the legal block) have a natural height this cannot go
     below, and a hard `height` would simply clip them — .footer already
     carries overflow:hidden — on a screen too short to hold them. On any
     screen with room to spare the two are the same thing.

     box-sizing so the 24px/32px of padding below counts INSIDE the one
     screen rather than adding a second band under it. */
  .footer {
    min-height: 100svh;
    box-sizing: border-box;
    /* Down from the 48px the ≤1024px rule sets. This is only the FLOOR
       for the space above the wordmark — the real gap is whatever
       .footer-brand-row's auto margin below collects, which is the
       "play with the black spacing above the PURE AMINO logo for any
       given device" half of the ask. On a tall phone that lands around
       80px; on one with no slack at all it falls back to this. */
    padding-top: 24px;
  }
  /* The one flexible thing in the column. .footer is display:flex;
     flex-direction:column, so an auto margin on its FIRST child claims
     every pixel of leftover height at once and puts it all above the
     logo — the whole group is pushed to the bottom of the one-screen
     box and the slack shows as black space at the top, which is exactly
     what was asked for. With no slack it resolves to 0 and nothing
     moves. */
  .footer-brand-row { margin-top: auto; }
  /* 180px, down from 300px. The wordmark is the one element here whose
     size is free — the link columns and the legal text are all copy —
     so shrinking it is what buys the headroom that makes the one-screen
     fit reachable on a phone rather than only on a tall desktop window.
     Height follows on its own: .footer-brand-img is width:100%;
     height:auto, so the mark scales whole. */
  .footer-brand { max-width: 180px; }

  /* ══ The wordmark, on the page's own left edge ══
     Asked for outright: aligned with the left edge of a product grid
     item, and about 10% bigger.

     12px, not the 4px of optical air this carried: .nav's own
     padding-inline resolves to 12px across this whole tier (--nav-gutter,
     from the ≤1024px rule — the max() that would otherwise widen it only
     bites well above 768px), so 12 + 12 puts the mark's left edge on 24px
     — exactly where a product card's does (.container's 24px padding,
     which the grids cancel and put back as their own inset; see
     .products-grid in this block).

     24.2px is 22px × 1.1. Height only: .nav-logo-img is width:auto, so
     the mark scales whole rather than being stretched. */
  .nav-logo { padding-left: 12px; }
  .nav-logo-img { height: 24.2px; }

  /* ══ Cart line: the bin sits level with the stepper ══
     .card-qty-selector carries margin-bottom:8px because on a product
     CARD it stands directly above the Add to Cart button. In
     .cart-line-meta there is nothing under it, and that margin was the
     whole bug: the row is align-items:center, which centres each child's
     MARGIN box, so a stepper whose margin box is 44px tall next to a
     36px button ends up sitting 4px higher than it. Measured, not
     guessed — stepper top 327.5 against the bin's 331.5.

     Zeroing it here rather than on .card-qty-selector itself leaves the
     product card, which does need it, alone. */
  .cart-line-meta .cart-line-qty { margin-bottom: 0; }

  /* ── Footer, restacked ──
     Logo and description first at full width, then Shop and Help side
     by side, then Legal across the bottom — all on one left edge. The
     desktop columns right-align their text (so each column's own right
     edge lines up); left here, because a two-up grid of right-aligned
     columns on a phone puts the two headings at unrelated x positions
     and reads as two unaligned lists rather than one block. */
  .footer-top {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 32px 20px;
    text-align: left;
  }
  .footer-brand-col { grid-column: 1 / -1; }
  /* The last column (Legal) spans the full width under the other two
     rather than leaving a hole beside itself.

     :last-child, NOT :nth-of-type(3) — which is what this was and was
     wrong: nth-of-type counts by ELEMENT type, and .footer-brand-col is
     a <div> sitting in the same row, so "the third div" was the SECOND
     sitemap column. That put Help across the full width and left Shop
     and Legal each alone on a half-width row: three stacked rows
     instead of the two-up-then-one this is meant to be. */
  .footer-col:last-child { grid-column: 1 / -1; }
  .footer-col,
  .footer-col * {
    text-align: left;
    align-items: flex-start;
  }
  .footer-bottom { text-align: left; }
}

/* ════════════════════════════════════════════════════════════
   CART LINE — the stacked layout, ≤640px
   ------------------------------------------------------------
   A separate query from the ≤768px block above, and deliberately: the
   price only drops beneath the copy below 640px (see .cart-line's own
   grid-column rule in the earlier 640px block). Between 641 and 768 the
   line is still three columns with the price in its own right-hand one,
   vertically centred and already aligned — putting these rules there
   would break a layout that has nothing wrong with it.

   Later in the file than the base .cart-line-price rule on purpose. That
   rule sits AFTER the original ≤640px block, so at equal specificity it
   already wins there (which is why that block's own align-items:
   flex-start never took effect and the price has always rendered
   right-aligned). Anything meant to actually change the price has to
   come after it, not just be inside a narrower query.
   ════════════════════════════════════════════════════════════ */
@media (max-width: 640px) {
  .cart-line-price {
    /* Right edge on the control row's, instead of 9px past it out at the
       container's edge — that overhang is the "not aligned with the
       components above it". 160px is what .cart-line-meta's own contents
       actually measure: the stepper's 108px (its ≤640px rule), the row's
       16px gap, and the 36px remove button. The price column starts on
       the same left edge as that row, so a matching width puts the two
       right edges on one line.

       Restating the three numbers is the cost of them living in three
       different rules; they have to be changed together. */
    width: calc(108px + 16px + 36px);
    /* Right-aligned within that box — the base rule's own flex-end,
       restated because it is the half of this that does the visible
       work and should not be left to be inherited by accident. */
    align-items: flex-end;
    /* Was 10px on top of the grid's own 16px row gap, i.e. 26px of air
       above a figure that then sat unattached to anything. The row gap
       alone still gives the price room to read as its own thing —
       thinner, but not crowded. */
    margin-top: 0;
  }
}

/* The short form of the "Most Popular" disclaimer — hidden by default,
   swapped in by the phone tier above. Declared at top level (not inside
   the query) so the default state exists at every width. */
.rb-tiny { display: none; }

/* ============================================================
   CHECKOUT — the address fields
   ------------------------------------------------------------
   All five are always on screen, and Street address is itself the
   autocomplete input (js/places.js binds to [name=line1] when no
   dedicated search box exists). The separate search field above them,
   and the reveal-on-pick that hid them until it was used, are gone.
   ============================================================ */

/* display:contents, and it is load-bearing rather than cosmetic: the
   five address fields are direct children of .form-grid's grid and are
   placed by it (two-up, with .full spanning). Wrapping them in a real
   box to toggle would make the WRAPPER the single grid item and stack
   all five inside it at one column. contents removes the wrapper's own
   box and leaves its children as the grid's items, exactly as they were
   before the wrapper existed.

   The [hidden] pair is required because display:contents overrides the
   UA's `[hidden] { display: none }` — a class-plus-attribute selector
   beats the bare class, so the attribute keeps working. */
.addr-fields { display: contents; }
/* The [hidden] pair is kept even though nothing sets it any more: the
   fields are always open now (the separate search box and the
   manual-entry link are gone), and display:contents would override the
   UA's `[hidden] { display: none }` if anything ever did.

   .addr-search-hint and .addr-manual-link went with the search box. */
.addr-fields[hidden] { display: none; }


/* ============================================================
   TAP TARGETS
   ------------------------------------------------------------
   Measured, not guessed: each control below was hit-tested with
   elementFromPoint outward from its own centre, because several of them
   already carry an invisible ::after that makes the real target bigger
   than the element box. Only the ones whose ACTUAL hit area came in
   under 44x44 are here.

   ── Why (pointer: coarse) and not a width breakpoint ──
   This is a question about the input device, not the window. A touch
   laptop at 1300px needs a finger-sized target and a mouse user at
   375px does not, so keying it to width would fix it in the wrong place
   twice. It also means none of this touches the desktop design.

   44px is Apple's HIG minimum and WCAG 2.5.5's; 48 is Android's. 44 is
   the floor used throughout.

   Inline links inside prose are deliberately absent. WCAG 2.5.8 exempts
   them, and padding a link inside a sentence to 44px either overlaps the
   lines around it or forces the paragraph's leading open.
   ============================================================ */
@media (pointer: coarse) {
  /* The primary calls to action, at 40px. min-height rather than height:
     several of these carry two lines of label at the narrowest widths and
     must still be able to grow. */
  .hero-btn,
  .btn-cart,
  .select-native {
    min-height: 44px;
  }

  /* The hamburger, at 41x40. Its three bars are absolutely positioned
     against this box at top 0 / 8 / 16 — and the .active rule re-points
     them to 7px to form the cross — so growing the box to 44x44 would
     have moved every one of them and broken the animation. Extended past
     it instead: 13px top and bottom takes 18 to 44, 9px each side takes
     26 to 44, and not one bar coordinate changes. */
  .nav-toggle::after {
    content: '';
    position: absolute;
    inset: -13px -9px;
  }

  .nav-icon { min-width: 44px; min-height: 44px; }

  /* The offer pill's dismiss X, at 31x30 the smallest real target on the
     site. The disc is sized to look right against the pill's 48px depth
     and should not grow, so the target is extended past it instead —
     an inset pseudo-element. 7px each way turns 30 into 44.

     It sits over the pill's own click area, which reopens the offer, so
     the two overlap by exactly the 7px ring. That is the right way round:
     a near-miss on the X reopens the card, which is undoable, where a
     near-miss on the pill would dismiss the offer for good. */
  .promo-pill-x::after {
    content: '';
    position: absolute;
    inset: -7px;
  }
}


/* ============================================================
   FINISHING TOUCHES
   ============================================================ */

/* ── Reduced motion, globally ──
   There are already a dozen scoped @media (prefers-reduced-motion)
   blocks in this file, each switching off one component's own
   transition. They are all still correct and none of them caught the
   two things that move the most:

   scroll-behavior:smooth on <html>, which animates every in-page jump —
   the skip link, the FAQ anchors, showPayError's scrollIntoView. For
   someone with a vestibular disorder a full-page smooth scroll is the
   single worst thing on a site, and it was the one thing not covered.

   And any animation or transition added later, anywhere, which now
   defaults to off under this setting rather than defaulting to on and
   waiting for someone to notice.

   0.01ms rather than 0: a real duration still fires transitionend and
   animationend, so any script waiting on one of those to clean up
   (js/promo.js's face swap, the nav's bar transition) still gets its
   event and does not strand an element mid-state. `!important` because
   this has to beat rules that are more specific by nature. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* ── A visible focus ring on everything ──
   21 components already define their own :focus-visible, and those are
   deliberate — they sit on dark grounds or inside tight boxes where the
   default ring reads badly. This is the floor under the rest: anything
   focusable that has never been given one gets a ring rather than
   nothing, which is the state a keyboard user cannot recover from.

   :focus-visible, not :focus — a mouse click on a button should not
   leave a ring behind it. Declared before the component rules would be
   wrong (they need to win); this sits at the end of the file, so the
   ordering is handled by position rather than by specificity, and any
   component rule at equal specificity still takes precedence... which
   is why the selector list is kept to bare elements: a component rule
   is always at least as specific as `button`. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--text-primary);
  outline-offset: 2px;
  /* The ring is drawn outside the box and can be clipped by an
     overflow:hidden ancestor. A radius that follows the element's own
     keeps it from looking like a stray rectangle where it does show. */
  border-radius: inherit;
}
/* On the dark grounds the site uses — the offer strip, the promo card,
   the order-process band — a near-black ring is invisible. */
.promo-bar a:focus-visible,
.promo-card a:focus-visible,
.promo-card button:focus-visible,
.gsi-preview a:focus-visible,
.gsi-preview button:focus-visible,
.cart-float-bar a:focus-visible,
.cart-float-bar button:focus-visible {
  outline-color: #fff;
}

/* ── iOS stops zooming the page on every field ──
   Mobile Safari auto-zooms when a text field smaller than 16px takes
   focus, and it does NOT zoom back out afterwards. The visitor is left
   on a page that is now wider than the window, panning sideways to read
   it — which on a checkout form happens at the worst possible moment and
   looks like the site is broken.

   The site's fields are 15px, one pixel under the threshold. Raised to
   16 for coarse pointers only, so the desktop type scale is untouched;
   at 15 vs 16 the difference is invisible next to a form that stops
   jumping.

   16px is the floor, not a size: max() lets anything already larger
   stay as it is. */
@media (pointer: coarse) {
  input,
  textarea,
  select,
  .field-input,
  .select-native,
  .promo-popup-input,
  .search-field input,
  .auth-field input,
  .auth-field textarea {
    font-size: max(16px, 1em);
  }
}

/* ── Hover states that do not stick on touch ──
   A tapped element keeps :hover on a touch device until something else
   is tapped, so a card the visitor merely scrolled past can be left
   looking active. Anything whose hover is purely decorative belongs
   inside @media (hover: hover); this is the blunt correction for the
   ones that are not, and it costs a mouse user nothing because the
   query does not match them. */
@media (hover: none) {
  .product-card:hover,
  .cert-card:hover,
  .pd-size:hover,
  .addr-option:hover {
    border-color: var(--border);
    background: inherit;
  }
}

/* ── Print ──
   The one page anyone prints is an order: a receipt for an expense
   claim or a lab's records. Everything that is navigation, decoration or
   an offer is noise on paper and is removed; what is left is the order
   itself, in black on white.

   Backgrounds are not printed by default in any browser, so a white-on-
   dark button prints as white-on-white — invisible. Forcing colour to
   black is what stops the total disappearing. */
@media print {
  .nav,
  .promo-bar,
  .footer,
  .promo-dock,
  .gsi-preview,
  .cart-float-bar,
  .pa-toast,
  .skip-link,
  .age-gate,
  .research-banner,
  .op-bleed,
  .account-nav,
  .btn-cart,
  .hero-btn,
  .admin-tab-new-btn {
    display: none !important;
  }
  html, body {
    background: #fff !important;
    color: #000 !important;
  }
  .page { padding-top: 0; min-height: 0; }
  /* A link's destination is lost on paper. Printed after the label for
     the ones that point somewhere a reader could actually go — not for
     in-page anchors, which name nothing useful. */
  a[href^="http"]::after,
  a[href^="mailto"]::after {
    content: ' (' attr(href) ')';
    font-size: 0.85em;
    word-break: break-all;
  }
  /* Never split a line item or a total across two pages. */
  .order-card,
  .ledger-entry,
  .summary-row,
  .oc-step { break-inside: avoid; }
}


/* ── The product page's buy row, stacked ──
   Below 768px the quantity selector and Add to Cart stop sharing a line.
   Side by side they split a phone's width between a control that needs
   three tap targets and a button that needs a readable label, and
   neither got enough: the selector sat at its 38/48/38 minimum while the
   button wrapped.

   Full width each, in the column's own measure — .pd-info already
   carries the container's padding, so `width: 100%` is exactly "the page
   less the standard edge spacing" with nothing restated here.

   The gap between them is unchanged. .pd-buy's flex `gap` is 12px, and
   turning the row into a column hands the same 12px to the vertical
   axis — the distance the two controls sat apart horizontally is now the
   distance they sit apart vertically, which is what was asked for. */
@media (max-width: 768px) {
  .pd-buy {
    flex-direction: column;
    align-items: stretch;
  }
  .pd-qty { flex: 0 0 auto; }
  .pd-qty .qty {
    display: grid;
    width: 100%;
    /* The two end buttons keep their 44px tap size; the middle track
       takes everything left over, which is what makes the control span
       the column instead of sitting at its content width. */
    grid-template-columns: 44px 1fr 44px;
    height: 44px;
  }
  .pd-buy .btn-cart {
    flex: 0 0 auto;
    width: 100%;
  }
}

/* ============================================================
   CHECKOUT — the payment gate and the folded summary
   ============================================================ */

/* ── Payment options, held shut until the declaration is ticked ──
   The research-use statement now sits ABOVE these (js/checkout.js), and
   until it is answered the options are dimmed and their radios are
   genuinely disabled — not merely styled as such, because a keyboard
   user tabs straight past pointer-events and a CSS-only gate is not one.

   The note above them says why, and disappears the moment it stops
   being true. Greying without a reason is the failure this avoids:
   "not yet" is only useful with "because of what". */
.pay-methods-note {
  margin: 0 0 12px;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-muted);
}
.pay-methods-note[hidden] { display: none; }

.pay-methods.is-gated {
  opacity: 0.45;
  pointer-events: none;
}
.pay-methods.is-gated .pay-method { cursor: default; }

/* Plainly unavailable rather than looking pressable and refusing. The
   submit handler still runs every one of its own checks — an Enter key,
   a late autofill and a hand-edited DOM all arrive there regardless. */
.btn-place-order:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

/* ── The folded order summary ──
   Desktop keeps the sticky sidebar exactly as it was: the toggle is not
   rendered and the body is always open. Everything here is the phone
   arrangement.

   Order -1 moves the summary above the form. The grid collapses to one
   column at 1024px (see .checkout-layout in the responsive block), and
   source order would otherwise put a full-width summary between the
   customer and the first field they came to fill in. */
.summary-peek { display: none; }

@media (max-width: 768px) {
  /* Half the 48px the grid gives every other pair — asked for outright.
     Stacked, the summary and Send To are one thing above another rather
     than two columns needing a gutter between them, and 48px read as a
     hole. */
  .checkout-layout { gap: 24px; }

  .checkout-summary {
    order: -1;
    padding: 0;
    /* The sticky offset belongs to a sidebar. As a band at the top of a
       one-column page it would peel off and follow the scroll over the
       form. */
    position: static;
    /* The same frame as every section of the form below it, asked for
       outright — .checkout-section's own border, radius and shadow. The
       shadow is the only one that differed: .cart-summary carries the
       deeper --shadow-md, which is right for a panel floating beside the
       page and too heavy for a bar sitting in the same stack as the
       cards it matches. */
    box-shadow: var(--shadow-sm);
  }

  .summary-peek {
    display: flex;
    align-items: center;
    gap: 12px;
    width: 100%;
    /* 28px on every side. The horizontal figure is .checkout-section's
       own, which is what puts the bag on the same left edge as the
       fields in Send To below — asked for outright, and the reason it is
       stated as one number rather than the 14px/16px a bar would
       otherwise want.

       Top and bottom match it too, also asked for: an even inset all
       round makes this read as a section of the form that happens to
       hold one row, rather than as a strip bolted above it. */
    padding: 28px;
    border: none;
    background: none;
    font-family: inherit;
    color: var(--text-primary);
    text-align: left;
    cursor: pointer;
  }
  .summary-peek-bag {
    flex: 0 0 auto;
    width: 22px;
    height: 22px;
    color: var(--text-secondary);
  }
  /* Price and count on one line, left-aligned against the bag —
     asked for outright. baseline, not center: the two are different
     sizes and aligning their boxes would leave the smaller one floating
     off the larger one's midline. */
  .summary-peek-text {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 4px 8px;
  }
  .summary-peek-price {
    display: flex;
    align-items: baseline;
    gap: 7px;
    font-size: 16px;
    font-weight: 800;
    letter-spacing: -0.01em;
  }
  /* The pre-discount figure. Muted and lighter so the pair reads as
     "was, now" at a glance rather than as two competing prices — the
     one being charged is the one in full weight. */
  .summary-peek-was {
    font-size: 14px;
    font-weight: 600;
    color: var(--text-muted);
    text-decoration-thickness: 1.5px;
  }
  .summary-peek-now { color: var(--text-primary); }
  .summary-peek-count {
    font-size: 13px;
    font-weight: 600;
    color: var(--text-secondary);
  }
  .summary-peek-chev {
    flex: 0 0 auto;
    width: 18px;
    height: 18px;
    color: var(--text-muted);
    transition: transform var(--transition);
  }
  /* Points down when there is more to see, up when there is not. */
  .checkout-summary:not(.is-folded) .summary-peek-chev { transform: rotate(180deg); }

  /* The body itself. display:none rather than a height transition: the
     contents are a variable-length list of line items, so any animated
     height would be measured-then-set on every cart change, and the one
     thing this bar must not do is take a moment to tell you the total. */
  .checkout-summary.is-folded .summary-body { display: none; }
  .summary-body {
    /* Same 28px inset as the bar above it, so the rows line up with the
       bag rather than sitting 12px inside it. */
    padding: 18px 28px 24px;
    border-top: 1px solid var(--border-soft);
  }
  /* The heading is what the folded bar already says. */
  .summary-body > h2 { display: none; }
}

@media (max-width: 768px) and (prefers-reduced-motion: reduce) {
  .summary-peek-chev { transition: none; }
}


/* ============================================================
   TOUCH PRESS FEEDBACK
   ------------------------------------------------------------
   What a tap felt like before this block: nothing, then a state change
   some time later. Every control on the site was styled for a mouse —
   :hover carried the whole "you are about to press this" conversation,
   and a finger has no hover. Four things were wrong at once on a phone:

     1. The browser's own grey flash was the ONLY press feedback, and it
        is a rectangle that ignores the control's radius and fires on a
        delay of the OS's choosing, not the page's.
     2. :hover styles stick after a tap on touch, because the element
        stays "hovered" until you touch something else — so a button you
        pressed a minute ago is still lit.
     3. Nothing acknowledged the press in the moment BETWEEN the finger
        landing and the work finishing. On a slow connection that is the
        whole interaction.
     4. Transitions tuned for a cursor (0.2s each way) read as lag when
        the thing being animated is under your finger.

   The fix is one press language applied to every control, scoped to
   pointers that cannot hover so nothing about the desktop experience
   moves.

   Why (hover: none) rather than a width query: this is about the INPUT,
   not the screen. A touchscreen laptop at 1400px wants the press state;
   a narrow window on a desktop with a mouse does not.
   ============================================================ */
@media (hover: none) {
  /* The site's own press state replaces the OS one. Removing the
     highlight WITHOUT putting something in its place would be a
     regression, which is why these two live in the same block. */
  a, button, [role="button"], label.pay-method, .op-seg,
  .pd-size, .qty button, input[type="checkbox"], input[type="radio"] {
    -webkit-tap-highlight-color: transparent;
  }

  /* ── The press itself ──
     Scale, not colour. A colour change has to be invented per control
     (each of these has a different ground), whereas a scale reads
     identically on all of them and cannot clash with any of the state
     colours already in use — sold out, pre-order, selected, disabled.

     0.96 is deliberately just past the threshold of being noticed: big
     enough to feel like the surface moved under the finger, small
     enough that a full-width button does not visibly detach from the
     layout around it.

     transform-origin: center so a wide button compresses toward its
     own middle rather than pulling away from one edge. */
  .btn-cart:active,
  .btn-primary:active,
  .btn-ghost:active,
  .btn-danger:active,
  .hero-btn:active,
  .promo-popup-submit:active,
  .promo-popup-decline:active,
  .summary-upsell-add:active,
  .summary-upsell-yes:active,
  .summary-upsell-no:active,
  .admin-link-btn:active,
  .op-link:active,
  .pd-size:active,
  .pay-method:active,
  .op-seg:active {
    transform: scale(0.96);
    transform-origin: center;
  }

  /* Faster IN than OUT. The press has to feel instantaneous — anything
     over ~80ms and the surface appears to lag the finger — while the
     release can afford to settle, which is what stops the control
     snapping back and reads as springy rather than mechanical.

     Stated on :active for the in, and on the base for the out, because
     a transition declared in one place cannot have two durations. */
  .btn-cart, .btn-primary, .btn-ghost, .btn-danger, .hero-btn,
  .promo-popup-submit, .promo-popup-decline,
  .summary-upsell-add, .summary-upsell-yes, .summary-upsell-no,
  .admin-link-btn, .op-link, .pd-size, .pay-method, .op-seg {
    transition: transform 0.18s cubic-bezier(0.22, 0.61, 0.36, 1),
                background 0.18s ease, color 0.18s ease,
                border-color 0.18s ease, opacity 0.18s ease;
  }
  .btn-cart:active, .btn-primary:active, .btn-ghost:active,
  .btn-danger:active, .hero-btn:active,
  .promo-popup-submit:active, .promo-popup-decline:active,
  .summary-upsell-add:active, .summary-upsell-yes:active,
  .summary-upsell-no:active, .admin-link-btn:active, .op-link:active,
  .pd-size:active, .pay-method:active, .op-seg:active {
    transition-duration: 0.06s;
  }

  /* ── Nothing presses that cannot act ──
     A disabled control that shrinks under the finger is telling the
     customer it accepted a press it is going to ignore. This is the one
     place the press language has to stay silent. */
  .btn-cart[disabled]:active,
  .btn-primary[disabled]:active,
  .btn-ghost[disabled]:active,
  .hero-btn[disabled]:active,
  .promo-popup-submit[disabled]:active,
  .pd-size[disabled]:active,
  .btn-cart.is-added:active {
    transform: none;
  }

  /* ── Sticky hover, cancelled ──
     On touch, :hover latches until the next tap elsewhere, so a button
     pressed a minute ago is still wearing its hover fill. These restate
     the resting look at a specificity that beats the plain :hover rules
     they undo, for the controls where that latch is most visible. */
  .btn-cart:hover:not(:active):not(:disabled) { background: var(--cta); }
  .hero-btn-primary:hover:not(:active) { background: var(--text-primary); }
  .op-seg:hover:not([aria-selected="true"]):not(:active) { background: transparent; }
}

/* The press is motion, and motion is exactly what this setting asks to
   be spared. The colour and opacity halves stay — they carry meaning,
   not decoration. */
@media (hover: none) and (prefers-reduced-motion: reduce) {
  .btn-cart:active, .btn-primary:active, .btn-ghost:active,
  .btn-danger:active, .hero-btn:active,
  .promo-popup-submit:active, .promo-popup-decline:active,
  .summary-upsell-add:active, .summary-upsell-yes:active,
  .summary-upsell-no:active, .admin-link-btn:active, .op-link:active,
  .pd-size:active, .pay-method:active, .op-seg:active {
    transform: none;
  }
}


/* ============================================================
   ORDER PROCESS — non-mobile widening and type
   ------------------------------------------------------------
   Every rule here is inside min-width:769px, deliberately and without
   exception: the phone tier rebuilds this section as a swipeable strip
   of six cards with its own widths, type and padding, and none of it
   should move. Editing the shared base rules instead would have reached
   both tiers.

   ── The measure ──
   The two columns as a GROUP now line up with the "Research Use Only"
   band above them, asked for outright. Measured rather than guessed: at
   1440px that band's text ink runs 228px → 1212px, i.e. 984px wide and
   centred on 720 — which is the viewport's own midline, and the midline
   .op-card is already centred on. So only the width changes; the group
   stays put and both columns gain the same amount.

   Working back through the box chain, in CSS pixels because .op-card
   carries zoom:0.9 and everything measured on screen is 0.9 of what is
   declared here:

     .op-panels wanted      984 / 0.9        = 1093.33
     .op-content padding    21px each side   =   42
     --op-inner-w           1093.33 + 42     = 1135.33

   The column gap is untouched — clamp(28px, 4vw, 56px) as before — so
   the extra width goes entirely to the content inside the two columns,
   which is what was asked. .op-card keeps margin-inline:auto and
   width:min(100%, …), so it still centres and still degrades to the
   viewport on a narrow window rather than overflowing.

   The tab selector above takes the same width for free: .op-slider is
   sized by .op-content, so widening the card widens the track, the three
   segments stay equal thirds of it, and --op-seg-inset is unchanged —
   so each label keeps exactly the left inset it had.
   ============================================================ */
@media (min-width: 769px) {
  .op-card {
    --op-inner-w: 1135.33px;
  }

  /* ── One size for the whole panel ──
     15.81px is .hero-subtitle's own size — the description under
     "Peptides / Tested & Delivered" — which is the reference asked for.
     The body copy, both kinds of table cell and the CTA link all take
     it, so the panel reads as one voice instead of four sizes.

     Nothing is emphasised or de-emphasised by SIZE any more. That work
     moves entirely to weight and colour, below. */
  .op-step-body,
  .op-bulk,
  .op-link {
    font-size: 15.81px;
  }
  .op-bulk.op-pay-table th[scope="row"],
  .op-bulk.op-pay-table td,
  .op-bulk--rows th[scope="row"],
  .op-bulk--rows td,
  .op-bulk th,
  .op-bulk td {
    font-size: 15.81px;
  }

  /* ── The three levels, by weight and colour only ──
     Audited across all three tables, which were not saying the same
     thing the same way. A column header was 11px/600/secondary: shrunk
     AND muted AND bolded at once, so it was being de-emphasised by two
     devices and emphasised by a third. At one shared size that
     contradiction has nowhere to hide, so each level now gets one clear
     treatment and the same one in every table:

       column header  the quiet LABEL for a column   600, muted
       row header     the KEY the row is about       700, primary
       data cell      the ANSWER                     400, primary

     Reading down the first column answers "which case is mine"; reading
     across answers "what applies to it". The weight ladder follows that
     rather than the DOM order. */
  .op-bulk th[scope="col"] {
    font-weight: 600;
    color: var(--text-muted);
  }
  .op-bulk.op-pay-table th[scope="row"],
  .op-bulk--rows th[scope="row"] {
    font-weight: 700;
    color: var(--text-primary);
  }
  .op-bulk td {
    font-weight: 400;
    color: var(--text-primary);
  }

  /* ── Column positions, stated rather than left to the content ──
     With the columns wider, `auto` layout was sizing each one to its
     longest cell, so the second column of the two-column tables started
     wherever "4 vials or more" happened to end. Fixed positions instead,
     asked for outright: with two columns the second begins at the
     halfway mark; with three, at half and three-quarters.

     table-layout:fixed is what makes the percentages authoritative —
     without it they are hints the auto algorithm may overrule when a
     cell does not fit. */
  .op-bulk { table-layout: fixed; }
  .op-bulk--rows:not(.op-bulk--wide) th[scope="col"]:first-child,
  .op-bulk--rows:not(.op-bulk--wide) th[scope="row"],
  .op-pay-table th[scope="col"]:first-child,
  .op-pay-table th[scope="row"] { width: 50%; }

  /* The transit table is the three-column exception: 50 / 25 / 25 puts
     the second column's left edge on the halfway mark and the third's on
     three-quarters, which is the layout asked for. */
  .op-bulk--wide th[scope="col"]:first-child,
  .op-bulk--wide th[scope="row"] { width: 50%; }
  .op-bulk--wide th[scope="col"]:nth-child(2),
  .op-bulk--wide td:nth-of-type(1) { width: 25%; }
  .op-bulk--wide th[scope="col"]:nth-child(3),
  .op-bulk--wide td:nth-of-type(2) { width: 25%; }

  /* Right-alignment removed. It was there to keep two narrow numeric
     columns tidy against a wide first one, but a right-aligned column
     starts wherever its longest value happens to begin — which directly
     contradicts "the second is halfway along, the third three-quarters".
     Left-aligned, each column begins exactly where its width puts it,
     and tabular-nums (already on .op-bulk) keeps the figures in line. */
  .op-bulk--wide td:not(:first-child),
  .op-bulk--wide th:not(:first-child) { text-align: left; }
}


/* The pressed cascade tile — see the pointerdown handler in index.html.
   Its job is to give the columns' stop a visible cause: the page is
   navigating, and a tile that visibly took the press says so where a
   frozen animation on its own just looks broken.

   Darken plus a small sit-down, on the same brightness the hover state
   already uses so the two read as one control getting more definite
   rather than as two unrelated effects. */
.hero-orb-tile--pressed img { filter: brightness(0.8); }
.hero-orb-tile--pressed { transform: scale(0.94); }
.hero-orb-tile { transition: transform 0.12s cubic-bezier(0.22, 0.61, 0.36, 1); }

@media (prefers-reduced-motion: reduce) {
  .hero-orb-tile--pressed { transform: none; }
}
