/* ==========================================================================
   Glow Getter Bedford
   Layout and components. Tokens live in tokens.css, faces in fonts.css.
   ========================================================================== */

/* --- Primitives ---------------------------------------------------------- */

.wrap {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

section { position: relative; }

/* Adjacent sections each contribute this, so the gap between two of them is
   double the value — keep it modest. */
.section {
  padding-block: clamp(56px, 6.5vw, 100px);
}

.section--tint { background: var(--cream-deep); }

/* Tucks a section up under the page-hero — the reduced top reads as the hero
   handing into its content when both share one ground. Was an inline style on
   /about and /gallery; a class so the mixed theme can undo it, because on a
   visible light band a 44px top against a 94px bottom reads as a mistake.
   The fallback is the tucked value; theme-mixed.css sets --snug-top back to
   the full section padding. */
.section--snug { padding-top: var(--snug-top, clamp(24px, 3vw, 44px)); }

/* Phone-only. On a small screen the statement and the rating run straight on
   from the hero with nothing to separate them, so they get their own white
   band — --card rather than pure #fff, which is the white already used for
   raised surfaces here and sits properly against the cream. Full-bleed, like
   .section--tint, so it reads as a band rather than a floating box.
   Desktop keeps the plain cream: there is enough room there for the spacing
   alone to do the separating. */
@media (max-width: 700px) {
  .section--white {
    background: var(--card);
    border-block: 1px solid var(--bronze-06);
  }
}

.stack   { display: grid; gap: 16px; }
.center  { text-align: center; justify-items: center; }

.lede {
  font-size: 18px;
  line-height: 1.5;
  max-width: 56ch;
  margin: 0;
}

.muted { color: var(--brown-muted); }

/* Section heading block */
.head { display: grid; gap: 14px; margin-bottom: clamp(36px, 5vw, 64px); }
/* h1 is in here too since 23 Aug 2026: /gallery lost its dark page-hero,
   so its h1 is now an ordinary section heading in the light band and should
   be sized like one rather than falling back to the base h1. */
.head h1,
.head h2 { font-size: clamp(32px, 5vw, 60px); }
.head.center { justify-items: center; text-align: center; }
.head.center .lede { margin-inline: auto; }
/* The eyebrow is inline-flex, but as a grid item it stretches to the full
   column and the pill runs the width of the page. Only noticeable once the
   label got short ("01"), but it was always wrong. */
.head:not(.center) > .eyebrow { justify-self: start; }
/* Photo snaps beside the /treatments category heads: a cluster of
   overlapping circles pinned to the head's top-right on desktop, per
   Lewis's mock (21 Aug 2026). The head is position:relative purely as the
   anchor for this. Below 1080px there is no reliable clear space to the
   right — the lede runs long — so the cluster drops into normal flow under
   the text at a smaller size instead of colliding with it. Images come
   from tools/build-treatment-snaps.js. */
.head { position: relative; }
/* The window: a fixed-width strip the circles drift through, faded at both
   ends so they appear and leave softly rather than being guillotined -
   same treatment as the hero marquee. */
.head__snaps {
  width: clamp(240px, 26vw, 360px);
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 12%, #000 88%, transparent 100%);
          mask-image: linear-gradient(to right, transparent 0, #000 12%, #000 88%, transparent 100%);
}
.head__snaps-track {
  display: flex;
  width: max-content;
}
/* main.js clones the set and adds is-looping; the -50% then lands the clone
   exactly where the originals began. The animation only ever runs on a
   doubled track - without the clone (JS off, reduced motion) the strip is
   simply static. The uniform trailing margin is what makes the overlap
   period identical across the loop seam. */
.head__snaps-track.is-looping { animation: snaps-drift 32s linear infinite; }
@keyframes snaps-drift { to { transform: translateX(-50%); } }
.head__snaps img {
  width: clamp(72px, 8.5vw, 120px);
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 50%;
  flex: none;
  margin-right: -20px;
  /* The ring is what keeps overlapped circles legible against each other,
     and --card tracks the theme and the mixed theme's light bands.
     NO box-shadow here: the window is exactly circle-height with overflow
     hidden, so a downward shadow gets clipped into dark crescents along the
     strip's bottom edge - on the mixed theme's light bands that rendered as
     a purple line under the circles, worst on mobile where the circles are
     small. Reported from a real handset 21 Aug 2026. */
  border: 3px solid var(--card);
}
@media (prefers-reduced-motion: reduce) {
  .head__snaps-track.is-looping { animation: none; }
}
/* Vertically centred against the TITLE AND SUBTITLE, not the section top
   (Lewis, 21 Aug 2026). Done with grid placement rather than a guessed
   offset: the head's rows are eyebrow / h2 / sub / lede, so the cluster
   occupies a second column spanning rows 2-4 and centres itself in that
   span. Exact at any type size, and the lede keeps its own full row. */
@media (min-width: 1081px) {
  .head:not(.center):has(.head__snaps) { grid-template-columns: minmax(0, 1fr) auto; }
  .head:not(.center):has(.head__snaps) > * { grid-column: 1; }
  .head:not(.center) > .head__snaps {
    grid-column: 2;
    grid-row: 2 / 4;
    align-self: center;
    justify-self: end;
  }
}
@media (max-width: 1080px) {
  .head__snaps { margin-top: 6px; width: min(100%, 300px); }
  .head__snaps img { width: 68px; margin-right: -14px; }
}

/* Sits under the category name on /treatments — the line that used to be the
   h2 before the two were swapped round. */
.head__sub {
  margin: -4px 0 0;
  font-family: var(--serif);
  font-size: clamp(20px, 2.2vw, 26px);
  line-height: 1.3;
  color: var(--bronze);
}

/* --- Buttons ------------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 14px 26px;
  border-radius: var(--r-pill);
  font-family: var(--sans);
  font-weight: 600;
  font-size: 15px;
  text-decoration: none;
  border: 1px solid transparent;
  cursor: pointer;
  transition: background-color .25s ease, color .25s ease,
              border-color .25s ease, transform .25s ease;
}

/* `background-origin: border-box` matters here: the 1px transparent border is
   painted over by the background COLOUR (clip is border-box) but the gradient
   IMAGE starts at the padding box by default, so flat --grad-solid showed as a
   1px ring around the pill. Invisible on the right, where the gradient ends
   close to that colour; on the left cap, where it is at its lightest, it read
   as a brown sliver. It has to be set after the `background` shorthand — the
   shorthand resets origin back to padding-box. */
.btn--solid  { background: var(--grad-solid); background-image: var(--grad); background-origin: border-box; color: var(--on-grad); }
.btn--solid:hover  { background-image: var(--grad-hover); transform: translateY(-2px); }

/* Compact variant — used for the header CTA so the pill sits neatly in the bar. */
.btn--sm { padding: 9px 18px; font-size: 14px; }

.btn--ghost  { background: transparent; color: var(--brown); border-color: var(--rule); }
.btn--ghost:hover  { background: var(--bronze-10); border-color: var(--bronze); }

.btn--light  { background: var(--btn-light-bg); color: var(--btn-light-fg); }
/* Stays #fff in both themes: --btn-light-bg is light in both, because this
   button only ever sits on the dark Million Dollar block. */
.btn--light:hover  { background: #fff; transform: translateY(-2px); }

/* --- Header -------------------------------------------------------------- */

.header {
  position: sticky;
  top: 0;
  z-index: 100;
  /* Two fallbacks here, and BOTH are load-bearing on iOS - this is the
     "nav bar looks transparent" Sabrina reported on her iPhone, 22 Aug 2026.

     1. The plain var(--cream) line comes FIRST as a fallback. color-mix()
        needs Safari 16.2+; older Safari does not merely ignore the function,
        it throws the WHOLE declaration away - so the header was left with no
        background at all and the page scrolled through it in full focus. A
        browser that understands color-mix overwrites this with the line
        below; one that does not keeps an opaque header, which is correct and
        merely less pretty.
     2. -webkit-backdrop-filter is required by Safari. Without it there is no
        blur, so even where the translucent background DID apply, whatever
        was behind stayed sharp instead of frosted.

     The unprefixed backdrop-filter must stay: per the global CLAUDE.md it is
     what disqualifies this header from iOS 26 status-bar sampling, which is
     the fix for the Safari strip bug on ellieshairandbeauty. Do not remove
     it as a performance measure. */
  background: var(--cream);
  background: color-mix(in srgb, var(--cream) 88%, transparent);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid transparent;
  transition: border-color .3s ease, background-color .3s ease;
}
.header.is-stuck { border-bottom-color: var(--bronze-10); }

/* Nav left, wordmark centred, CTA right — the reference's arrangement. */
.header__inner {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 24px;
  min-height: 88px;
}
/* grid-row must be explicit on all four. Without it, auto-placement only ever
   moves forward, so the nav (column 1) lands on a second row after the
   wordmark (column 2) and the header doubles in height. */
.header__inner .wordmark    { grid-column: 2; grid-row: 1; justify-self: center; }
.header__inner .nav         { grid-column: 1; grid-row: 1; justify-self: start; }
.header__inner .header__cta { grid-column: 3; grid-row: 1; justify-self: end; }
.header__inner .nav-toggle  { grid-column: 3; grid-row: 1; justify-self: end; }

/* Supplied logo, cropped to its content so it sits on any surface. Aspect is
   360:177 — wordmark only. The current artwork arrives already transparent;
   see tools/build-logo-mark.js, which still keys a flat background out if a
   future supply needs it. */
.wordmark { text-decoration: none; display: block; line-height: 0; }
.wordmark img {
  display: block;
  height: 46px;
  width: auto;
}
.wordmark--footer img { height: 58px; }

@media (max-width: 560px) {
  .wordmark img { height: 36px; }
  .wordmark--footer img { height: 46px; }
}

.nav { display: flex; align-items: center; gap: 22px; }
.nav a {
  font-size: 14px;
  font-weight: 500;
  /* Seven items beside a centred logo — without this they wrap to two and
     three lines and the header grows. */
  white-space: nowrap;
  color: var(--brown);
  text-decoration: none;
  position: relative;
  padding-block: 4px;
}
.nav a::after {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  width: 100%; height: 1px;
  background: var(--bronze);
  transform: scaleX(0);
  transform-origin: right;
  transition: transform .3s ease;
}
.nav a:hover::after { transform: scaleX(1); transform-origin: left; }

.nav-toggle { display: none; }

/* Lives inside the nav for the phone menu; the header has its own CTA above
   1080px, so it stays hidden there. */
.nav__cta { display: none; }

/* --- Hero ----------------------------------------------------------------
   A single full-bleed rounded image card, inset slightly from the viewport
   edges, with the headline centred over the photograph. The treatment ring
   sits inside the bottom of the same card. */

/* Reference insets the card 50px each side at desktop (93.4% of the viewport)
   and rounds it 50px, tapering to 10px/30px on a phone. */
.hero { padding: 10px clamp(10px, 2.8vw, 50px) 0; }

.hero__card {
  position: relative;
  border-radius: clamp(30px, 3vw, 50px);
  overflow: hidden;
  min-height: var(--card-h);
  display: grid;
  /* Top-aligned, not centred: the lower half of the card belongs to the ring. */
  align-content: start;
  isolation: isolate;
  background: var(--cream-deep);
}

/* The photo is wrapped in <picture> so phones can be served their own crop.
   Both selectors are kept: the wrapper carries the positioning, the img
   inside it does the covering. Matching `img` alone rather than `> img`
   matters — with <picture> the img is no longer a child of the card. */
.hero__card > picture {
  position: absolute;
  inset: 0;
  display: block;
  z-index: -2;
}

.hero__card img {
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: 60% 30%;
}

/* The photograph is light, so the white headline needs help to hold contrast.
   Three stacked layers, topmost first:
     1. A copper glow in the button colour, strongest at the very top and gone
        by the vertical midpoint — this is what lifts the headline off the
        model's face, and ties the hero to the brand gradient.
     2. A soft radial darkening centred where the type sits.
     3. A gentle top-and-bottom vignette. */
.hero__scrim {
  position: absolute;
  inset: 0;
  z-index: -1;
  background:
    linear-gradient(to bottom,
      rgba(154, 66, 120, .64) 0%,
      rgba(168, 84, 133, .46) 16%,
      rgba(186, 108, 152, .24) 32%,
      rgba(206, 150, 183, .08) 43%,
      rgba(206, 150, 183, 0) 50%),
    radial-gradient(120% 78% at 50% 34%, rgba(46, 22, 38, .34) 0%, rgba(46, 22, 38, .18) 45%, rgba(46, 22, 38, 0) 78%),
    linear-gradient(to bottom, rgba(46, 22, 38, .18) 0%, rgba(46, 22, 38, 0) 34%, rgba(46, 22, 38, .16) 100%);
}

.hero__content {
  position: relative;
  text-align: center;
  display: grid;
  justify-items: center;
  gap: 16px;
  padding: clamp(26px, 3.4vw, 54px) var(--gutter) 0;
  max-width: 940px;
  margin-inline: auto;
}

.hero__content .eyebrow { color: rgba(255, 255, 255, .82); }

.hero h1 {
  font-size: clamp(34px, 4.6vw, 68px);
  line-height: 1.08;
  color: #FFFDF9;
  /* Wide enough to hold two lines — a third line runs into the ring. */
  max-width: 17ch;
  text-shadow: 0 2px 24px rgba(46, 22, 38, .28);
}

.hero__sub {
  margin: 0;
  max-width: 46ch;
  font-size: 17px;
  color: rgba(255, 253, 249, .88);
  text-shadow: 0 1px 14px rgba(46, 22, 38, .34);
}

.hero__cta { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; }

.hero__meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 10px 22px;
  font-size: 14px;
  color: rgba(255, 253, 249, .8);
}
.hero__meta strong { color: #FFFDF9; font-weight: 600; }

/* --- Treatment marquee ---------------------------------------------------
   Two rows across the bottom of the hero card, travelling opposite ways.
   Each row contains the same list twice and shifts by exactly -50%, so the
   loop is seamless with no visible reset. */

.marquee {
  position: absolute;
  left: 0; right: 0;
  bottom: 26px;
  display: grid;
  gap: 12px;
  overflow: hidden;
  /* Feather both ends so pills melt into the photograph rather than being
     guillotined by the card edge. */
  -webkit-mask-image: linear-gradient(to right, transparent 0, #000 9%, #000 91%, transparent 100%);
          mask-image: linear-gradient(to right, transparent 0, #000 9%, #000 91%, transparent 100%);
}

.marquee__row {
  display: flex;
  width: max-content;
  will-change: transform;
}
.marquee__row--left  { animation: marquee-left  46s linear infinite; }
.marquee__row--right { animation: marquee-right 52s linear infinite; }

.marquee__group { display: flex; }

.marquee__pill {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin-right: 12px;
  padding: 9px 20px;
  border-radius: var(--r-pill);
  background: rgba(178, 87, 145, .20);
  border: 1px solid rgba(255, 255, 255, .30);
  backdrop-filter: blur(5px);
  -webkit-backdrop-filter: blur(5px);
  color: #fff;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
}

/* -50% lands the second copy exactly where the first started. */
@keyframes marquee-left  { from { transform: translateX(0); }    to { transform: translateX(-50%); } }
@keyframes marquee-right { from { transform: translateX(-50%); } to { transform: translateX(0); } }

/* The cream notch at the bottom centre, carrying the ring's call to action. */
.hero__notch {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  background: var(--cream);
  border-radius: 100px 100px 0 0;
  padding: 18px 34px 14px;
  display: grid;
  justify-items: center;
  gap: 4px;
  z-index: 3;
}
.hero__notch span {
  font-size: 15px;
  font-weight: 600;
  color: var(--brown);
}
/* Whole notch is the target, not just the words — it already looks like a
   button, so a text-sized hit area is a trap. */
.hero__notch { text-decoration: none; cursor: pointer; transition: background-color .25s ease; }
.hero__notch:hover { background: var(--card-hi); }
.hero__notch:hover span { color: var(--grad-solid); }
/* The star spins continuously (Lewis, 22 Aug 2026).

   It used to rotate 90deg on hover, via transform + a transition. That had
   to go rather than sit alongside this: a running animation overrides a
   plain transform declaration, so the hover rule would still be in the
   stylesheet and silently do nothing. Hover now changes the SPEED instead,
   which composes with the animation rather than fighting it.

   The shape has eight spokes at 45deg, so it repeats visually eight times a
   revolution - 12s per turn is a visual cycle every 1.5s, which reads as a
   slow shimmer rather than a spin. Straight linear, no easing, or it
   visibly stutters at the loop point. */
.hero__notch svg {
  transform-origin: 50% 50%;
  animation: notch-spin 12s linear infinite;
}
.hero__notch:hover svg { animation-duration: 2.5s; }
@keyframes notch-spin { to { transform: rotate(360deg); } }
.hero__notch:focus-visible { outline: 2px solid var(--grad-solid); outline-offset: 3px; }
.hero__notch svg { width: 18px; height: 18px; color: var(--bronze); }

/* Credentials strip beneath the hero card — carries the booking CTA that the
   reference keeps out of the hero itself. */
.credentials {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 14px 30px;
  padding: 34px 0 4px;
  font-size: 15px;
  color: var(--brown-muted);
}
.credentials strong { color: var(--brown); font-weight: 600; }
.credentials .btn { margin-left: 6px; }

/* --- Word-scrub statement ------------------------------------------------ */

.scrub {
  font-family: var(--serif);
  font-size: clamp(26px, 3.6vw, 46px);
  line-height: 1.28;
  /* Centred full-width, as in the reference — not a narrow left column. */
  text-align: center;
  max-width: 30ch;
  margin: 22px auto 0;
}
.scrub .w {
  color: var(--scrub-off);
  transition: color .45s ease;
}
.scrub .w.on { color: var(--brown); }

.proof { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 40px; }
.proof__score {
  background: var(--grad-solid);
  background-image: var(--grad);
  color: var(--on-grad);
  width: 64px; height: 64px;
  border-radius: 100%;
  display: grid; place-items: center;
  flex: none;
}
.proof__score { font-family: var(--serif); font-size: 24px; }
.proof__stars { color: var(--bronze); letter-spacing: 2px; font-size: 14px; }

/* --- Page hero ------------------------------------------------------------
   Inner pages get a plain typographic band rather than the home page's
   photographic card. */

.page-hero {
  /* The bottom padding is a token so the mixed theme can equalise it. The
     short bottom is right when the hero shares its ground with what follows
     (black): the heading hands straight into the content. On mixed the hero
     is a visible dark band, and 24px under the lede against 88px above the
     eyebrow read as cramped — the theme sets --page-hero-pb to match the
     top. Measured, not eyeballed: 86/24 before, 86/86 after. */
  padding-block: clamp(48px, 6vw, 88px) var(--page-hero-pb, clamp(8px, 2vw, 24px));
}
.page-hero h1 {
  font-size: clamp(34px, 5vw, 64px);
  line-height: 1.08;
  max-width: 18ch;
  margin-top: 18px;
}
.page-hero .lede { margin-top: 20px; }

.nav a[aria-current="page"] { color: var(--grad-solid); }
.nav a[aria-current="page"]::after { transform: scaleX(1); background: var(--grad-solid); }

/* --- Treatment accordion -------------------------------------------------
   Full-width stacked rows rather than a card grid, matching the reference:
   each row ~200px closed, opening to reveal detail and an image. */

.accordion { display: grid; gap: 20px; }

.acc {
  background: var(--cream-deep);
  border-radius: var(--r-lg);
  overflow: hidden;
}

.acc__head {
  width: 100%;
  background: none;
  border: 0;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: start;
  gap: 20px 32px;
  padding: clamp(24px, 2.6vw, 36px);
}

.acc__icon {
  width: 44px; height: 44px;
  border-radius: 100%;
  background: var(--gold-10);
  display: grid; place-items: center;
  color: var(--gold);
  margin-bottom: 14px;
}
.acc__icon svg { width: 22px; height: 22px; }

.acc__title {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(24px, 2.6vw, 38px);
  color: var(--brown);
  line-height: 1.1;
  margin: 0 0 12px;
}

.acc__desc { margin: 0; max-width: 52ch; font-size: 16px; line-height: 1.55; }

.acc__toggle {
  grid-column: 2;
  grid-row: 1;
  flex: none;
  width: 46px; height: 46px;
  border-radius: 100%;
  background: var(--card);
  display: grid; place-items: center;
  color: var(--bronze);
  transition: transform .35s ease, background-color .25s ease;
}
.acc__toggle svg { width: 18px; height: 18px; }
.acc[open] .acc__toggle { transform: rotate(45deg); }
.acc__head:hover .acc__toggle { background: var(--card-hi); }

/* Body reveals on open */
.acc__body {
  display: grid;
  grid-template-columns: 1.15fr .85fr;
  gap: clamp(24px, 3vw, 48px);
  align-items: center;
  padding: 0 clamp(24px, 2.6vw, 36px) clamp(24px, 2.6vw, 36px);
}
.acc__list { margin: 0; padding: 0; list-style: none; display: grid; gap: 12px; }
.acc__list li {
  display: flex; justify-content: space-between; gap: 20px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--bronze-10);
  font-size: 15px;
}
.acc__list b { color: var(--brown); font-weight: 600; }
.acc__list span { color: var(--bronze); font-family: var(--serif); font-size: 18px; }

.acc__media {
  border-radius: var(--r-md);
  overflow: hidden;
  aspect-ratio: 4 / 3;
  background: var(--bronze-06);
  display: grid;
  place-items: center;
  color: var(--bronze);
  text-align: center;
  padding: 20px;
  font-size: 14px;
}

.acc summary { list-style: none; }
.acc summary::-webkit-details-marker { display: none; }

/* --- Cards --------------------------------------------------------------- */

.grid { display: grid; gap: 20px; }
.grid--2 { grid-template-columns: repeat(2, 1fr); }
.grid--3 { grid-template-columns: repeat(3, 1fr); }
.grid--4 { grid-template-columns: repeat(4, 1fr); }

/* Five cards. A fifth item in a 4-track grid is an orphan on its own row, and
   five equal columns leave 158px of text inside a 28px-padded card - so it is
   six tracks: the two specialisms wide on top, the other three across below.
   Order in the markup IS the layout here; moving a card changes its size. */
/* Six cards, and six is a much kinder number than five: it divides evenly
   by 3, 2 and 1, so no breakpoint ever strands a card alone on its own row.
   grid--5 needed a nth-child(5) special case at every width to avoid that.
   Row 1 is the body work (sculpting, post-op, lymphatic drainage), row 2 is
   face and finishing — so the grouping means something. Order IS the layout
   here as much as it was under grid--5. */
.grid--6 { grid-template-columns: repeat(3, 1fr); }
.grid--5 { grid-template-columns: repeat(6, 1fr); }
.grid--5 > * { grid-column: span 2; }
.grid--5 > :nth-child(-n+2) { grid-column: span 3; }

.card {
  background: var(--card);
  border: 1px solid var(--bronze-10);
  border-radius: var(--r-md);
  padding: 28px;
  display: grid;
  gap: 12px;
  align-content: start;
  transition: transform .35s ease, box-shadow .35s ease, border-color .35s ease;
}
/* Gated on (hover: hover) because iOS KEEPS :hover applied after a tap and
   only drops it when you tap something else. On the reviews marquee that
   left a tapped card parked 6px above its neighbours — reported 22 Aug 2026
   as "one of the boxes is higher up than the rest" on an iPhone, and it
   would look like a layout bug on any card on the site.

   A touch device gets no lift at all, which is correct: there is no pointer
   to respond to. The marquee keeps its vertical padding regardless, since a
   desktop visitor still gets the lift and still needs the headroom. */
@media (hover: hover) {
  .card:hover {
    transform: translateY(-6px);
    border-color: var(--bronze);
    box-shadow: 0 18px 40px -24px var(--shadow);
  }
}
.card h3 { font-size: 24px; }
.card p { margin: 0; font-size: 15px; line-height: 1.55; }

.card__icon {
  width: 46px; height: 46px;
  border-radius: var(--r-pill);
  /* --gold now aliases to --bronze in every theme — the gold icon discs were
     rejected on sight 20 Aug 2026 ("yellow icons"), so this is a no-op
     everywhere. The token stays because reverting it is one line if the idea
     ever returns. The testimonial avatars and the "Signature" price tag use
     the same disc treatment and are deliberately NOT on --gold - initials
     beside a real name are identity, not decoration. */
  background: var(--gold-10);
  display: grid; place-items: center;
  color: var(--gold);
}
/* 24 rather than 22: the figure and the profile carry more detail than the
   old geometric marks and need the extra couple of pixels to stay legible. */
.card__icon svg { width: 24px; height: 24px; }

/* Cards that end in a "View treatments" link put it on a bottom row of its
   own, so the four cards line their links up however long the copy runs.
   `align-content: start` packs every row to the top, which is why the link
   followed the end of the text and sat at four different heights — the
   explicit row track replaces that behaviour for these cards only. */
.card:has(.card__link) {
  align-content: stretch;
  grid-template-rows: auto auto 1fr auto;
}

.card__link {
  margin-top: 6px;
  font-size: 14px;
  font-weight: 600;
  color: var(--bronze);
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: 7px;
}
.card__link:hover { color: var(--bronze-deep); }
.card__link svg { width: 14px; height: 14px; transition: transform .25s ease; }
.card__link:hover svg { transform: translateX(4px); }

/* --- About (sticky column) ----------------------------------------------- */

/* Rounded cream block holding a two-column image / text split. */
.about__block {
  background: var(--cream-deep);
  border-radius: var(--r-lg);
  padding: clamp(24px, 3vw, 40px);
}

.about__grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(28px, 4vw, 64px);
  align-items: center;
}

.about__figure {
  position: relative;
  border-radius: var(--r-md);
  overflow: hidden;
  aspect-ratio: 4 / 5;
  background: var(--bronze-06);
  display: grid;
  place-items: center;
  color: var(--bronze);
  text-align: center;
  padding: 24px;
  font-size: 14px;
}
.about__figure img { width: 100%; height: 100%; object-fit: cover; }
/* The padding above is the mount for the placeholder TEXT state. Once a real
   photograph is in there it reads as an accidental frame, so it comes off —
   scoped with :has() so the placeholder still gets its inset. */
.about__figure:has(img) { padding: 0; }

/* Frosted credential card over the photograph */
.about__card {
  position: absolute;
  left: 16px; right: 16px; bottom: 16px;
  background: rgba(70, 34, 57, .38);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, .28);
  border-radius: var(--r-sm);
  padding: 18px 20px;
  display: grid;
  gap: 4px;
  text-align: left;
}
.about__card b {
  font-family: var(--serif);
  font-weight: 400;
  font-size: 24px;
  color: #FFFDF9;
}
.about__card span { font-size: 14px; color: rgba(255, 253, 249, .84); font-weight: 600; }

.ticks { display: grid; gap: 18px; margin: 32px 0 0; padding: 0; list-style: none; }
.ticks li { display: flex; gap: 14px; align-items: flex-start; font-size: 16px; }
.ticks svg { flex: none; width: 20px; height: 20px; color: var(--bronze); margin-top: 2px; }

/* --- Signature (Million Dollar) ------------------------------------------ */

.signature {
  background: var(--ink);
  color: rgba(255,255,255,.72);
  /* Transparent, but present. .signature--light carries a real 1px border,
     and side by side with a borderless block that 1px shifts its whole
     padding box down — enough to put the two CTAs on different lines.
     Matching the box here means the modifier only ever changes the colour. */
  border: 1px solid transparent;
  border-radius: var(--r-lg);
  padding: clamp(36px, 5vw, 72px);
  display: grid;
  grid-template-columns: 1.1fr .9fr;
  gap: clamp(28px, 4vw, 64px);
  align-items: center;
}
/* Same size as the half blocks below it — the three feature boxes carry one
   title size (Lewis, 21 Aug 2026). The block still leads by sheer area, and
   the 52px price keeps the hierarchy inside it. */
.signature h2 { color: #fff; font-size: clamp(23px, 2.3vw, 30px); }
/* Brand copper on the dark block, with room beneath it — it was sitting hard
   against the heading. */
.signature .eyebrow {
  color: #E9C6DC;
  background: rgba(200, 127, 171, .22);
  margin-bottom: 22px;
}
.signature ol { margin: 28px 0 0; padding-left: 20px; display: grid; gap: 10px; font-size: 15px; }
.signature__price {
  font-family: var(--serif);
  font-size: 52px;
  color: #fff;
  line-height: 1;
}

/* Light counterpart, for the second featured service.
   Body sculpting has two things worth leading with — the Ultimate Body Sculpt
   and lymphatic drainage — and two dark slabs one after the other is a wall.
   This is the same component on the page's own surface, so the pair reads as
   a primary and a secondary rather than as two competing hero blocks.

   Everything here is a token, so it inverts with the theme for free: on the
   black theme --cream-deep is a dark panel and --brown is near-white, and the
   block simply becomes a second, slightly different dark surface. */
.signature--light {
  background: var(--cream-deep);
  color: var(--brown-muted);
  border: 1px solid var(--bronze-10);
}
.signature--light h2 { color: var(--brown); }
.signature--light .eyebrow {
  color: var(--bronze);
  background: var(--bronze-10);
}
.signature--light .signature__price { color: var(--bronze); }
/* .btn--light is built for the dark block — it would vanish here. */
.signature--light .btn--light { background: var(--grad-solid); background-image: var(--grad); background-origin: border-box; color: var(--on-grad); }
.signature--light .btn--light:hover { background-image: var(--grad-hover); }
.signature--light strong { color: var(--brown); }

/* --- Stats bento --------------------------------------------------------- */

.bento { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
.bento .card { justify-items: start; }
.bento .stat { font-family: var(--serif); font-size: clamp(34px, 4vw, 54px); color: var(--brown); line-height: 1; }
.bento .card p { color: var(--brown-muted); }
.bento .card--wide { grid-column: span 2; }

/* --- Prices -------------------------------------------------------------- */

.price-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 40px;
  justify-content: center;
}
.price-nav button {
  font-family: var(--sans);
  font-size: 14px;
  font-weight: 600;
  padding: 10px 18px;
  border-radius: var(--r-pill);
  border: 1px solid var(--bronze-10);
  background: transparent;
  color: var(--brown-muted);
  cursor: pointer;
  transition: all .25s ease;
}
.price-nav button:hover { border-color: var(--bronze); color: var(--brown); }
.price-nav button[aria-selected="true"] { background: var(--brown); border-color: var(--brown); color: var(--cream); }

.price-group + .price-group { margin-top: 48px; }
.price-group h3 {
  font-size: 28px;
  padding-bottom: 14px;
  border-bottom: 1px solid var(--bronze-10);
  margin-bottom: 6px;
}

.price-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px 28px;
  padding: 20px 0;
  border-bottom: 1px solid var(--bronze-06);
  align-items: baseline;
}
.price-row__name { font-size: 17px; font-weight: 600; color: var(--brown); }
.price-row__meta { grid-column: 1; font-size: 13px; color: var(--brown-muted); }
.price-row__price {
  grid-row: 1 / span 2;
  grid-column: 2;
  font-family: var(--serif);
  font-size: 24px;
  color: var(--bronze);
  white-space: nowrap;
}
.price-row__desc { grid-column: 1 / -1; font-size: 14px; line-height: 1.55; margin: 6px 0 0; max-width: 70ch; }

.tag {
  display: inline-block;
  font-family: var(--label);
  font-weight: 700;
  font-size: 10px;
  letter-spacing: .06em;
  text-transform: uppercase;
  padding: 3px 8px;
  border-radius: 5px;
  background: var(--bronze-10);
  color: var(--bronze);
  margin-left: 8px;
  vertical-align: middle;
}

/* --- Studio photo band (plum preview only) --------------------------------
   Four shots between the testimonials and the closing CTA. Emitted by
   photoBlock() in tools/build-site.js and present only in a theme with
   photos: true, so the Black and Cream previews never see this markup - but
   the CSS is shared, because a second stylesheet for one block is worse than
   a few unused rules.

   The staggered second and fourth columns are the whole point. Four photos in
   a flat row reads as a contact sheet; dropping the alternates by 34px makes
   it a considered band. The stagger comes off below 940px, where the grid is
   two columns and an offset just looks like a mistake. */
.studio-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 20px;
  align-items: start;
}
.studio-grid > :nth-child(even) { margin-top: 34px; }

.studio-shot { display: grid; gap: 12px; margin: 0; }

/* The aspect-ratio MUST be explicit. The width/height attributes on the img
   only supply "auto <w>/<h>", and auto yields to the file's real ratio the
   moment it decodes - so a ratio leaning on them holds before load and then
   lets go, which looks right in every quick test and wrong on a real page
   load. Same trap as the gallery grid; see CLAUDE.md. */
.studio-shot img {
  width: 100%;
  /* height:auto is NOT optional. width/height on the img are presentational
     hints - they map to an actual height:1600px, which beats aspect-ratio and
     renders every photo at its full pixel height. Exactly the fault the
     gallery grid hit; see .gallery__item img and CLAUDE.md. */
  height: auto;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  display: block;
  border-radius: var(--r-md);
  box-shadow: 0 18px 40px -24px var(--shadow);
}

.studio-shot figcaption {
  font-family: var(--label);
  font-weight: 700;
  font-size: 12px;
  letter-spacing: -0.24px;
  text-transform: uppercase;
  /* Muted body colour, NOT the gold. Gold was tried and rejected - at 12px
     uppercase it stops reading as a metallic and just reads as yellow text,
     which is a different thing entirely. Gold survives on the eyebrow dots
     and icon discs, where it is a shape rather than type and small enough to
     read as a material. A caption under a photograph is supporting
     information and should sit back from it. */
  color: var(--brown-muted);
}

/* --- Testimonials -------------------------------------------------------- */

/* Two rows: the quote takes the slack, the attribution is pushed to the
   bottom. Reviews differ in length, and with `align-content: start` the
   avatar and name followed the end of the text, so they landed at three
   different heights across the row. The cards are already equal height —
   they are stretched grid items — so pinning the attribution to the bottom
   is what lines them up. */
/* Review cards are WHITE on every theme since 21 Aug 2026 (Lewis's request),
   so their colours are FIXED rather than tokenised - the same rule as the
   hero photo and the Million Dollar block: the ground never changes, so
   there is nothing for a theme to say about it. The values are the mixed
   theme's light-band palette (theme-mixed.css .section--alt), which was
   already solved for a near-white ground: text 8.4:1, muted 4.8:1,
   accent 4.9:1. Rows are stars / quote / attribution, with the quote row
   taking the slack so attributions stay pinned to the bottom. */
.quote {
  display: grid; grid-template-rows: auto 1fr auto; gap: 18px; align-content: stretch;
  background: #FFFCFE;
  border-color: rgba(164, 71, 127, 0.14);
}
.quote p { font-family: var(--serif); font-size: 20px; line-height: 1.45; color: #603851; margin: 0; }
.quote .muted { color: #7B5C70; }
.quote__stars { display: flex; gap: 3px; color: #A4477F; }
.quote__stars svg { width: 15px; height: 15px; fill: currentColor; }
.quote__who { display: flex; align-items: center; gap: 12px; font-size: 14px; }
.quote__avatar {
  width: 42px; height: 42px;
  border-radius: 100%;
  background: rgba(164, 71, 127, 0.10);
  display: grid; place-items: center;
  color: #A4477F;
  font-weight: 700;
  font-size: 14px;
  flex: none;
}

/* --- Reviews marquee -----------------------------------------------------
   TWO rows travelling opposite ways, since 21 Aug 2026. It was one row of
   420px cards, which was fine on a laptop and unusable on a phone: at 375px
   a single card measured 499px tall - 61% of the screen - and showed one
   review at a time. Two rows of smaller cards show four or five at once and
   read as a wall of proof rather than one slab.

   Same seamless-loop mechanics as the hero pills: each row holds two halves
   of identical width and shifts by exactly -50%, so the second half lands
   where the first began. main.js builds those halves at runtime, because how
   many copies a row needs depends on the window width - see the comment
   there, and note the row does not animate until it adds .is-looping.

   The seam rule from the snaps applies here too: every card carries the SAME
   trailing margin, including the last in each group, so the gap across the
   loop join is identical to every other gap. */
.reviews-marquee {
  overflow: hidden;
  display: grid;
  gap: 20px;
  /* Vertical padding is NOT spacing - it is headroom for .card:hover, which
     lifts the card translateY(-6px) and drops a shadow under it. The overflow
     box is exactly card-height, so without this the lift is sliced off the top
     edge and the shadow off the bottom. Reported 21 Aug 2026 as the box being
     "cut short at the top" on hover.

     Exactly the same mechanism as the clipped shadow on the treatment snaps,
     and it cannot be fixed with overflow-x: hidden + overflow-y: visible -
     CSS promotes the visible axis to auto when the other is hidden, which
     buys a scrollbar instead of headroom.

     The negative margin-top cancels the top padding back to the 12px gap the
     section had before, so this costs no layout. If the hover lift or shadow
     ever grows, grow this with it. */
  padding: 22px 0;
  margin-top: -10px;
  /* NO edge mask. There was a linear-gradient mask fading the strip out at
     both ends; on the dark themes that fade goes to the page ground, which
     reads as a black gradient washing over the white cards rather than as a
     soft edge. Removed 21 Aug 2026 at Lewis's request - the cards now simply
     clip at the viewport edge, which is what the treatment snaps' window
     does too. If a soft edge is ever wanted back it has to be a mask, not an
     overlay: an overlay would need the ground colour, and there are three
     different grounds. */
}
.reviews-marquee__row {
  display: flex;
  width: max-content;
}
/* Only a row main.js has filled animates - otherwise a short row would slide
   around leaving gaps. Durations differ so the two rows stay out of phase,
   exactly as the hero's 46s/52s pair does.

   NO will-change HERE, and this one is a real device bug rather than a
   preference - it is why the carousel rendered on a resized desktop window
   and NOT on a Samsung S24 Ultra (21 Aug 2026).

   will-change: transform forces the element into a single composited layer,
   and that layer is rasterised at DEVICE pixel ratio. These rows are wide by
   design: the narrowest possible row is one group each side, 1872 CSS px. On
   a DPR 3.5 phone that is a 6552px texture, and mobile GPUs commonly cap a
   single texture at 4096px. Past the cap the layer fails to rasterise and
   renders blank or garbled. At DPR 1 on a laptop the same row is 1872px
   against a 16384px desktop limit, so it looks perfect - which is exactly
   why narrowing a desktop window could never reproduce this.

   Without the hint Chrome still composites an animated transform, but it
   TILES the layer instead of demanding one oversized texture. The hero
   marquee gets away with will-change because its pill rows are a fraction of
   this width. Do not add it back here. */
/* These durations are FALLBACKS. main.js overrides them per row with a value
   computed from the row's real width, so the drift is a constant speed in
   px/sec rather than a constant lap time — otherwise two phones a few pixels
   apart in width get different numbers of copies and run at different
   speeds. Tune the speed in main.js, not here. */
/* The reviews use their OWN keyframes, shifting by an explicit --loop
   distance in pixels rather than -50%.

   A percentage translate resolves against the row's own width, and reading
   that width back is exactly where Safari differed: the row is a grid item
   whose max-content width overflows its column, and its getBoundingClientRect
   does not reliably report the content width there. JS was computing the
   duration from a clamped width, so the animation covered the real (much
   larger) distance in a fraction of the intended time — reported 22 Aug 2026
   as the reviews "scrolling very fast" on an iPhone.

   main.js sets --loop by summing the first half's group offsetWidths, which
   is immune to that. The 50% fallback only applies if JS never ran, and JS
   is also what adds .is-looping, so in practice it never does. */
.reviews-marquee__row--left.is-looping  { animation: reviews-left  64s linear infinite; }
.reviews-marquee__row--right.is-looping { animation: reviews-right 76s linear infinite; }
@keyframes reviews-left {
  from { transform: translateX(0); }
  to   { transform: translateX(calc(-1 * var(--loop, 50%))); }
}
@keyframes reviews-right {
  from { transform: translateX(calc(-1 * var(--loop, 50%))); }
  to   { transform: translateX(0); }
}
/* NO pause on hover - removed 21 Aug 2026 at Lewis's request. It used to
   stop both rows while the pointer was over the strip. The hover LIFT on the
   card stays, which is why .reviews-marquee keeps its vertical padding. */

.reviews-marquee__group { display: flex; }

/* Equal-height cards - Lewis, 21 Aug 2026. align-items defaults to stretch,
   so every card takes the height of the tallest in its ROW, and because both
   halves are flex items of one row that height is shared across the whole
   loop: a card does not change size as it comes round again.

   Natural heights were tried first and rejected. They only work if the quotes
   are of similar length, and the fix for THAT is the length band on REVIEWS
   in tools/build-site.js, not ragged boxes. The two go together - if a short
   review is ever added back, this will be what looks wrong. */
/* These cards are deliberately much smaller than a .card elsewhere on the
   site, and every value here is doing height work. Two rows means the card
   height is DOUBLED into the page, so the section grew to 651px on desktop
   and 865px on a phone - taller than the whole screen (1.07 viewports),
   which is what "broken on mobile" and "section is too tall" both were.

   Wider cards are what buy the height back: a wider card fits more
   characters per line, so a 216-character quote wraps to fewer lines. That
   is why these are WIDER than the phone cards were, not narrower - the
   instinct to shrink the width makes the card taller. */
/* The width is min(400px, 86vw) at EVERY breakpoint, not just inside the
   phone media query, and that is a robustness fix rather than a style
   choice - 21 Aug 2026, from a photo of a real S24 Ultra.

   The card had a flat 400px here and only became viewport-relative inside
   @media (max-width: 700px). On the handset that media query was not the one
   in play, so a 400px card landed on a ~412px viewport: one card filled the
   screen edge to edge and overflowed it. A vw ceiling means the card is
   sane whichever breakpoint applies, on a viewport width nobody predicted,
   and if the breakpoints are ever restructured. Never give these cards a
   width that cannot fit the screen. */
/* max-width MUST match the flex-basis, and this is the fix for the gaps on
   a real handset - diagnosed 21 Aug 2026 from a ?debug=1 readout on a
   Samsung S24 Ultra running FIREFOX (Gecko 153).

   The rows are `width: max-content`. A flex item's max-content CONTRIBUTION
   is not automatically its flex-basis: Firefox sized each card's
   contribution from the review's UNWRAPPED text, so a row of three 300px
   cards reserved 4329px instead of 936px. The cards still painted at 300px,
   which is why the bug is invisible to a screenshot of the cards themselves
   and why "card width 300" in the readout looked correct - the ~3400px of
   dead space sat at the END of each group, and the -50% loop dutifully slid
   it through the viewport as a blank band.

   The numbers proved it: 6.88px per character in BOTH rows, identical to
   two decimal places, which only happens if width tracks text length.

   max-width is what caps that contribution. Set it wherever flex-basis is
   set, including in the media query below - a flex-basis alone is not
   enough. Chrome happens to be forgiving here; Firefox is not, and Firefox
   on Android is a real share of traffic. */
.reviews-marquee .quote {
  flex: 0 0 min(400px, 86vw);
  max-width: min(400px, 86vw);
  margin-right: 16px;
  padding: 16px 18px;
  gap: 9px;
}
.reviews-marquee .quote p { font-size: 14px; line-height: 1.42; }
.reviews-marquee .quote__who { font-size: 13px; gap: 10px; }
.reviews-marquee .quote__avatar { width: 28px; height: 28px; font-size: 12px; }
.reviews-marquee .quote__stars { gap: 2px; }
.reviews-marquee .quote__stars svg { width: 12px; height: 12px; }

@media (max-width: 700px) {
  /* Less hover headroom on a phone: there is no hover, so the 22px only has
     to cover the shadow, not the lift. 28px of section height back. */
  .reviews-marquee { gap: 12px; padding: 8px 0; margin-top: 0; }
  .reviews-marquee .quote {
    flex: 0 0 min(300px, 82vw);
    max-width: min(300px, 82vw);   /* must track the basis — see above */
    margin-right: 12px;
    padding: 14px;
  }
  .reviews-marquee .quote p { font-size: 14px; line-height: 1.4; }
}

/* --- FAQ ----------------------------------------------------------------- */

.faq { display: grid; gap: 0; }
.faq details {
  border-bottom: 1px solid var(--bronze-10);
  padding: 22px 0;
}
.faq summary {
  cursor: pointer;
  list-style: none;
  display: flex;
  justify-content: space-between;
  gap: 24px;
  align-items: center;
  font-family: var(--serif);
  font-size: clamp(18px, 2.2vw, 22px);
  color: var(--brown);
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
  content: "";
  flex: none;
  width: 12px; height: 12px;
  border-right: 1.5px solid var(--bronze);
  border-bottom: 1.5px solid var(--bronze);
  transform: rotate(45deg);
  transition: transform .3s ease;
}
.faq details[open] summary::after { transform: rotate(-135deg); }
.faq details p { margin: 14px 0 0; max-width: 72ch; font-size: 15px; line-height: 1.6; }

/* --- CTA + Footer -------------------------------------------------------- */

.cta {
  background: var(--cream-deep);
  border-radius: var(--r-lg);
  padding: clamp(48px, 7vw, 96px) var(--gutter);
  text-align: center;
  display: grid;
  gap: 20px;
  justify-items: center;
}
.cta h2 { font-size: clamp(32px, 5vw, 60px); max-width: 16ch; }

/* --- Opening hours ------------------------------------------------------- */

.hours { margin: 0; display: grid; gap: 0; }
.hours div {
  display: flex;
  justify-content: space-between;
  gap: 24px;
  padding: 14px 0;
  border-bottom: 1px solid var(--bronze-10);
  font-size: 15px;
}
.hours dt { font-weight: 600; color: var(--brown); }
.hours dd { margin: 0; text-align: right; }
.hours .closed { color: var(--bronze); }

/* --- Footer -------------------------------------------------------------- */

/* No margin above the footer: it used to stand off the page by ~90px, which
   on the mixed themes exposed a strip of the page ground between the last
   light band and the footer panel - read as "a bar with some colour above
   the footer" and removed 21 Aug 2026 at Lewis's request. The last section
   now hands straight into the footer; the rounded corners sit directly on
   whatever ground that section ends with. */
.footer {
  background: var(--cream-deep);
  border-radius: 30px 30px 0 0;
  padding: clamp(48px, 6vw, 80px) 0 40px;
}
.footer__grid {
  display: grid;
  grid-template-columns: 1.4fr 1fr 1fr;
  gap: 40px;
}
.footer h4 {
  font-family: var(--label);
  font-weight: 700;
  font-size: 12px;
  letter-spacing: -0.24px;
  text-transform: uppercase;
  color: var(--bronze);
  margin-bottom: 18px;
}
.footer ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
.footer a { color: var(--brown-muted); text-decoration: none; font-size: 15px; }
.footer a:hover { color: var(--bronze); }
.footer__bottom {
  margin-top: 56px;
  padding-top: 24px;
  border-top: 1px solid var(--bronze-10);
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  justify-content: space-between;
  font-size: 13px;
}

/* --- Reveal on scroll ---------------------------------------------------- */

/* NO will-change here, and that is the fix for the scroll jank reported on
   21 Aug 2026 — measured, after a first guess at the Lenis config turned out
   to be wrong (see the note in main.js).

   `will-change: transform, opacity` was on the BASE selector, so it applied
   to every [data-reveal] element on the page, from first paint, forever —
   including elements whose reveal finished long ago. will-change is a
   promise that something is about to animate, and the browser keeps its
   promise by giving each one its own compositor layer and holding it. On
   /treatments that measured 37 permanent layers; the home page is similar.

   Every one of those layers has to be composited on every scrolled frame,
   and on integrated laptop graphics that is exactly where the frame budget
   goes. Dropped frames are what "jumpy and glitchy" describes — and in a
   simulation of the animator under a 25% frame-drop rate, per-frame jerk
   went up tenfold, in EVERY smoothing configuration. The frames were the
   problem, not the smoothing.

   Nothing is lost by removing it: Chrome promotes an element for the
   duration of a transform/opacity transition on its own. will-change only
   saves the promotion on the first frame, and buys that by paying for the
   layer permanently. It stays on the marquee tracks, which genuinely do
   animate forever — three or four layers, not thirty-seven.

   NOT touched, deliberately: backdrop-filter: blur(12px) on the header is
   also a real per-frame cost, but it is load-bearing for something else —
   it is what disqualifies the header from iOS 26's status-bar sampling, and
   removing it would reintroduce the Safari strip bug documented in the
   global CLAUDE.md. Do not "optimise" it away. */
[data-reveal] {
  opacity: 0.001;
  transform: translateY(46px) scale(.97);
  transition: opacity .8s cubic-bezier(.22,.61,.36,1),
              transform .8s cubic-bezier(.22,.61,.36,1);
}
[data-reveal].in { opacity: 1; transform: none; }

/* --- Responsive ---------------------------------------------------------- */

/* Nav collapses earlier than the rest of the layout: seven items plus a
   centred logo and a CTA run out of room well before 940px. */
@media (max-width: 1080px) {
  /* Keep the base 1fr auto 1fr so the wordmark stays centred: the nav is
     fixed at this width so column 1 is empty, and that empty column balances
     the burger on the right. */
  .header__inner .wordmark   { grid-column: 2; grid-row: 1; justify-self: center; }
  .header__inner .nav-toggle { grid-column: 3; grid-row: 1; justify-self: end; }
  .header__cta { display: none; }

  /* Full screen from under the header to the bottom edge, rather than a short
     drop-down. svh not vh: with vh the panel is taller than the visible area
     on a phone and its lowest item — the booking button — sits off screen. */
  .nav {
    position: fixed;
    inset: 88px 0 auto;
    /* An explicit height, NOT bottom: 0. `.header` has backdrop-filter, which
       makes it the containing block for its position:fixed children — so
       bottom: 0 resolves to the bottom of the header, not of the viewport,
       and the panel collapses to about 33px. svh so the panel ends at the
       bottom of what is actually visible with the URL bar showing. */
    height: calc(100svh - 88px);
    /* Explicit too: .nav is a grid item carrying justify-self: start from the
       header, so left:0 + right:0 alone shrink-wraps it to its content
       (241px of a 375px screen) instead of filling. */
    width: 100%;
    justify-self: stretch;
    overflow-y: auto;
    background: var(--cream);
    flex-direction: column;
    align-items: stretch;
    justify-content: center;
    gap: 0;
    padding: 8px var(--gutter) 24px;
    border-bottom: 1px solid var(--bronze-10);
    /* Must clear its own height AND the 88px it is inset from the top, or the
       bottom of the closed panel hangs over the header. -120% was a guess: it
       only hides the panel if the panel is taller than 440px, and the real
       menu is 282px, which left a 32px cream band sitting across the logo.
       Percentages here are of the element, so the 88px offset has to be added
       explicitly rather than folded into a bigger percentage. */
    transform: translateY(calc(-100% - 88px));
    transition: transform .35s ease;
  }
  /* :not(.nav__cta) — the booking button lives in this nav too and must not
     inherit the list rules. */
  .nav a:not(.nav__cta) {
    padding-block: 20px;
    border-bottom: 1px solid var(--bronze-06);
    font-family: var(--serif);
    font-weight: 400;
    font-size: 26px;
    color: var(--brown);
  }
  .nav a:not(.nav__cta)[aria-current="page"] { color: var(--bronze); }
  /* The desktop underline animation is pointless at this size. */
  .nav a::after { display: none; }

  /* `.nav a` (0,1,1) sets colour: var(--brown) and outranks `.btn--solid`
     (0,1,0), so the button rendered with brown text on the gradient. Matching
     `.nav a.nav__cta` (0,2,1) is what puts it back to white. */
  .nav a.nav__cta {
    display: inline-flex;
    justify-content: center;
    align-items: center;
    color: var(--on-grad);
    margin-top: 32px;
    /* Sized against the 75px menu rows above it — a default-height button
       looks like an afterthought next to them. */
    min-height: 62px;
    padding-block: 18px;
    font-size: 17px;
  }

  .header.is-open .nav { transform: none; }

  .nav-toggle {
    display: grid;
    gap: 5px;
    background: none;
    border: 0;
    cursor: pointer;
    padding: 8px;
  }
  .nav-toggle i { display: block; width: 22px; height: 1.5px; background: var(--brown); transition: transform .3s ease, opacity .3s ease; }
  .header.is-open .nav-toggle i:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
  .header.is-open .nav-toggle i:nth-child(2) { opacity: 0; }
  .header.is-open .nav-toggle i:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }
}

@media (max-width: 940px) {
  .about__grid,
  .acc__body,
  .signature,
  .grid--2 { grid-template-columns: 1fr; }
  .grid--4 { grid-template-columns: repeat(2, 1fr); }
  .grid--5 { grid-template-columns: repeat(2, 1fr); }
  .grid--6 { grid-template-columns: repeat(2, 1fr); }
  .studio-grid { grid-template-columns: repeat(2, 1fr); }
  .studio-grid > :nth-child(even) { margin-top: 0; }
  .grid--5 > *, .grid--5 > :nth-child(-n+2) { grid-column: span 1; }
  .grid--5 > :nth-child(5) { grid-column: span 2; }
  .grid--3 { grid-template-columns: 1fr; }
  .bento { grid-template-columns: repeat(2, 1fr); }
  .bento .card--wide { grid-column: span 2; }
  .about__sticky { position: static; }
  .footer__grid { grid-template-columns: 1fr 1fr; }

}

@media (max-width: 560px) {
  .grid--4, .bento { grid-template-columns: 1fr; }
  .grid--5 { grid-template-columns: 1fr; }
  .grid--6 { grid-template-columns: 1fr; }
  /* Deliberately stays at two columns - see the note on .studio-grid. */
  .studio-grid { gap: 14px; }
  .grid--5 > :nth-child(5) { grid-column: span 1; }
  .bento .card--wide { grid-column: span 1; }
  .footer__grid { grid-template-columns: 1fr; }
  .price-row { grid-template-columns: 1fr auto; }

  .marquee__pill { font-size: 13px; padding: 8px 16px; }
  .hero__notch { padding: 14px 24px 10px; }
  .hero__notch span { font-size: 13px; white-space: nowrap; }
  .hero__notch svg { width: 15px; height: 15px; }
}

/* --- Reduced motion ------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  .marquee__row { animation: none; }
  .hero__notch svg { animation: none; }
  [data-reveal] { opacity: 1; transform: none; transition: none; }
  * { scroll-behavior: auto !important; }

  /* The reviews become two ordinary swipeable strips. main.js never clones
     under reduced motion, so there is nothing duplicated to hide - each row
     just holds its own real cards. */
  .reviews-marquee { overflow-x: auto; }
  .reviews-marquee__row { animation: none; }
}

html { scroll-behavior: smooth; scroll-padding-top: 96px; }

/* --- Category jump nav (treatments page) --------------------------------- */

.jump {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 30px;
}
.jump a {
  font-size: 14px;
  font-weight: 600;
  padding: 10px 18px;
  border-radius: var(--r-pill);
  border: 1px solid var(--bronze-10);
  color: var(--brown-muted);
  text-decoration: none;
  transition: all .25s ease;
}
.jump a:hover { border-color: var(--grad-solid); color: var(--brown); background: var(--bronze-06); }

/* --- Hero variant: model right, copy left --------------------------------
   Used on /hero-alt for comparison. Expects a photograph framed with the
   subject on the right and clear backdrop on the left; the standard hero
   photo is centre-framed, so on that one the copy sits over her rather than
   beside her. */

/* Shorter than the standard hero: the card plus the credentials strip
   beneath it has to fit the viewport, so the strip is readable without
   scrolling. 210px covers the header, the hero padding and the strip. */
/* The card no longer computes its own height from 100vh minus a guessed
   constant — that is what made it fragile, because the constant had to
   predict how tall the strip below would render and was wrong the moment the
   strip wrapped onto two lines. Instead the section is a flex column filling
   the viewport below the header, the strip takes its natural height, and the
   card takes everything left over. The strip therefore stays a thin band at
   the bottom and cannot fall below the fold at any window size. */
.hero--split {
  --card-h: clamp(340px, 46vh, 620px);   /* floor only; flex supplies the rest */
  display: flex;
  flex-direction: column;
  min-height: calc(100vh - 89px);        /* 89px is the header */
  min-height: calc(100svh - 89px);       /* phones: see note below */
}

/* 100vh on a phone is the LARGE viewport — the height the page would have if
   the browser's URL bar were hidden. On load the bar is showing, so anything
   sized to 100vh is taller than what you can actually see and the bottom of
   it is cut off until you scroll. 100svh is the small viewport, which is what
   is visible on open. The plain vh line above stays as the fallback. */

.hero--split .hero__card {
  flex: 1 1 auto;
  min-height: var(--card-h);
}

.hero--split h1 { font-size: clamp(30px, 3.6vw, 52px); }

/* Slimmer strip on this variant so the taller card still leaves it above the
   fold — its height is what caps how tall the hero can be.

   The strip is centred in whatever space is left below the card rather than
   given fixed padding. Fixed padding cannot stay balanced: the space beneath
   it is a function of the viewport height, so any single pair of values is
   right at one window size and wrong at every other. Centring makes the gap
   above the strip equal the gap below it at every height, including where
   --card-h hits either end of its clamp. */
/* The thin strip. Equal padding top and bottom is all that is needed now —
   it is the card that flexes, not this. */
.hero--split > .wrap {
  flex: 0 0 auto;
  padding-top: 14px;
  padding-bottom: 14px;
}

/* Above the phone breakpoint the strip gets more air. The section height is
   fixed, so whatever is added here comes off the card — 19px each side takes
   about 1cm off the photograph and gives the line room to breathe. Phones keep
   the tighter 14px: there is no spare height to give away there. */
@media (min-width: 701px) {
  .hero--split > .wrap {
    padding-top: 33px;
    padding-bottom: 33px;
  }
}

.hero--split + .wrap .credentials,
.hero--split .credentials {
  flex: 1 1 auto;   /* single flex child — take the full width of the wrap */
  padding: 0;
  gap: 10px 24px;
  font-size: 14px;
}
.hero--split .credentials .btn { padding: 9px 18px; font-size: 14px; }

.hero--split .hero__content--left {
  text-align: left;
  justify-items: start;
  max-width: min(560px, 48%);
  margin-inline: 0;
  padding-left: clamp(24px, 5vw, 72px);
}
.hero--split .hero__content--left h1 { max-width: 15ch; }
.hero--split .hero__content--left .hero__sub { max-width: 42ch; }

/* Glow leans left so the copy has something to sit on, rather than washing
   evenly across the whole frame. */
.hero--split .hero__scrim {
  background:
    linear-gradient(105deg,
      rgba(154, 66, 120, .78) 0%,
      rgba(168, 84, 133, .56) 26%,
      rgba(186, 108, 152, .26) 46%,
      rgba(206, 150, 183, 0) 66%),
    linear-gradient(to bottom,
      rgba(154, 66, 120, .42) 0%,
      rgba(168, 84, 133, .22) 22%,
      rgba(206, 150, 183, 0) 48%),
    linear-gradient(to bottom, rgba(46, 22, 38, .16) 0%, rgba(46, 22, 38, 0) 40%, rgba(46, 22, 38, .16) 100%);
}


@media (max-width: 940px) {
  /* Below this the photo has no room to sit beside the copy — fall back to
     the centred treatment. */
  .hero--split .hero__content--left {
    text-align: center;
    justify-items: center;
    max-width: 940px;
    padding-left: var(--gutter);
    margin-inline: auto;
  }
  .hero--split .hero__content--left h1 { max-width: 17ch; }
  .hero--split .hero__notch { left: 50%; }
}

/* --- Hero on a phone -----------------------------------------------------
   The card stops filling the viewport here. On a phone the strip below it is
   the useful part — three reasons to trust the place, and the booking button —
   and it has to be on screen the moment the page opens, not one scroll away.
   So the card fills the screen and the strip sits on its bottom edge — the
   points and the button are the last thing in view rather than the first
   thing below the fold. This only works because the section is measured in
   svh, the viewport as it really is with the URL bar showing. An earlier
   version gave the card a fixed short height instead: the strip was on
   screen, but it wasted the bottom third of the display. */
@media (max-width: 700px) {
  /* The desktop floor runs to 620px, taller than a small phone leaves once
     the strip is accounted for — and a floor that cannot be met pushes the
     strip back off the bottom edge. Let flex decide the height here. */
  .hero--split .hero__card {
    min-height: 0;
  }

  /* The phone crop is cut to roughly the card's own portrait shape, so it
     needs almost no repositioning — just centre it. */
  .hero--split .hero__card img { object-position: 50% 50%; }

  /* Centring her means the copy now sits over her face rather than over
     background, so the scrim has to work harder here. The desktop version
     leans left, which is no use when the copy is centred: this one is a
     straight top-down wash. */
  .hero--split .hero__scrim {
    background:
      linear-gradient(to bottom,
        rgba(140, 58, 108, .88) 0%,
        rgba(154, 66, 120, .72) 34%,
        rgba(186, 108, 152, .40) 58%,
        rgba(206, 150, 183, .12) 78%,
        rgba(206, 150, 183, 0) 100%),
      linear-gradient(to bottom, rgba(46, 22, 38, .22) 0%, rgba(46, 22, 38, 0) 46%);
  }

  /* The three points were a wrapping line of 14px muted text — they read as
     small print under a large photograph and disappeared. As tiles they get
     a surface of their own, and the number/keyword leads. */
  .hero--split > .wrap .credentials {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    align-items: stretch;
    gap: 8px;
  }

  .hero--split > .wrap .credentials > span {
    display: grid;
    align-content: start;
    gap: 3px;
    text-align: center;
    background: var(--bronze-06);
    border: 1px solid var(--bronze-10);
    border-radius: var(--r-md);
    padding: 11px 6px 12px;
    font-size: 11px;
    line-height: 1.25;
  }

  .hero--split > .wrap .credentials > span strong {
    display: block;
    font-size: 15px;
    line-height: 1.2;
  }

  .hero--split > .wrap .credentials .btn {
    grid-column: 1 / -1;
    width: 100%;
    justify-content: center;
    margin-left: 0;
    margin-top: 2px;
    padding-block: 13px;
  }
}

/* --- Find us: copy beside a map ----------------------------------------
   Google's keyless embed (maps?q=...&output=embed), the same approach as
   garagetwenty.co.uk. It is an iframe, so nothing about its colours or its
   marker can be styled — theming would mean the Maps JavaScript API, a
   Cloud project and a billing card. The card around it does the work of
   making it sit in the palette. */

.findus {
  display: grid;
  grid-template-columns: 1fr 1.05fr;
  gap: clamp(32px, 5vw, 72px);
  align-items: center;
}

.findus__copy h2 {
  font-size: clamp(28px, 3.4vw, 44px);
  margin-top: 16px;
  max-width: 16ch;
}
.findus__copy .lede { margin: 18px 0 26px; max-width: 46ch; }

.findus__map {
  height: clamp(320px, 38vw, 460px);
  border-radius: var(--r-lg);
  overflow: hidden;          /* clips the iframe's square corners */
  background: var(--cream-deep);
  border: 1px solid var(--bronze-10);
  box-shadow: 0 24px 60px -32px var(--shadow-soft);
}

.findus__map iframe {
  display: block;            /* kills the inline-element baseline gap */
  width: 100%;
  height: 100%;
  border: 0;
}

@media (max-width: 900px) {
  /* Side by side stops working before the map gets unusably narrow. */
  .findus { grid-template-columns: 1fr; }
  .findus__map { height: clamp(280px, 60vw, 380px); }
}

@media (max-width: 620px) {
  .findus__copy .hero__cta .btn { width: 100%; justify-content: center; }
}

/* --- Theme switch (preview scaffolding) ----------------------------------
   Lets the cream and black views be compared without hand-editing the URL.
   Not part of the design — it comes out with the losing theme, or by setting
   THEME_SWITCH to false in tools/build-site.js.

   Deliberately NOT nested inside .header: that carries backdrop-filter, which
   would make it the containing block for any position:fixed child and pin
   this to the header's box instead of the viewport. */
/* Bottom RIGHT, not bottom centre: the hero's credentials strip is centred and
   sits at the bottom of the first screen, so a centred switch lands straight
   on top of "Free consultations & patch tests". */
.theme-switch {
  position: fixed;
  z-index: 200;
  right: 18px;
  bottom: 18px;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 5px 6px 5px 14px;
  border-radius: var(--r-pill);
  background: var(--card);
  border: 1px solid var(--bronze-10);
  box-shadow: 0 10px 30px -12px var(--shadow);
  font-size: 13px;
  font-weight: 600;
}
.theme-switch span {
  font-family: var(--label);
  font-size: 10px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--brown-muted);
  margin-right: 4px;
}
.theme-switch a {
  padding: 6px 14px;
  border-radius: var(--r-pill);
  text-decoration: none;
  color: var(--brown);
  transition: background-color .2s ease, color .2s ease;
}
.theme-switch a:hover { background: var(--bronze-10); }
.theme-switch a[aria-current] {
  background: var(--grad-solid);
  background-image: var(--grad);
  background-origin: border-box;
  color: var(--on-grad);
}

@media (max-width: 560px) {
  /* On a phone the hero's booking button runs the full width at the bottom,
     so the switch moves up clear of it rather than just shrinking. */
  .theme-switch { right: 10px; bottom: 10px; padding-left: 10px; font-size: 12px; }
  .theme-switch span { display: none; }
  .theme-switch a { padding: 6px 12px; }
}

/* --- Splash (home page only) ---------------------------------------------
   Glitter and glow around the wordmark, which then flies into its place in
   the header. Runs on every load and refresh.

   The markup is in the HTML rather than injected, so it paints on the first
   frame — injecting it would show the real page first and then cover it.
   The price of that is that a JS failure would park it over the page, so the
   fail-safe animation below hides it at 3.2s no matter what. JS only ever
   makes it leave sooner. Do not remove that animation.

   z-index sits above the header (100) and the theme switch (200). */
/* Arrived via the preview theme switcher: the inline head script sets this
   before first paint, so the splash never even flashes. main.js then goes
   straight to finish() and removes the node. */
.no-splash .splash { display: none; }

.splash {
  position: fixed;
  inset: 0;
  z-index: 999;
  display: grid;
  place-items: center;
  background: var(--cream);
  animation: splash-failsafe 1ms linear 3.2s forwards;
}
@keyframes splash-failsafe {
  to { opacity: 0; visibility: hidden; pointer-events: none; }
}

.splash.is-out {
  opacity: 0;
  transition: opacity .4s ease;
  pointer-events: none;
}

.splash__stage {
  position: relative;
  display: grid;
  place-items: center;
}

.splash__fx {
  position: absolute;
  inset: -40% -22%;
  transition: opacity .34s ease;
}
.splash.is-flying .splash__fx { opacity: 0; }

.splash__glow {
  position: absolute;
  left: 50%; top: 50%;
  width: min(84vw, 640px);
  aspect-ratio: 1;
  margin: 0;
  translate: -50% -50%;
  border-radius: 50%;
  background: radial-gradient(circle, var(--grad-solid) 0%, transparent 62%);
  filter: blur(44px);
  opacity: 0;
  animation: splash-glow 2.4s cubic-bezier(.3,.7,.4,1) 80ms both;
}
@keyframes splash-glow {
  0%   { opacity: 0;   transform: scale(.55); }
  40%  { opacity: .55; transform: scale(1); }
  100% { opacity: .38; transform: scale(1.08); }
}

.splash__sparks { position: absolute; inset: 0; }

/* Four-pointed glitter. Position, size, delay and colour come in as custom
   properties from main.js so each one differs — a fixed set reads as a
   pattern rather than as glitter. The negative margin centres each spark on
   its own point. */
.splash__spark {
  position: absolute;
  left: var(--x); top: var(--y);
  width: var(--s); height: var(--s);
  margin: calc(var(--s) / -2);
  background: var(--c);
  clip-path: polygon(50% 0%, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0% 50%, 40% 40%);
  opacity: 0;
  animation: splash-spark 1.25s ease-in-out var(--d) infinite;
}
@keyframes splash-spark {
  0%, 100% { opacity: 0; transform: scale(.2) rotate(0deg); }
  50%      { opacity: 1; transform: scale(1)  rotate(90deg); }
}

.splash__logo {
  position: relative;
  z-index: 1;
  display: block;
  width: clamp(200px, 40vw, 400px);
  height: auto;
  opacity: 0;
  transform: scale(.86);
  animation: splash-logo .62s cubic-bezier(.22,.9,.3,1) 60ms forwards;
  will-change: transform, opacity;
}
@keyframes splash-logo {
  to { opacity: 1; transform: scale(1); }
}

/* The splash locks the page. overflow alone is not enough — Lenis drives
   scrolling itself — so main.js stops it too, and both are released in every
   exit path including the failure ones. */
html.is-splashing { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
  /* No intro at all. main.js removes the element outright; this covers the
     window before it runs, and the case where it never does. */
  .splash { display: none; }
}

/* --- Featured service blocks, half width ---------------------------------
   Two of these sit side by side under the full-width signature block, in a
   plain .grid.grid--2 — which already collapses to one column at 940px, so
   this variant needs no breakpoint of its own.

   Single column inside: .signature's 1.1fr/.9fr split is for a full-width
   block, and at half the width the price column is too narrow to hold a
   52px serif figure without wrapping it.

   grid-template-rows + align-content: stretch is the same fix as the
   testimonial attributions and the "What we do" card links — the two blocks
   are already equal height as stretched grid items, but the default
   align-content packs their rows to the top, so the price and CTA followed
   the end of the copy and the two landed at different heights. Verify by
   measuring both .stack tops, not by eye. */
.signature--half {
  grid-template-columns: 1fr;
  grid-template-rows: 1fr auto;
  align-content: stretch;
  align-items: start;
  padding: clamp(24px, 2.6vw, 34px);
  gap: 20px;
}
.signature--half .eyebrow { margin-bottom: 16px; }
/* :not(.eyebrow) matters: the eyebrow is a <p> and this rule outweighs
   .eyebrow's own 12px, which was silently inflating the two half blocks'
   pill badges to 15px against 12px everywhere else. */
.signature--half p:not(.eyebrow) { font-size: 15px; }
.signature--half ol { margin-top: 16px; gap: 8px; font-size: 14px; }
.signature--half .signature__price { font-size: 34px; }

/* Price and CTA share a row instead of stacking. Stacked, these came out at
   584px against the full-width block's 550px — the pair meant to sit UNDER
   the signature treatment was taller than it, which inverts the hierarchy.
   Side by side there is easily room at this width, and it takes ~80px out. */
.signature--half .stack {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 14px 20px;
}
.signature--half .stack .btn { padding: 11px 20px; font-size: 14px; }

/* --- Price ladder --------------------------------------------------------
   The course is the offer, so it leads. The single session drops to the
   bottom as a footnote rather than being the headline it used to be —
   quoting "from £99" first buries the fact that a course is £70 or £60 a
   session, which is the actual reason to book one. */
.price-ladder { display: grid; gap: 0; }
.price-ladder__label { margin: 6px 0 0; font-size: 14px; }
.price-ladder__alt {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 10px;
  margin-top: 18px;
  padding-top: 16px;
  /* On the dark block in both themes, so a white alpha rather than a token. */
  border-top: 1px solid rgba(255, 255, 255, .16);
}
.price-ladder__alt b {
  font-family: var(--serif);
  font-weight: 400;
  font-size: 27px;
  line-height: 1;
  color: #fff;
}
.price-ladder__alt span { font-size: 14px; }
.price-ladder__single {
  margin: 12px 0 0;
  font-size: 14px;
}

/* The light variant sits on the page surface, so its rules and figures take
   the brand colours rather than white. */
.signature--light .price-ladder__alt { border-top-color: var(--bronze-10); }
.signature--light .price-ladder__alt b { color: var(--bronze); }

/* --- Gallery -------------------------------------------------------------
   CSS multi-column masonry rather than a grid of fixed cells, so a photo is
   never forced into a shape it was not shot in. Columns are 300px minimum and
   the browser fits as many as the width allows.

   This works ONLY because js/gallery.js gives every img its real width and
   height attributes. Without them the columns balance against zero-height
   boxes and everything reflows as the photos decode. */
.gallery {
  columns: 3 300px;
  column-gap: 20px;
}

.gallery__item {
  display: block;
  margin-bottom: 20px;
  /* Without this a tile can be split across a column break. */
  break-inside: avoid;
  overflow: hidden;
  border-radius: var(--r-md);
  background: var(--cream-deep);
  border: 1px solid var(--bronze-10);
  position: relative;
  line-height: 0;
}

.gallery__item img {
  width: 100%;
  /* Load-bearing. The width/height attributes are presentational hints, so
     without this the height applies literally in CSS pixels and every photo
     stretches to its full pixel height. Deleting this line does not unstyle
     the gallery — it wrecks it. */
  height: auto;
  display: block;
  transition: transform .6s ease, opacity .4s ease;
}

/* Set by js/gallery.js on any photo whose shape falls outside 3:2 – 2:3. The
   tile takes the nearest allowed shape and the photo is centre-cropped into
   it, rather than rendering as a letterbox sliver or a tower. The lightbox is
   unaffected and still shows the whole frame. */
.gallery__item img.is-clamped {
  aspect-ratio: var(--tile-ratio);
  object-fit: cover;
  object-position: center;
}

.gallery__item:hover img { transform: scale(1.03); }

@media (prefers-reduced-motion: reduce) {
  .gallery__item img { transition: none; }
  .gallery__item:hover img { transform: none; }
}

/* Shown until the fetch resolves, and left up if there is nothing to show.
   A gallery with no photos has to read as deliberate rather than broken —
   this one starts empty by definition. */
.gallery__empty {
  border: 1px solid var(--bronze-10);
  border-radius: var(--r-lg);
  background: var(--cream-deep);
  padding: clamp(40px, 6vw, 76px) var(--gutter);
}
.gallery__empty-inner {
  max-width: 46ch;
  margin-inline: auto;
  display: grid;
  gap: 16px;
  justify-items: center;
  text-align: center;
}
.gallery__empty-inner h2 { font-size: clamp(26px, 3.4vw, 40px); }

/* --- Lightbox ------------------------------------------------------------
   Deliberately a direct child of body, not of the grid: .header carries a
   backdrop-filter and any transformed or filtered ancestor becomes the
   containing block for a position: fixed child, which would collapse this to
   that element's box instead of the viewport. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 300;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 5vh 5vw;
  /* Same two fallbacks as .header, and they matter more here: without the
     opaque first line an unsupported color-mix leaves the lightbox with no
     background at all, so a photo opens over the live page instead of over
     a dimmed one. See the note on .header. */
  background: var(--cream);
  background: color-mix(in srgb, var(--cream) 92%, transparent);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
}
.lightbox.is-open { display: flex; }

.lightbox__img {
  max-width: 100%;
  max-height: 90vh;
  width: auto;
  height: auto;
  border-radius: var(--r-md);
  /* The full frame, uncropped — the point of opening it. */
  object-fit: contain;
  box-shadow: 0 30px 80px -40px var(--shadow);
}

.lightbox__close,
.lightbox__nav {
  position: absolute;
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  border: 1px solid var(--bronze-10);
  border-radius: 100%;
  background: var(--card);
  color: var(--brown);
  cursor: pointer;
  transition: background-color .2s ease, border-color .2s ease;
}
.lightbox__close:hover,
.lightbox__nav:hover { background: var(--card-hi); border-color: var(--bronze); }
.lightbox__close svg,
.lightbox__nav svg { width: 20px; height: 20px; }

.lightbox__close { top: 18px; right: 18px; }
.lightbox__nav--prev { left: 18px; }
.lightbox__nav--next { right: 18px; }

@media (max-width: 560px) {
  .lightbox { padding: 8vh 3vw; }
  .lightbox__nav { bottom: 18px; top: auto; }
  .lightbox__nav--prev { left: calc(50% - 58px); }
  .lightbox__nav--next { right: calc(50% - 58px); }
}

/* --- Gallery editing (admin only) ----------------------------------------
   Nothing here renders for a visitor: js/gallery-admin.js only adds .is-admin
   to <body> once /api/admin/session confirms an authorised identity, and that
   route is behind Cloudflare Access. */
/* Drag handle for reordering, replacing the old arrows - 22 Aug 2026.

   touch-action: none is REQUIRED, not decoration: without it the browser
   treats a finger on the handle as a scroll gesture and pointermove never
   reaches us, so drag silently does nothing on a tablet - which is very
   likely where this gets used.

   cursor: grab / grabbing is the only affordance saying it is draggable, so
   keep both halves. */
.gallery__grip {
  touch-action: none;
  cursor: grab;
  letter-spacing: -2px;
}
.gallery__grip:active { cursor: grabbing; }

/* --- The admin grid is NOT the visitor's masonry -------------------------
   Rewritten 23 Aug 2026, reported as "drag and drop works very poorly". This
   layout override IS most of the fix; the JS rewrite in gallery-admin.js is
   the rest. Three faults compounded, and none of them was the drag code:

   1. `columns: 3 300px` is CSS MULTI-COLUMN, which flows DOWN a column before
      moving right. So "later in the sequence" is UP-AND-RIGHT once you leave
      a column: you had to drag a photo upwards to move it later. This is the
      same geometry that made the old arrows point the wrong way on 22 Aug —
      replacing them with a drag handle inherited the problem instead of
      solving it.
   2. Every insert reflows all three columns, so tiles jumped between columns
      mid-drag, the tile under the pointer changed, and the insert fired
      again. The drop target oscillated under a completely still finger.
   3. Measured on the real admin page at 1152px wide: tiles were 494–555px
      tall against a 675px viewport. ONE tile was fully visible at a time and
      the grid ran to 2100px, so most drop positions were simply off-screen
      with no auto-scroll to reach them.

   A uniform grid answers all three at once — reading order is left-to-right
   and top-to-bottom like everything else, every tile is the same size so an
   insert cannot resize anything, and small tiles put the whole set on one
   screen. The visitor's masonry is untouched: she is choosing an ORDER here,
   not judging the composition, and the two jobs want different layouts. */
.is-admin .gallery {
  columns: auto;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 14px;
}

/* Phones, and this is the case that matters: Sabrina edits on a phone.
   MEASURED at 360 / 390 / 414 / 430px - minmax(200px, 1fr) resolves to ONE
   column at every one of them, giving 312-382px squares and a grid about
   2800px tall. That is the same "one tile at a time, drag it miles" problem
   the masonry had, just in a different shape, so the desktop fix alone would
   have left the primary user exactly where she started.

   150px is not arbitrary. It is the largest floor that still yields TWO
   columns on a 360px phone, and two is the most columns the tools row fits:
   the three buttons need 34*3 + 6*2 = 114px, and a tile inset 10px each side
   at 2 columns on a 360px screen leaves 130px. Three columns would be ~118px
   wide and the ALT and remove buttons would overflow their own tile.

   The padding-bottom clears the fixed Add photos pill in the bottom-left
   corner, which otherwise lands on top of the last row - and the last cell
   is the add TILE, so the two add controls would have overlapped each
   other. */
@media (max-width: 700px) {
  .is-admin .gallery {
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
    gap: 12px;
    padding-bottom: 76px;
  }
}

.is-admin .gallery__item {
  margin-bottom: 0;
  aspect-ratio: 1;
}

/* Square thumbnails. The second selector is not redundant: it has to beat
   .gallery__item img.is-clamped, which sets its own aspect-ratio from the
   stored dimensions and would otherwise win on specificity. */
.is-admin .gallery__item img,
.is-admin .gallery__item img.is-clamped {
  height: 100%;
  aspect-ratio: auto;
  object-fit: cover;
  object-position: center;
}

/* Tools are always visible in admin. At 200px a hover-to-reveal control is a
   guessing game, and the grip has to be findable before anything can be
   dragged at all.

   The extra .gallery__item in the selector is doing real work: the base
   .is-admin .gallery__tools rule that sets opacity: 0 lives FURTHER DOWN
   this file, so at equal specificity it wins on order. Verified on the live
   admin page - the first version of this rule computed to 0. */
.is-admin .gallery__item .gallery__tools { opacity: 1; }

/* Add photos, as the last cell of the grid - a second entry point beside
   the fixed pill, which is off at the bottom-left corner by the time she has
   scrolled to the end of the photos. Sized and shaped exactly like a tile so
   the row it lands in stays even.

   It is a <button>, not a .gallery__item: the reorder code counts
   .gallery__item to derive indices. See mountAdd() in gallery-admin.js. */
.is-admin .gallery__add {
  aspect-ratio: 1;
  display: grid;
  place-content: center;
  gap: 8px;
  justify-items: center;
  border: 1px dashed var(--bronze);
  border-radius: var(--r-md);
  background: transparent;
  color: var(--brown);
  cursor: pointer;
  font: inherit;
  font-size: 13px;
  text-align: center;
  padding: 12px;
  transition: background-color .2s ease, border-color .2s ease;
}
.is-admin .gallery__add svg {
  width: 26px;
  height: 26px;
}
@media (hover: hover) {
  .is-admin .gallery__add:hover {
    background: var(--card);
    border-color: var(--brown);
  }
}
.is-admin .gallery__add:focus-visible {
  outline: 2px solid var(--brown);
  outline-offset: 2px;
}

/* The tile left behind while the ghost follows the pointer. It stays in the
   DOM and in flow and acts as its own placeholder — DOM order is the single
   source of truth for where the drop will land, so there is nothing separate
   to keep in sync. */
.gallery__item.is-dragging {
  opacity: .35;
  outline: 2px dashed var(--bronze);
  outline-offset: -2px;
}
.gallery__item.is-dragging img { visibility: hidden; }
.gallery__item.is-dragging .gallery__tools { opacity: 0; }

/* The thing actually under the pointer, and the reason a drag now reads as
   picking something up. pointer-events: none is load-bearing — without it
   the ghost wins every hit-test against the tile it is hovering over, which
   is the trap the old code worked around by toggling pointerEvents on and
   off around a single elementFromPoint call. */
.gallery__ghost {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 400;
  margin: 0;
  pointer-events: none;
  opacity: .92;
  border-radius: var(--r-md);
  overflow: hidden;
  box-shadow: 0 24px 60px -20px var(--shadow);
  transform-origin: top left;
}
.gallery__ghost .gallery__tools { display: none; }

/* While a drag is in progress: kill text selection across the grid, and stop
   the hover zoom fighting the FLIP transitions the JS applies. Selection in
   particular is what makes a half-finished drag feel broken. */
body.is-reordering {
  user-select: none;
  -webkit-user-select: none;
  cursor: grabbing;
}
body.is-reordering.is-admin .gallery__tools { opacity: 1; }
body.is-reordering .gallery__item { cursor: grabbing; }
body.is-reordering .gallery__item:hover img { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .gallery__ghost { box-shadow: none; }
}

.gallery__tools { display: none; }

.is-admin .gallery__tools {
  display: flex;
  gap: 6px;
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: 10px;
  justify-content: center;
  opacity: 0;
  transition: opacity .2s ease;
}
.is-admin .gallery__item:hover .gallery__tools,
.is-admin .gallery__item:focus-within .gallery__tools { opacity: 1; }

.is-admin .gallery__tools button {
  flex: none;
  min-width: 34px;
  height: 34px;
  padding: 0 9px;
  border: 1px solid var(--bronze-10);
  border-radius: 100px;
  background: var(--card);
  color: var(--brown);
  font: 600 12px/1 var(--sans);
  cursor: pointer;
}
.is-admin .gallery__tools button:hover:not(:disabled) {
  background: var(--card-hi);
  border-color: var(--bronze);
}
.is-admin .gallery__tools button:disabled { opacity: .35; cursor: default; }
.is-admin .gallery__tools button[data-remove] { color: #C2405B; }

/* Sits above the theme switch (200) but below the lightbox (300). */
.admin-bar {
  position: fixed;
  z-index: 250;
  left: 18px;
  bottom: 18px;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 9px 10px 9px 16px;
  border-radius: var(--r-pill);
  background: var(--card);
  border: 1px solid var(--bronze-10);
  box-shadow: 0 12px 34px -14px var(--shadow);
}
.admin-bar__dot {
  width: 8px; height: 8px;
  border-radius: 100%;
  background: #3FA96B;
  flex: none;
}
.admin-bar__label {
  font-family: var(--label);
  font-size: 11px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--brown-muted);
}
.is-uploading .admin-bar { pointer-events: none; opacity: .6; }
.is-uploading .admin-bar__dot { background: var(--bronze); animation: admin-pulse 1s ease-in-out infinite; }
@keyframes admin-pulse { 50% { opacity: .25; } }

@media (max-width: 560px) {
  .admin-bar { left: 10px; bottom: 10px; padding-left: 12px; }
  .admin-bar__label { display: none; }
  /* Tap targets rather than hover targets on a phone. */
  .is-admin .gallery__tools { opacity: 1; }
}

/* --- About: the story column ---------------------------------------------
   Portrait pinned on the left while her words scroll past it. There was
   already an unused `.about__sticky` rule in the responsive block, so this
   was the original intent for the page and never got built.

   The STICKY ELEMENT IS THE FIGURE, not the column. A grid item with
   `align-items: start` shrink-wraps to its content, and a sticky element can
   only travel inside its own containing block — so pinning the column itself
   gives it nothing to move within and it does not stick. Leaving the column
   stretched (the default) and sticking the figure inside it is what works.

   Nothing in the ancestor chain may take `overflow: hidden` either; that
   silently kills sticky everywhere below it. Checked: .wrap, .section and
   main are all clear. */
.about-story__grid {
  display: grid;
  grid-template-columns: 0.82fr 1fr;
  gap: clamp(32px, 5vw, 76px);
}

.about-story__media > .about__figure {
  position: sticky;
  /* 88px header, plus air so she is not jammed under it. */
  top: 112px;
  margin: 0;
}

/* Each block gets real separation, so the scroll has distinct stops to travel
   between rather than one undifferentiated run of text. */
.about-story__copy {
  display: grid;
  gap: clamp(44px, 6vw, 80px);
  align-content: start;
}
.about-story__copy > * { display: grid; gap: 14px; }
.about-story__copy h2 {
  font-size: clamp(26px, 3.2vw, 40px);
  margin: 0;
}
.about-story__copy p { margin: 0; }
.about-story__copy .eyebrow { justify-self: start; }
/* The page h1 lives in the first copy block since 21 Aug 2026 - the separate
   page-hero above the grid put the portrait 230px below the fold. Sized a
   step up from the column's h2s so it still reads as the page title. */
.about-story__copy h1 {
  font-size: clamp(32px, 4vw, 50px);
  line-height: 1.08;
  margin: 0;
}
/* On short viewports the full 4:5 portrait is taller than the screen even
   with the hero gone. The cap goes on the FIGURE - its own aspect-ratio: 4/5
   is what fixes the box height, so capping the img alone just floats a
   shorter photo inside an untouched 4:5 frame. The img fills whatever box
   remains and object-position: top crops from the bottom, so it is her
   feet-end that gives, never her face. At ~800px tall and above this never
   engages and the true 4:5 renders. */
.about__figure {
  /* 190 = the 172px of header + section padding above the figure, measured
     at desktop, plus breathing room below. */
  max-height: calc(100svh - 190px);
}
.about__figure img { object-position: top; }

@media (max-width: 940px) {
  /* Stacked, and the portrait stops pinning — there is nothing beside it to
     scroll past, so a pinned image would just sit there taking up the screen.
     RELATIVE, not static: the credential card is position:absolute against
     this figure, and static un-anchors it — the card then resolves against
     the page and rendered at the bottom of the section, straddling the Book
     button. Found on a real handset 21 Aug 2026. Relative stops the pinning
     just the same and keeps the card on the photo; top must also clear or
     the sticky offset shifts the figure down 112px. */
  .about-story__grid { grid-template-columns: 1fr; gap: 40px; }
  .about-story__media > .about__figure { position: relative; top: auto; }
  .about-story__copy { gap: 44px; }
}

/* The closing line, scrubbed word by word — same machinery as the home page
   statement, so it reads as part of the site rather than a bolted-on effect.
   Wider than the home page's 30ch because it is three short sentences.
   margin-top 0: .scrub's 22px top margin spaces it under the home page's
   eyebrow, but this one sits alone in its band — the margin just made the
   band's padding read 116/94 instead of an even 94/94. */
.scrub--close { max-width: 24ch; margin-top: 0; }

/* --- Featured stack ------------------------------------------------------
   The Ultimate Body Sculpt block and the pair beneath it, in one container so
   the vertical gap between them is the same 20px as the horizontal gap
   between the pair. They used to be two separate <section>s, which put a
   section's worth of padding (~100px) between them and 20px beside them —
   so the three blocks read as two unrelated groups rather than one set. */
.feature-stack {
  display: grid;
  gap: 20px;
}

/* Shorter. Padding was 37% of the block's height at 72px a side, which is
   what made it tower over the pair below rather than lead it. */
.feature-stack > .signature:not(.signature--half) {
  padding: clamp(30px, 3.6vw, 52px);
}
/* No h2 override here any more: all three feature boxes carry the one title
   size set on .signature h2 (Lewis, 21 Aug 2026). */
.feature-stack > .signature:not(.signature--half) ol {
  margin-top: 22px;
  gap: 9px;
}
