/* ==========================================================================
   THE HEALING BENCH. Design system, owned entirely by Agent S.
   Massage therapy and wellness in Beamsville.

   DIRECTION-V3.md is binding on everything visual: warm spa editorial.
   Alternating warm bands (espresso, tan, cream), Playfair Display for
   display type, Jost for body and kickers, no mono face anywhere, no
   chromatic accent anywhere. REDESIGN-PLAN.md and BUILD-CONTRACT-V2.md's
   "one environment" and Lamborghini/ember palette are superseded on look;
   their motion contract, file ownership and the copy gate still stand.

   Read before touching this file: DIRECTION-V3.md, BUILD-CONTRACT-V2.md
   (DOM/motion sections only), haydn-house-style. Phone first at 390px,
   then widened. Animate only transform and opacity; never filter, width,
   height, top or left, for anything continuous. Discrete hover/state
   changes on colour are fine.
   ========================================================================== */

/* --------------------------------------------------------------------------
   0. RESET
   -------------------------------------------------------------------------- */

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

html, body {
  margin: 0;
  padding: 0;
}

/* Overflow guard, hidden declared first as the fallback, clip as the real rule. */
html, body {
  overflow-x: hidden;
  overflow-x: clip;
}

html {
  color-scheme: light;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  min-height: 100dvh;
  min-height: 100vh;
  font-family: var(--font-body);
  background: var(--cream);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

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

/* A neutral, espresso-tinted 1px line on every framed photograph: with
   no chromatic accent and a warm palette end to end, a themed or
   literal-black outline would read as a colour decision rather than a
   material fact. Inset by its own width so it hugs a card's 16px radius
   instead of squaring the corner off. Excluded on the two full-bleed
   backdrops (hero, book) which have no frame to hug in the first
   place — an inset line there would just draw a faint box around the
   whole viewport. */
img {
  outline: 1px solid rgba(23, 18, 14, .08);
  outline-offset: -1px;
}

.hero__video, .hero__poster, .book__media,
.fees__ground img, .faq__ground img, .reviews__ground img {
  outline: none;
}

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

a {
  color: inherit;
}

ul, ol {
  list-style: none;
  margin: 0;
  padding: 0;
}

p {
  margin: 0;
}

h1, h2, h3, h4, h5, h6 {
  margin: 0;
}

table {
  border-collapse: collapse;
}

/* No chromatic accent anywhere per DIRECTION-V3: text selection stays a
   neutral warm tan tint, never the removed ember token. */
::selection {
  background: rgba(217, 198, 172, .6);
  color: #17120E;
}

/* --------------------------------------------------------------------------
   1. TOKENS
   The exact DIRECTION-V3 palette and fonts, character for character, plus
   an internal-only scale nothing outside this file needs to reference.
   -------------------------------------------------------------------------- */

:root {
  /* Palette, from DIRECTION-V3.md, verbatim. */
  --espresso: #17120E;
  --espresso-deep: #0E0B08;
  --tan: #D9C6AC;
  --cream: #F4EBDD;
  --card: #FFFFFF;
  --ink: #2A211A;
  /* Darkened from the original #6B5B4C: that value measured 3.91:1 on
     --tan, under the 4.5:1 AA floor, on live text (the About lede, every
     Visit hours row). #4F4036 clears 5.96:1 on tan and 4.70:1 on
     tan-deep, the darkest light ground it can render on, per a
     from-scratch WCAG relative-luminance check that matches this
     project's own independently-computed number. */
  --ink-soft: #4F4036;
  --bone: #F7F1E8;
  --bone-soft: #C9BCAC;
  --line: rgba(42, 33, 26, .14);
  --line-dark: rgba(247, 241, 232, .16);

  /* Derived, not in the direction doc but needed to build it out: a raised
     dark-ground card fill (DIRECTION-V3 defines --card for tan/cream only
     and says dark-ground cards carry no shadow, which still leaves them
     needing a fill distinct from the espresso ground behind them), a
     warm shadow for light-ground cards, and a couple of tan/cream steps
     for internal contrast (table stripes, hairline panels). */
  --card-dark: #241C15;
  --tan-deep: #C7AF8E;
  --cream-deep: #EADFCB;

  /* Three-layer shadow, resting and hover, one variant per light ground.
     A single soft blur reads as a diffuse smudge, not a lifted object;
     this stacks a 1px contact ring (the card's own edge), a short tight
     shadow for near-field lift, and a longer soft wash for ambient
     depth, all tinted from --espresso, never black. White-on-cream
     separates by only 1.18:1 relative luminance versus tan's 1.66:1 (two
     near-white surfaces with almost no colour difference), so the cream
     variant carries a heavier ambient layer to do proportionally more of
     the depth-signalling work colour alone can't provide there. The
     ring layer never moves between resting and hover, on purpose: it
     stays put as the "this is still the card's edge" anchor while the
     other two layers do the lifting. */
  --shadow-card-tan:
    0 0 0 1px rgba(23, 18, 14, .05),
    0 2px 6px -2px rgba(23, 18, 14, .14),
    0 20px 40px -20px rgba(23, 18, 14, .22);
  --shadow-card-tan-hover:
    0 0 0 1px rgba(23, 18, 14, .06),
    0 4px 10px -3px rgba(23, 18, 14, .18),
    0 32px 56px -22px rgba(23, 18, 14, .28);
  --shadow-card-cream:
    0 0 0 1px rgba(23, 18, 14, .06),
    0 2px 6px -2px rgba(23, 18, 14, .16),
    0 22px 44px -18px rgba(23, 18, 14, .26);
  --shadow-card-cream-hover:
    0 0 0 1px rgba(23, 18, 14, .07),
    0 4px 10px -3px rgba(23, 18, 14, .20),
    0 34px 60px -20px rgba(23, 18, 14, .32);
  /* Kept as an alias for anywhere still reading the old flat name. */
  --shadow-warm: var(--shadow-card-cream);

  /* Fonts, DIRECTION-V3 verbatim. Fraunces, Karla, IBM Plex Mono, Bricolage
     Grotesque and Space Mono must never appear in this file. */
  --font-display: 'Playfair Display', Georgia, serif;
  --font-body: 'Jost', system-ui, sans-serif;

  --ease-out: cubic-bezier(.22, 1, .36, 1);
  --ease-in-out: cubic-bezier(.65, 0, .35, 1);
  --gutter: clamp(20px, 5vw, 72px);
  --measure: 62ch;

  /* Internal spacing and radius scale. Two-tier radius policy per
     DIRECTION-V3: 16px on every card and image, a full pill (999px) on
     every control, nothing in between. Full-bleed hero/book photography
     is the one deliberate exception, styled at 0 further down. */
  --space-2xs: 6px;
  --space-xs: 10px;
  --space-sm: 16px;
  --space-md: 24px;
  --space-lg: 40px;
  --space-xl: 64px;
  --space-2xl: 96px;
  --radius-card: 16px;
  --radius-pill: 999px;

  /* .site-header's real rendered height, plus breathing room, so an anchor
     jump never lands a heading tucked under the fixed header. */
  --header-h: 78px;

  /* Type scale, phone value first, desktop second, all clamp()-built in
     rem so a visitor's own root size still scales the page. Sizes hold
     the v2 floors DIRECTION-V3 reaffirms: body 17/18px, section heading
     40px phone up to 88px desktop. Nothing here dips under 15px except
     the kicker, which DIRECTION-V3 sets at 12 to 13px by name. */
  --fs-kicker:    clamp(.75rem, .72rem + .12vw, .8125rem);   /* 12 -> 13px */
  --fs-sm:        clamp(.9375rem, .9rem + .18vw, 1rem);       /* 15 -> 16px */
  --fs-base:      clamp(1.0625rem, 1.02rem + .2vw, 1.125rem); /* 17 -> 18px, contract floor */
  --fs-md:        clamp(1.1875rem, 1.1rem + .38vw, 1.375rem); /* 19 -> 22px */
  --fs-card-name: clamp(1.25rem, 1.16rem + .4vw, 1.5rem);     /* 20 -> 24px, image-card captions */
  --fs-row-name:  clamp(1.375rem, 1.2rem + .8vw, 2rem);       /* 22 -> 32px, list/group headings */
  --fs-lg:        clamp(1.75rem, 1.5rem + 1.2vw, 2.5rem);     /* 28 -> 40px */
  --fs-shead:     clamp(2.5rem, 1.4rem + 5vw, 5.5rem);        /* 40 -> 88px, section heading floor */
  --fs-heroword:  clamp(1.75rem, 1.55rem + 1vw, 2.75rem);     /* 28 -> 44px, hero crest wordmark */
  --fs-heroh1:    clamp(2.75rem, 1.7rem + 5.6vw, 6.5rem);     /* 44 -> 104px, the hero's big serif line */

  --lh-tight: 1.04;
  --lh-snug: 1.18;
  --lh-body: 1.6;
  --lh-relaxed: 1.68;

  --ls-tight: -.01em;
  --ls-label: .18em;

  /* Per-tier tracking: negative tracking should read more aggressively as
     size grows (a 104px line has far more absolute white space between
     glyphs than a 20px one, so a single flat value is either too loose
     at hero scale or too tight at card scale). Replaces --ls-tight on
     the four roles below; --ls-tight itself stays for anything not
     covered by a tier. */
  --ls-hero: -.028em;
  --ls-shead: -.02em;
  --ls-row: -.01em;
  --ls-card: -.005em;
}

@media (min-width: 880px) {
  :root {
    --header-h: 84px;
  }
}

/* --------------------------------------------------------------------------
   2. GROUND CONTEXT
   The alternating warm-band rhythm, DIRECTION-V3's defining structural
   move. Agent M puts exactly one of three real classes on each section,
   .tone-espresso / .tone-tan / .tone-cream, which is the actual switch;
   the section ids and the .ground-* aliases below are kept as a second
   way in, so the same result holds even if a class is ever missing.
   Every section gets a local set of --ground-* variables (background,
   ink, secondary ink, hairline colour, card surface, card border, card
   shadow, and the two button-fill roles), which every component below
   reads instead of hardcoding a light or dark assumption: a card, a
   button or a rule never needs to know which band it is in. No two
   neighbours share a ground, per the exact order in DIRECTION-V3.md:
   hero espresso (photographic), about tan, practitioners cream,
   treatments espresso, fees cream, visit tan, reviews espresso, faq
   cream, book espresso (photographic).
   -------------------------------------------------------------------------- */

.ground-espresso, .tone-espresso,
#hero, #treatments, #reviews, #book, .site-footer {
  --ground-bg: var(--espresso);
  --ground-bg-deep: var(--espresso-deep);
  --ground-ink: var(--bone);
  --ground-ink-soft: var(--bone-soft);
  --ground-line: var(--line-dark);
  --ground-card: var(--card-dark);
  --ground-card-border: var(--line-dark);
  /* No drop shadow on a near-black ground; it would be invisible. The
     dark-card equivalent of a light card's shadow doing the "this is a
     raised object" job is an inset top highlight instead, wired
     directly on .review-card (section 18), since a drop shadow simply
     cannot read here at all. */
  --ground-card-shadow: none;
  --ground-card-shadow-hover: none;
  --ground-btn-bg: var(--cream);
  --ground-btn-ink: var(--espresso);
  --ground-ghost-border: var(--line-dark);
  background: var(--ground-bg);
  color: var(--ground-ink);
}

.ground-tan, .tone-tan,
#intro, #visit {
  --ground-bg: var(--tan);
  --ground-bg-deep: var(--tan-deep);
  --ground-ink: var(--ink);
  --ground-ink-soft: var(--ink-soft);
  --ground-line: var(--line);
  --ground-card: var(--card);
  --ground-card-border: transparent;
  --ground-card-shadow: var(--shadow-card-tan);
  --ground-card-shadow-hover: var(--shadow-card-tan-hover);
  --ground-btn-bg: var(--espresso);
  --ground-btn-ink: var(--cream);
  --ground-ghost-border: var(--line);
  background: var(--ground-bg);
  color: var(--ground-ink);
}

.ground-cream, .tone-cream,
#practitioners, #fees, #faq {
  --ground-bg: var(--cream);
  --ground-bg-deep: var(--cream-deep);
  --ground-ink: var(--ink);
  --ground-ink-soft: var(--ink-soft);
  --ground-line: var(--line);
  --ground-card: var(--card);
  --ground-card-border: transparent;
  --ground-card-shadow: var(--shadow-card-cream);
  --ground-card-shadow-hover: var(--shadow-card-cream-hover);
  --ground-btn-bg: var(--espresso);
  --ground-btn-ink: var(--cream);
  --ground-ghost-border: var(--line);
  background: var(--ground-bg);
  color: var(--ground-ink);
}

/* Band transitions are hard edges, never gradients, per DIRECTION-V3. No
   rule is needed at most seams: an espresso band next to a light one is
   an 11-17:1 luminance jump, plainly a seam on its own. The one place
   that needs help is a light-to-light seam: --tan and --cream measure
   only 1.41:1 against each other, barely perceptible as a hard edge on
   some monitors. A very quiet 1px hairline drawn exactly on those two
   seams (about->practitioners, fees->visit) gives the eye the edge the
   colour alone almost doesn't. Never on an espresso neighbour, where it
   would be redundant. */
#practitioners, #visit {
  border-top: 1px solid var(--line);
}

/* --------------------------------------------------------------------------
   3. TYPE SYSTEM
   -------------------------------------------------------------------------- */

body {
  font-size: var(--fs-base);
  line-height: var(--lh-body);
  font-weight: 400;
}

.font-display,
h1, h2, h3, h4,
.hero__h1, .hero__wordmark {
  font-family: var(--font-display);
  font-weight: 500;
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-tight);
  text-wrap: balance;
  color: inherit;
}

/* 400 is the default per DIRECTION-V3 ("Playfair Display 400 for big
   display, 500 and 600 only where a heading must hold against a busy
   photograph"). Section headings read fine at 400 on a flat band; the
   two headings that sit over real footage (the hero, the book CTA) step
   up to weight below. */
h1, h2 { font-weight: 400; }
h3, h4 { font-weight: 500; }

/* Tracking tightens as size grows: the only <h1> in this document is the
   hero headline (.hero__h1 below re-sets its size larger still and
   inherits this tracking); h2 covers every section .shead. */
h1 { font-size: var(--fs-shead); letter-spacing: var(--ls-shead); }
h2 { font-size: var(--fs-shead); letter-spacing: var(--ls-shead); }
h3 { font-size: var(--fs-row-name); letter-spacing: var(--ls-row); }
h4 { font-size: var(--fs-card-name); letter-spacing: var(--ls-card); }

p, .lede, .prose {
  text-wrap: pretty;
}

.fees-table caption {
  text-wrap: balance;
}

/* Hyphenation is now off by default and switched on only for the two
   places that genuinely need it. It used to be on for every <p> on the
   page, which is far too broad: <p> carries the long About copy, but it
   also carries one-line practitioner descriptions, fact captions and
   pull-quotes, and in those a mid-word break reads as a defect rather
   than a save. A dash at the end of a line is only forgivable when the
   reader is already deep in running copy.

   What is left on: the fee-table captions, which sit in a two-column
   panel that gets down to about 150px at 360px wide, and the FAQ answers,
   which are the longest running copy on the page. lang="en" on <html>
   gives the browser a dictionary to hyphenate from. */
.faq-item__a, .fees-table caption {
  hyphens: auto;
  -webkit-hyphens: auto;
}

h1, h2, h3, h4, .shead, .kicker, .btn,
p, blockquote, address, li, cite, strong, span {
  hyphens: manual;
  -webkit-hyphens: manual;
}

/* Evening the lines out, which is the other half of the same complaint.
   balance makes the browser even the length of every line in the block
   instead of filling greedily and leaving a stub on the last one; it is
   capped at a handful of lines by every engine that implements it, so it
   is safe to hand it a block that turns out longer than expected, and it
   simply stops applying. Everything here is short enough to qualify.

   Running copy keeps `pretty` instead, set further up: it does not even
   the lines, it only prevents a single word being stranded on the last
   line, which is the right trade once a paragraph is long enough that
   balancing would be ignored anyway. */
.practitioner-card__title,
.practitioner-card__desc,
.practitioner-card__quote,
.practitioner-card__quote p,
.treatment-row__desc,
.treatment-row__with,
.book__details-text,
.book__address,
.hero__reveal-facts span,
.hero__reveal-kicker,
.review-card p,
.reviews__cta-lede,
.faq__cta-lede,
.visit__hours-time,
.site-footer__col li {
  text-wrap: balance;
}

/* Text blocks measured in ch on the text element itself, never a container. */
p, .lede, .prose p, blockquote, .faq-item__a {
  max-width: var(--measure);
}

.lede {
  font-size: var(--fs-md);
  line-height: var(--lh-relaxed);
  color: var(--ground-ink-soft, var(--ink-soft));
}

/* Kickers: uppercase Jost, wide tracking, 12 to 13px, per DIRECTION-V3.
   This is the one small label size on the page; nothing else drops
   under 15px. No mono face anywhere. */
.kicker, .eyebrow {
  font-family: var(--font-body);
  font-size: var(--fs-kicker);
  font-weight: 500;
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--ground-ink-soft, var(--ink-soft));
}

.figure {
  font-variant-numeric: tabular-nums;
}

strong, b {
  font-weight: 500;
}

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

/* --------------------------------------------------------------------------
   3A. WORD-MASK HEADING REVEAL, data-split="words"
   Every h2 carries data-split="words" per the motion contract. Agent J
   wraps each word in a .word-mask span at runtime (site.js's
   splitWords()), each one wrapping a .word-mask__inner span, and adds
   .in to the heading itself once it enters view; the full string stays
   in the DOM for screen readers via the heading's own text plus (if J
   chooses) an aria-label duplicate. Purely additive: an unsplit heading
   with no .word-mask children renders exactly as a plain heading, so
   this never depends on the split actually having run. Transform and
   opacity only. (These selectors previously read .mask / .mask > span,
   which never matched site.js's actual word-mask / word-mask__inner
   classes, so the reveal never ran -- fixed here.) */
[data-split='words'] .word-mask {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-block: .08em;
  margin-block: -.08em;
}

[data-split='words'] .word-mask__inner {
  display: inline-block;
  transform: translateY(110%);
  transition: transform .8s var(--ease-out);
}

[data-split='words'].in .word-mask__inner,
[data-reveal].in [data-split='words'] .word-mask__inner {
  transform: none;
}

/* --------------------------------------------------------------------------
   4. LAYOUT UTILITIES
   -------------------------------------------------------------------------- */

.container {
  width: 100%;
  max-width: min(1240px, 100% - var(--gutter) * 2);
  margin-inline: auto;
}

.section {
  position: relative;
  padding-block: clamp(64px, 11vh, 132px);
  scroll-margin-top: calc(var(--header-h) + var(--space-md));
}

#main,
#hero {
  scroll-margin-top: calc(var(--header-h) + var(--space-md));
}

/* --------------------------------------------------------------------------
   4A. SECTION LABEL: .slabel (data-reveal) THEN .shead AS SIBLINGS
   The real markup is BUILD-CONTRACT-V2's device (.slabel > .slabel__n +
   .slabel__glyph + .slabel__rule, then a sibling <h2 class="shead">), not
   wrapped in any .section__head container. DIRECTION-V3 retires the
   numbered index and the cairn-as-label-glyph, so .slabel__n and
   .slabel__glyph are hidden here rather than removed from markup — the
   cairn stays reserved for the hero crest, the header brand and the
   favicon only. .slabel itself collapses to nothing on a centred section
   and survives only as the self-drawing hairline on the two split-layout
   sections (About, Visit), which is the one place DIRECTION-V3 keeps it.
   .shead is centred by default and left-aligned on those same two
   sections. -------------------------------------------------------------------------- */

.slabel__n,
.slabel__glyph {
  display: none;
}

.slabel {
  display: none;
}

.shead {
  display: block;
  max-width: min(760px, 100% - var(--gutter) * 2);
  margin-inline: auto;
  margin-bottom: var(--space-lg);
  text-align: center;
}

.about .slabel, .visit .slabel,
#intro .slabel, #visit .slabel {
  display: block;
  margin-bottom: var(--space-sm);
}

.about .shead, .visit .shead,
#intro .shead, #visit .shead {
  text-align: left;
  margin-inline: 0;
  max-width: none;
}

/* The self-drawing rule: a hairline, transform-origin left, scaled from 0
   to 1 on .in. */
.slabel__rule {
  display: block;
  width: 100%;
  max-width: 220px;
  height: 1px;
  background: var(--ground-line, var(--line));
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 1.1s var(--ease-out);
}

.slabel.in .slabel__rule,
[data-reveal].in .slabel__rule {
  transform: scaleX(1);
}

/* --------------------------------------------------------------------------
   5. SKIP LINK, FOCUS AND TOUCH TARGETS
   -------------------------------------------------------------------------- */

.skip-link {
  position: fixed;
  top: var(--space-sm);
  left: var(--space-sm);
  z-index: 200;
  padding: .8em 1.2em;
  background: var(--bone);
  color: var(--espresso);
  font-weight: 500;
  border-radius: var(--radius-pill);
  transform: translateY(-160%);
  opacity: 0;
  transition: transform .3s var(--ease-out), opacity .3s var(--ease-out);
}

.skip-link:focus {
  transform: translateY(0);
  opacity: 1;
}

/* No chromatic accent: focus rings use ink on light grounds and bone on
   dark, at a 2px offset, per DIRECTION-V3. Scoped by the same ground
   variables everything else reads, so it flips automatically per band. */
:focus-visible {
  outline: 2px solid var(--ground-ink, var(--ink));
  outline-offset: 2px;
  border-radius: 4px;
}

.site-header :focus-visible {
  outline-color: var(--bone);
}

/* Concentric with the control it rings: the 4px default above draws a
   squared-off box around a pill or a circle, which is one of the
   fastest tells of an un-designed focus state. Pills and circles
   declare their own outline radius to match. */
.btn:focus-visible, .nav a:focus-visible, .nav__toggle:focus-visible {
  border-radius: var(--radius-pill);
}

.stone:focus-visible,
[data-carousel-prev]:focus-visible, [data-carousel-next]:focus-visible {
  border-radius: 50%;
}

/* Touch targets, 44px minimum, added without changing layout or display type. */
@media (pointer: coarse) {
  .btn, .nav a, .faq-item__q, .site-footer__col a, .header-actions a {
    min-height: 44px;
  }

  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  .nav a, .site-footer__col a, .header-actions a {
    display: inline-flex;
    align-items: center;
  }

  .stone {
    min-width: 44px;
    min-height: 44px;
  }
}

/* --------------------------------------------------------------------------
   6. THE MARK: THE CAIRN
   Three stacked stones, Agent M's <symbol id="cairn"> referenced by
   <use>. Styled here wherever it appears: the hero crest, the header
   brand, and nowhere else in v3 (it drops out of the section label
   device and stays out of the favicon's own markup, which is inline SVG
   in <head>, not this file). Assumes the symbol paints with
   fill="currentColor" so colour is set here, not baked into the artwork.
   -------------------------------------------------------------------------- */

.brand svg,
.hero__mark {
  color: currentColor;
  flex: none;
}

.brand svg {
  width: 26px;
  height: 26px;
}

.hero__mark {
  width: clamp(40px, 6vw, 56px);
  height: clamp(40px, 6vw, 56px);
  color: var(--bone);
  margin-inline: auto;
  filter: drop-shadow(0 2px 10px rgba(0, 0, 0, .45));
}

/* --------------------------------------------------------------------------
   7. HEADER AND THE FLOATING GLASS NAV PILL
   Kept from v2/Roberts: dual-layer gradient fill, an ambient drop shadow
   together with an inset top highlight, backdrop-filter blur+saturate,
   and a masked-gradient hairline brighter on top than bottom. Retoned
   warm: the highlight layers are bone-tinted, not cool white, and the
   base fill mixes from --espresso, since the header sits over the dark,
   photographic hero for most of a first viewport. A plain border cannot
   produce the masked hairline's top/bottom asymmetry, so this is never
   swapped for one.
   -------------------------------------------------------------------------- */

.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 60;
  padding: var(--space-sm) var(--gutter);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  background: linear-gradient(rgba(14, 11, 8, .55), transparent);
  pointer-events: none;
}

.site-header > * {
  pointer-events: auto;
}

/* Contrast fix: the wordmark used to rely on text-shadow alone, which
   measured ~1.98:1 over the cream section band and ~2.67:1 over tan,
   both well under the 4.5:1 floor, because .site-header is fixed and
   its own background (a gradient that is fully transparent by the
   bottom of the bar) does nothing once a light band scrolls underneath.
   Same backing-chip recipe as .chip and .hero__caption .kicker below,
   but at a higher fill opacity than either: .chip's own .55 alpha and
   .hero__caption .kicker's .48 alpha are tuned for sitting over the
   hero photograph, and both fall short of 4.5:1 once composited over
   cream (measured ~4.13:1 and ~3.51:1 respectively) -- not enough
   headroom for a header that also has to cross tan and cream bands. */
.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.05rem, 1rem + .3vw, 1.25rem);
  letter-spacing: var(--ls-tight);
  color: var(--bone);
  text-decoration: none;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .4);
  padding: .4em .85em .4em .6em;
  border-radius: var(--radius-pill);
  background: rgba(14, 11, 8, .7);
  border: 1px solid rgba(247, 241, 232, .2);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
}

/* One line, always: the wordmark was wrapping to three stacked lines
   inside the flex header row, since a flex item's default min-width:auto
   lets its text wrap before it overflows. nowrap forces it to size to
   its content instead. */
.brand span {
  white-space: nowrap;
}

/* Phone header is tight (brand, a Book pill and the toggle all in one
   row at 360-390px); the wordmark drops to visually hidden text there,
   leaving just the cairn mark, same technique as .visually-hidden. */
@media (max-width: 560px) {
  .brand span {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
  }
}

.nav {
  display: none;
  align-items: center;
  gap: 2px;
}

/* Contrast fix: this used to sit at rgba(247, 241, 232, .78), measured
   ~3.9:1 against the pill's fill, under the 4.5:1 floor. Raised to
   fully opaque bone -- the resting/hover/current distinction still
   reads fine through the background-only change below (hover and
   is-current already used opaque bone here, so this just matches
   them), and the pill's own fill opacity is raised alongside this so
   the text stays >=4.5:1 even where the fill runs lightest. */
.nav a {
  position: relative;
  padding: 9px clamp(9px, 1.05vw, 14px);
  border-radius: var(--radius-pill);
  font: 500 clamp(.75rem, .92vw, .8125rem)/1 var(--font-body);
  letter-spacing: .05em;
  text-transform: uppercase;
  color: var(--bone);
  text-decoration: none;
  transition: color .2s var(--ease-out), background-color .2s var(--ease-out), scale .15s var(--ease-out);
}

.nav a:hover, .nav a:focus-visible {
  color: var(--bone);
  background: rgba(247, 241, 232, .1);
}

.nav a.is-current,
.nav a[aria-current='true'] {
  color: var(--bone);
  background: rgba(247, 241, 232, .14);
}

@media (min-width: 900px) {
  .site-header {
    height: auto;
    padding: 14px clamp(14px, 2vw, 24px);
    gap: clamp(8px, 1.1vw, 14px);
  }

  .nav {
    position: relative;
    display: flex;
    align-items: center;
    gap: 1px;
    margin: 0 0 0 auto;
    padding: 6px 6px;
    border-radius: var(--radius-pill);
    isolation: isolate;
    /* Fill raised from 62% to 75%: at 62% the nav link text above (now
       opaque bone) still dropped to ~4.13:1 against the pill once
       composited over the cream band, and to ~4.28:1 at the pill's own
       top edge where the highlight layer above lightens the fill
       further. 75% clears 4.5:1 over cream with margin at every point
       in the pill, including that lightened top edge and the hover/
       current overlays, which only ever darken further via
       color-mix or lighten the same way. */
    background:
      linear-gradient(180deg, rgba(247, 241, 232, .09), rgba(247, 241, 232, .02)),
      color-mix(in srgb, var(--espresso) 75%, transparent);
    box-shadow: 0 14px 34px rgba(14, 11, 8, .38), inset 0 1px 0 rgba(247, 241, 232, .12);
    backdrop-filter: blur(16px) saturate(135%);
    -webkit-backdrop-filter: blur(16px) saturate(135%);
  }

  /* The masked-gradient hairline. Brighter along the top edge than the
     bottom, which a plain 1px border cannot produce, because a border is
     one flat colour all the way round. */
  .nav::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    pointer-events: none;
    background: linear-gradient(160deg,
      rgba(247, 241, 232, .28) 0%,
      rgba(247, 241, 232, .11) 38%,
      rgba(247, 241, 232, .03) 70%,
      rgba(247, 241, 232, .11) 100%);
    -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
    mask-composite: exclude;
  }

  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .nav {
      background: color-mix(in srgb, var(--espresso) 92%, transparent);
    }
  }
}

.header-actions {
  display: none;
  align-items: center;
  gap: var(--space-xs);
}

@media (min-width: 900px) {
  .header-actions { display: flex; }
}

/* Mobile nav toggle and the panel it controls: #navToggle / .nav__toggle,
   .nav as the panel (aria-controls resolves the id on it), .is-open added
   to both. Also honoured on the legacy .nav__list class name in case that
   is what ships, so the mobile panel opens correctly either way. */
/* Contrast fix: the .35 fill measured ~2.33:1 for the Menu label/icon
   over the cream band, under the 4.5:1 floor. Raised to .68, which
   composited over cream still lands well clear of the floor (see the
   header contrast note above .brand); over the darker tan/espresso
   backdrops the fill only reads darker still. */
.nav__toggle {
  display: inline-flex;
  position: relative;
  align-items: center;
  height: 44px;
  min-width: 44px;
  padding: 0 var(--space-md) 0 calc(var(--space-sm) + 26px);
  border: 1px solid rgba(247, 241, 232, .3);
  border-radius: var(--radius-pill);
  background: rgba(23, 18, 14, .68);
  color: var(--bone);
  font-family: var(--font-body);
  font-size: .75rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: border-color .25s var(--ease-out), background-color .25s var(--ease-out), scale .15s var(--ease-out);
}

.nav__toggle::before,
.nav__toggle::after {
  content: '';
  position: absolute;
  left: var(--space-sm);
  width: 16px;
  height: 1.5px;
  background: currentColor;
  transition: transform .3s var(--ease-out), opacity .2s var(--ease-out);
}

.nav__toggle::before { top: 18px; }
.nav__toggle::after { top: 24px; }

.nav__toggle.is-open {
  border-color: rgba(247, 241, 232, .5);
  background: var(--espresso);
}

.nav__toggle.is-open::before { top: 21px; transform: rotate(45deg); }
.nav__toggle.is-open::after { top: 21px; transform: rotate(-45deg); }

/* Declared after .nav__toggle's own unconditional base rule (display:
   inline-flex above) on purpose: that base rule carries no media query,
   so a hide-on-desktop rule placed BEFORE it in source order loses the
   cascade tie at 900px and up even though its condition matches, which
   is exactly the bug that showed the hamburger next to the full glass
   pill at 1280px. Placed after, this wins the tie the same way the
   later declaration always does at equal specificity. */
@media (min-width: 900px) {
  .nav__toggle { display: none; }
}

@media (max-width: 899.98px) {
  .nav, .nav__list {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    position: fixed;
    inset: 0 0 auto 0;
    padding: calc(var(--space-2xl) + 12px) var(--gutter) var(--space-lg);
    background:
      radial-gradient(ellipse 70% 50% at 50% 0%, rgba(217, 198, 172, .12), transparent 70%),
      var(--espresso-deep);
    border-bottom: 1px solid rgba(247, 241, 232, .14);
    box-shadow: 0 24px 56px -20px rgba(0, 0, 0, .7);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-14px);
    pointer-events: none;
    transition: opacity .3s var(--ease-out), transform .3s var(--ease-out),
                visibility 0s linear .3s;
  }

  .nav.is-open, .nav__list.is-open {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
    pointer-events: auto;
    transition: opacity .3s var(--ease-out), transform .3s var(--ease-out),
                visibility 0s linear;
  }

  .nav a {
    padding-block: var(--space-sm);
    font-size: var(--fs-md);
    color: var(--bone);
    border-bottom: 1px solid rgba(247, 241, 232, .14);
  }
}

/* The phone-number pill is the first thing to go when the header runs
   out of room. On phones it was the fourth control crowding 360-390px and
   clipping the toggle; between 900 and 1180 it is what pushes a nav that
   now carries nine links off the right edge. The number is reachable from
   the hero, the Book panel and the footer, so it drops rather than the
   nav shrinking to unreadable type or losing a link. */
@media (max-width: 1179.98px) {
  .header-actions .btn--ghost {
    display: none;
  }
}

@media (max-width: 899.98px) {
  .header-actions {
    display: flex;
  }
}

/* --------------------------------------------------------------------------
   8. BUTTONS AND CHIPS
   Full pill, no chromatic accent: cream fill with espresso text on dark
   grounds, espresso fill with cream text on light grounds, driven by the
   --ground-btn-* variables set in section 2 so a button never needs to
   know what ground it is on. A ghost variant carries a 1px border in the
   current ground's own line colour.
   -------------------------------------------------------------------------- */

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .6em;
  padding: 16px 32px;
  border-radius: var(--radius-pill);
  font-family: var(--font-body);
  font-size: .9375rem;
  font-weight: 500;
  text-decoration: none;
  border: 1px solid transparent;
  cursor: pointer;
  white-space: nowrap;
  transform: translateY(0);
  transition: transform .25s var(--ease-out), scale .15s var(--ease-out),
              background-color .25s var(--ease-out), border-color .25s var(--ease-out),
              color .25s var(--ease-out);
}

.btn--sm {
  padding: 11px 22px;
  font-size: .8125rem;
}

.btn--primary {
  background: var(--ground-btn-bg, var(--espresso));
  color: var(--ground-btn-ink, var(--cream));
}

.btn--primary:hover, .btn--primary:focus-visible {
  transform: translateY(-2px);
  filter: brightness(1.06);
}

.btn--ghost {
  background: transparent;
  color: var(--ground-ink, var(--ink));
  border-color: var(--ground-ghost-border, var(--line));
}

.btn--ghost:hover, .btn--ghost:focus-visible {
  border-color: var(--ground-ink, var(--ink));
  transform: translateY(-2px);
}

/* Press feedback: never guarded behind (hover:hover), since a touch
   visitor deserves confirmation a tap registered just as much as a
   mouse user does. scale, not transform, so it composes cleanly with
   the hover translateY above rather than needing one shared transform
   value. */
.btn:active, .nav a:active, .faq-item__q:active,
[data-carousel-prev]:active, [data-carousel-next]:active,
.nav__toggle:active {
  scale: .96;
}

/* The hero and the header sit directly on photography rather than a flat
   ground, so their buttons are pinned to the dark-ground pairing
   explicitly rather than inheriting whatever --ground-btn-* the section
   underneath happens to set. */
.hero .btn--primary, .header-actions .btn--primary {
  background: var(--cream);
  color: var(--espresso);
}

.hero .btn--ghost, .header-actions .btn--ghost {
  color: var(--bone);
  border-color: rgba(247, 241, 232, .5);
}

.chip {
  display: inline-flex;
  align-items: center;
  background: rgba(23, 18, 14, .55);
  border: 1px solid rgba(247, 241, 232, .22);
  border-radius: var(--radius-pill);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  color: var(--bone);
  padding: .5em .9em;
}

/* --------------------------------------------------------------------------
   9. HERO STAGE
   A full-bleed, low-lit treatment film (two source files, portrait and
   landscape, chosen by Agent F's JS; both object-fit: cover, no
   letterboxing) graded down so it supports rather than competes, since
   the footage is AI-generated and does not survive close inspection.
   Above centre: the cairn crest and a quiet wordmark. Below: a kicker, the
   page's one big serif headline, and one or two pill buttons. Agent F
   writes --heroOut (0 to 1, over 0.66 of a viewport height) onto
   #heroStage; this file translates and fades the crest and caption off
   with it. No CSS transition on that translate/opacity, deliberately, so
   it can never fall a frame behind the JS driving it.
   -------------------------------------------------------------------------- */

.hero {
  position: relative;
  /* The scroll distance the film is scrubbed across. Was 1000vh, which meant
     ten screens of scrolling before any content. Cut to 280vh, then raised
     to 320vh once the reveal panel arrived: the sticky stage takes 100vh,
     so there are 220vh of scroll, and the last quarter of that is the hold
     with the film pinned on the table and the text at rest. Roberts
     Renovations uses 210svh for a hero with nothing to hold on. */
  height: 320vh;
  background: var(--espresso-deep);
}

.hero__stage {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  background: var(--espresso-deep);
  --heroOut: 0;
}

/* The poster was costing every visitor a 274KB download they never saw.
   This rule used to default to room-1.jpg, the warm real treatment-room
   photograph. But film-engine.js's initHeroOnce() overwrites this same
   element's background-image with an inline style the moment it runs
   (film-engine.js:499-500), pointing at the much smaller generated frame
   that matches the film's own first frame. CSS is render-blocking and
   the scripts are deferred, so the browser had good reason to fetch
   room-1.jpg before the JS ever ran, then throw it away unseen. 274KB on
   the critical path, on a phone, for nothing.

   So the default is now the same small file the JS is about to set, which
   makes the second fetch a cache hit rather than a new request. room-1.jpg
   is kept for the only two cases where a visitor actually sits and looks
   at the poster: the two gates that turn the film off entirely. Those are
   media queries, so CSS can answer them without waiting for JS.

   The portrait and landscape defaults mirror film-engine.js's own FILM_Q
   ('(orientation: portrait) and (max-width: 1024px)', film-engine.js:461)
   so the frame CSS paints is the frame the film is about to start on. */
.hero__poster {
  position: absolute;
  inset: 0;
  background-image: url('assets/hero-wide-poster.jpg?v=3');
  background-size: cover;
  background-position: center 30%;
  opacity: 1;
  transition: opacity .6s var(--ease-out);
}

@media (orientation: portrait) and (max-width: 1024px) {
  .hero__poster {
    background-image: url('assets/hero-poster.jpg?v=3');
    background-position: center 38%;
  }
}

/* The two static-fallback gates, matching GATES[3] and GATES[4] in
   film-engine.js character for character. Here the poster is not a
   placeholder for a film that is coming, it IS the hero, so it gets the
   real photograph and the weight is worth paying. */
@media (orientation: landscape) and (pointer: coarse) and (max-height: 560px) {
  .hero__poster {
    background-image: url('assets/room-1.jpg');
    background-position: center 30%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero__poster {
    background-image: url('assets/room-1.jpg');
    background-position: center 38%;
  }
}

/* Both films use object-fit: cover at every width. The v1 contain-and-
   feather letterbox system is removed entirely: a real landscape file
   exists now, so the desktop over-zoom is solved by the asset, not by
   CSS. */
.hero__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  /* Pushes in as the hero fades out. --heroFade runs 0 to 1 across the
     last stretch of the section (film-engine.js), so the picture is
     still closing in on the table at the moment the blackout takes it,
     rather than sitting frozen while it dims. Kept to 4.5%: this is a
     last breath inwards, not a zoom, and anything more read as a lurch
     over the short stretch of scroll it has to happen in. translateZ(0) is kept in
     the same transform so the element stays on its own layer. */
  transform: translateZ(0) scale(calc(1 + var(--heroFade, 0) * .045));
  transform-origin: 50% 42%;
  transition: opacity .8s var(--ease-out);
}

/* The grade that used to live here, saturate(.78) contrast(1.02)
   brightness(.92), is now baked into the film and the posters themselves.

   A CSS filter on a <video> forces the browser off the cheap hardware
   video path and onto per-frame shader compositing of every decoded
   frame. On a phone, paid continuously while the visitor scrubs through
   the whole hero, that is one of the most expensive things the page was
   doing, and it showed as a low frame rate in the smoke. The look is
   identical; it just costs nothing now. */

.hero__stage.video-ready .hero__video { opacity: 1; }
.hero__stage.video-ready .hero__poster { opacity: 0; }
.hero__stage.video-failed .hero__video { display: none; }
.hero__stage.video-failed .hero__poster { opacity: 1; }

/* The poster takes the same push so a visitor on any of the static-hero
   gates gets the same closing move rather than a dead frame. */
.hero__poster {
  transform: scale(calc(1 + var(--heroFade, 0) * .045));
  transform-origin: 50% 42%;
}

/* Lower right of the stage, small and quiet: it was sitting dead centre
   over the kicker and the crest, reading as a bug rather than a loading
   indicator. It never shares space with the crest or caption text at
   this corner, at any width, and .video-ready below still takes it to
   full transparent once the stream is live. */
.hero__ring {
  position: absolute;
  bottom: clamp(20px, 4vh, 40px);
  right: clamp(20px, 4vw, 40px);
  width: 30px;
  height: 30px;
  transform: rotate(-90deg);
  color: var(--bone);
  opacity: .55;
  pointer-events: none;
  transition: opacity .4s var(--ease-out);
  z-index: 5;
}

.hero__ring circle {
  stroke-linecap: round;
}

.hero__stage.video-ready .hero__ring,
.hero__stage.video-failed .hero__ring {
  opacity: 0;
}

@media (max-height: 560px) {
  .hero__ring { display: none; }
}

/* The four-layer legibility system, simplified for the crest+caption
   composition: one strong base scrim, tuned so the crest (upper third)
   and the caption (lower half) both sit on real dark, while the middle
   of the frame stays legible as photography. */
.hero__scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    radial-gradient(ellipse 60% 46% at 50% 50%, rgba(14, 11, 8, 0) 30%, rgba(14, 11, 8, .1) 60%),
    linear-gradient(to top, rgba(14, 11, 8, .78) 0%, rgba(14, 11, 8, .28) 34%, rgba(14, 11, 8, .08) 55%),
    linear-gradient(to bottom, rgba(14, 11, 8, .62) 0%, rgba(14, 11, 8, .12) 30%, rgba(14, 11, 8, 0) 48%);
}

/* The hero closes on black. --heroFade runs across the last stretch of
   the section, after the reveal panel has been sitting at rest, so the
   picture dims away before the About band's warm tan arrives. Without it
   the cut from a lit treatment room straight into a flat tan band was
   abrupt; fading the film out first makes the tan read as the next thing
   rather than an interruption.

   z-index 2 puts it over the film and the scrim but under .hero__reveal
   at 3, so the text stays fully legible while the picture behind it goes.
   Opacity only, and a pure function of scroll like everything else here,
   so it unwinds on the way back up. */
.hero__blackout {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: var(--espresso-deep);
  opacity: var(--heroFade, 0);
}

.hero__crest {
  position: absolute;
  top: 36%;
  left: 50%;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-xs);
  text-align: center;
  transform: translate3d(-50%, calc(-50% - var(--heroOut, 0) * 26vh), 0);
  opacity: clamp(0, calc(1 - var(--heroOut, 0) * 1.7), 1);
}

.hero__wordmark {
  font-size: var(--fs-heroword);
  font-weight: 500;
  color: var(--bone);
  text-shadow: 0 2px 16px rgba(0, 0, 0, .5);
}

.hero__caption {
  position: absolute;
  left: 50%;
  bottom: clamp(48px, 9vh, 116px);
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
  width: min(680px, 100% - var(--gutter) * 2);
  text-align: center;
  transform: translate3d(-50%, calc(var(--heroOut, 0) * -16vh), 0);
  opacity: clamp(0, calc(1 - var(--heroOut, 0) * 1.5), 1);
}

/* A real surface, not just a text-shadow: this line crosses the
   brightest part of the frame (the lit stone stack) at every scroll
   position, where a shadow alone left the contrast genuinely marginal.
   A small blurred backing chip, the same treatment .chip already uses
   elsewhere in this file, fixes it regardless of what is behind it. */
.hero__caption .kicker {
  display: inline-flex;
  align-items: center;
  gap: .5em;
  color: var(--bone);
  background: rgba(14, 11, 8, .48);
  border: 1px solid rgba(247, 241, 232, .16);
  border-radius: var(--radius-pill);
  padding: .55em 1.1em;
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
}

.hero__caption .kicker svg {
  width: 14px;
  height: 14px;
  color: currentColor;
}

.hero__h1 {
  font-size: var(--fs-heroh1);
  font-weight: 600;
  letter-spacing: var(--ls-hero);
  line-height: 1;
  color: var(--bone);
  text-shadow: 0 3px 24px rgba(0, 0, 0, .5);
}

.hero__actions {
  margin-top: var(--space-xs);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
}


/* --------------------------------------------------------------------------
   9B. THE HERO REVEAL, over the settled frame
   Agent F writes --heroLate (0 to 1 across film progress 0.52 to 0.88)
   onto #heroStage, plus .is-late once it has started. The crest and
   caption leave on --heroOut, which is spent by about film progress
   0.37; this arrives well after they have gone, so the two never share
   the frame.

   Every child animates off that one variable through its own --d offset
   rather than a transition or a keyframe. That is what makes the panel
   reversible: scroll back up the hero and it unbuilds itself frame for
   frame, because there is no time-based animation with its own opinion
   about where it should be. The same reason nothing here has a
   transition on transform or opacity, matching the crest and caption
   directly above.

   --t is each child's own progress: the shared --heroLate, re-based so
   that a child with --d:.3 sits at 0 until the panel is 30% in and
   reaches 1 with everything else.
   -------------------------------------------------------------------------- */

/* Hidden unless the film is actually scrubbing. Every gate that falls back
   to the static hero leaves the crest and caption pinned in the middle of
   the frame, which is where this sits. See enableScrub() in
   film-engine.js. */
.hero__reveal {
  display: none;
}

.hero__stage.is-scrubbing .hero__reveal {
  display: flex;
}

.hero__reveal {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 3;
  width: min(1120px, 100% - var(--gutter) * 2);
  flex-direction: column;
  align-items: center;
  gap: var(--space-md);
  text-align: center;
  transform: translate3d(-50%, -50%, 0);
  /* Not clickable until it is actually there. An invisible button at
     opacity 0 is still a button, and it would sit over the whole frame
     swallowing clicks for the entire first half of the hero. */
  pointer-events: none;
}

.hero__stage.is-late .hero__reveal {
  pointer-events: auto;
}

/* The panel lands squarely on the brightest thing in the frame: the lit
   sheet on the treatment table. Cream type on that was cream on cream.
   A soft pool of shade travelling in with the panel gives every element
   in it a ground, rather than patching each one with its own chip or
   shadow and ending up with a cluttered stack of backing plates under a
   large hollow headline. Fades in on --heroLate with everything else, so
   it is never a dark blob sitting over the film early on. */
.hero__reveal::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: -1;
  width: 190%;
  height: 175%;
  transform: translate3d(-50%, -50%, 0);
  pointer-events: none;
  background: radial-gradient(ellipse 46% 40% at 50% 50%,
    rgba(14, 11, 8, .8) 0%,
    rgba(14, 11, 8, .58) 42%,
    rgba(14, 11, 8, .2) 68%,
    rgba(14, 11, 8, 0) 82%);
  opacity: var(--heroLate, 0);
}

.hero__reveal > *,
.hero__reveal-facts > li {
  --d: 0;
  --t: clamp(0, calc((var(--heroLate, 0) - var(--d)) / (1 - var(--d))), 1);
  opacity: var(--t);
  transform: translate3d(0, calc((1 - var(--t)) * 26px), 0);
}

.hero__reveal-kicker {
  font-size: clamp(.8125rem, 1.9vw, 1rem);
  font-weight: 500;
  letter-spacing: .34em;
  text-transform: uppercase;
  color: var(--bone-soft);
  text-shadow: 0 1px 12px rgba(0, 0, 0, .8);
}

/* The hollow line. -webkit-text-stroke is the only way to get a real
   outline on live text; the @supports guard matters because
   color:transparent with no stroke is invisible text, not a fallback.
   The drop-shadow (filter, not text-shadow) traces the stroke itself
   rather than the glyph's absent fill, which is what keeps the outline
   legible where the footage behind it goes bright. */
.hero__reveal-hollow {
  margin: 0;
  font-family: var(--font-display);
  text-wrap: balance;
  font-size: clamp(3.4rem, 15vw, 10.5rem);
  font-weight: 500;
  line-height: 1.02;
  letter-spacing: var(--ls-hero, -.01em);
  color: var(--bone);
}

@supports (-webkit-text-stroke: 1px currentColor) {
  .hero__reveal-hollow {
    -webkit-text-stroke: 2px var(--bone);
    filter: drop-shadow(0 2px 18px rgba(0, 0, 0, .6));
    /* Hollow, then a faint fill washing in over the last quarter of the
       panel's arrival, so the line reads as drawing itself and then
       filling rather than simply being there. Done with the fill colour's
       own alpha rather than a second copy of the text, which would have
       to re-wrap identically at every width to stay registered. */
    -webkit-text-fill-color: rgba(247, 241, 232,
      calc(clamp(0, calc((var(--heroLate, 0) - .72) / .28), 1) * .2));
  }

  /* The stroke thins out at phone sizes if it stays at 2px against a
     much smaller glyph, so it comes down with the type. */
  @media (max-width: 720px) {
    .hero__reveal-hollow {
      -webkit-text-stroke-width: 1.25px;
    }
  }
}

/* The ul is only a wrapper; its children carry the stagger. Left in the
   rule above it would multiply its own fade into every item's, pushing
   all three facts much later than their --d asks for. */
.hero__reveal-facts {
  opacity: 1;
  transform: none;
}

.hero__reveal-facts {
  margin: var(--space-sm) 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: var(--space-md) var(--space-xl);
}

.hero__reveal-facts > li {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  min-width: 118px;
  max-width: 260px;
}

.hero__reveal-facts strong {
  font-family: var(--font-display);
  font-size: clamp(1.85rem, 6vw, 3rem);
  font-weight: 500;
  color: var(--bone);
  text-shadow: 0 2px 14px rgba(0, 0, 0, .55);
}

/* The star sits on the numeral's own line, sized in em so it tracks the
   clamp above rather than needing its own breakpoints, and nudged down a
   little because a star's optical centre sits above its bounding box
   centre while a lining numeral's does not. */
.hero__reveal-rating {
  display: inline-flex;
  align-items: center;
  gap: .28em;
}

.hero__reveal-star {
  width: .78em;
  height: .78em;
  margin-top: .06em;
  flex: none;
  color: var(--bone);
}

.hero__reveal-facts span {
  font-size: clamp(.875rem, 2vw, 1.0625rem);
  line-height: 1.35;
  color: var(--bone-soft);
  text-shadow: 0 1px 10px rgba(0, 0, 0, .6);
}

.hero__reveal-actions {
  margin-top: var(--space-sm);
}

/* --------------------------------------------------------------------------
   9C. THE HERO BOOKING BUTTON
   Bigger than a body-copy button and lit from behind, because it is the
   one control on a full-bleed moving photograph rather than on a flat
   band, and at the standard .btn size it was disappearing into the
   footage. The halo breathes very slowly. Fast enough to notice, slow
   enough that it reads as the button sitting in the room's own light
   rather than as a notification demanding something.
   -------------------------------------------------------------------------- */

@keyframes ctaHalo {
  0%, 100% { box-shadow: 0 10px 30px rgba(14, 11, 8, .45), 0 0 0 0 rgba(247, 241, 232, .3); }
  50%      { box-shadow: 0 10px 30px rgba(14, 11, 8, .45), 0 0 0 10px rgba(247, 241, 232, 0); }
}

.btn--hero {
  padding: 19px 40px;
  font-size: 1.0625rem;
  letter-spacing: .01em;
  box-shadow: 0 10px 30px rgba(14, 11, 8, .45);
  animation: ctaHalo 3.6s ease-out infinite;
}

.btn--hero:hover,
.btn--hero:focus-visible {
  /* The halo owns box-shadow, so hover cannot use it without the
     keyframe stamping over the hover value on its next frame. Scale and
     brightness are free. */
  animation-play-state: paused;
  scale: 1.03;
  filter: brightness(1.08);
}

@media (max-width: 480px) {
  .btn--hero {
    padding: 17px 30px;
    font-size: 1rem;
  }
}
/* --------------------------------------------------------------------------
   9A. THE FIVE HERO GATES
   These five strings must stay character for character identical to
   film-engine.js's GATES array. All five are listed here, in the same
   order, for the audit that diffs them side by side. Only the last two
   actually change anything in this file: they pin the frame to the
   poster and stop the film, matching film-engine.js falling back to a
   complete-without-video state. The first three keep the real
   scroll-scrubbed film active (phones and portrait tablets included, per
   MOBILE_SCRUB) and are listed for the audit, not as hiding rules.
   -------------------------------------------------------------------------- */

@media (max-width: 720px) {
}

@media (orientation: portrait) and (max-width: 1024px) {
}

@media (orientation: portrait) and (pointer: coarse) {
}

@media (orientation: landscape) and (pointer: coarse) and (max-height: 560px) {
  .hero__video { display: none; }
  .hero__poster { opacity: 1; }
  .hero__ring { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .hero__video { display: none; }
  .hero__poster { opacity: 1; }
}

/* --------------------------------------------------------------------------
   10. THE SPINE
   A hairline rule with stone nodes, one per section, brightening as the
   reader passes. No chromatic accent: state reads as a lightness jump
   (dim stone-tone to full bone), never a colour change.
   -------------------------------------------------------------------------- */

/* Starts below the fixed header rather than at the true viewport top: a
   node sitting flush against y:0 behind the header read as a stray
   artifact rather than a deliberate element, since nothing else on the
   page touches that edge. */
.spine {
  position: fixed;
  top: var(--header-h);
  bottom: 0;
  left: 32px;
  z-index: 40;
  width: 7px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  padding-block: var(--space-xl);
  pointer-events: none;
  mix-blend-mode: difference;
}

.spine__rule {
  position: absolute;
  top: var(--space-xl);
  bottom: var(--space-xl);
  left: 50%;
  width: 1px;
  transform: translateX(-50%);
  background: rgba(247, 241, 232, .35);
}

.spine__rule::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--bone);
  transform-origin: top;
  transform: scaleY(var(--spine-progress, 0));
  transition: transform .2s linear;
}

/* Sized to actually show the cairn glyph each node now contains, per
   Agent M's real markup (<span class="spine__node"><svg><use
   href="#cairn"/></svg></span>) — a plain 7px dot from v2 would reduce a
   three-stone mark to an unreadable speck. */
.spine__node {
  position: relative;
  z-index: 1;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: transparent;
}

.spine__node svg {
  width: 60%;
  height: 76%;
  color: rgba(247, 241, 232, .32);
  transition: color .4s var(--ease-out);
}

.spine__node.is-passed svg {
  color: var(--bone);
  filter: drop-shadow(0 0 4px rgba(247, 241, 232, .35));
}

/* The spine is gone on phones, not just thinner. At 2px against the left
   edge it read as a scroll bar rather than as a progress indicator, and it
   was taking room the content wanted. It is also the only fixed,
   full-height mix-blend-mode element on the page, so it is the one piece
   of compositor work that could not be skipped on any scroll frame of the
   whole visit; removing it on the devices least able to afford that is
   the right trade twice over. Section progress is still legible on a
   phone from the nav's current-section highlight. */
@media (max-width: 720px) {
  .spine {
    display: none;
  }
}

/* mix-blend-mode:difference lets one spine read correctly against both
   dark and light bands without per-section colour logic; it is not
   scoped to the reduced-motion query below because it is not motion. */

/* --------------------------------------------------------------------------
   11. THE HOLD INTERACTION
   Press and hold the cairn to feel it warm. --heat runs 0 to 1. Retoned
   off ember: the glow is warm bone/tan light, contrast doing the work a
   chromatic accent used to. Agent M's actual markup replaces the drawn
   stone body with the cairn mark itself (.stone__mark, an <svg><use
   href="#cairn"/></svg>) as the button's one visual, plus its halo; the
   button sits over the photographic backdrop styled in section 15. -------------------------------------------------------------------------- */

.stone {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: clamp(88px, 18vw, 120px);
  aspect-ratio: 1;
  border: 1px solid var(--line-dark);
  border-radius: 50%;
  padding: 0;
  background: rgba(23, 18, 14, .4);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: none;
  box-shadow:
    inset 0 2px 5px rgba(255, 255, 255, .06),
    0 14px 34px -10px rgba(0, 0, 0, .5);
}

.stone__mark {
  position: relative;
  z-index: 1;
  width: 38%;
  height: 52%;
  color: var(--bone);
  transition: color .18s linear, filter .18s linear;
  filter: drop-shadow(0 0 calc(var(--heat, 0) * 10px) rgba(217, 198, 172, .8));
}

.stone__halo {
  position: absolute;
  inset: -32%;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(217, 198, 172, .4) 0%, transparent 70%);
  opacity: var(--heat, 0);
  transform: scale(calc(.55 + var(--heat, 0) * .6));
  pointer-events: none;
  transition: opacity .18s linear, transform .18s linear;
}

.stone:active {
  transform: scale(.97);
  transition: transform .15s var(--ease-out);
}

#heatStoneHint {
  margin-top: var(--space-sm);
  font-family: var(--font-body);
  font-size: .8125rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ground-ink-soft, var(--bone-soft));
}

.heat-reveal {
  margin-top: var(--space-lg);
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}

.heat-reveal .heat-reveal__item {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity .5s var(--ease-out), transform .5s var(--ease-out);
}

.heat-reveal__item.btn {
  transition: opacity .5s var(--ease-out), transform .25s var(--ease-out),
              background-color .25s var(--ease-out), border-color .25s var(--ease-out);
}

.heat-reveal__item:nth-child(1) { transition-delay: 0ms; }
.heat-reveal__item:nth-child(2) { transition-delay: 120ms; }
.heat-reveal__item:nth-child(3) { transition-delay: 240ms; }
.heat-reveal__item:nth-child(4) { transition-delay: 360ms; }

.heat-reveal[data-state='warming'] .heat-reveal__item {
  opacity: .4;
  transform: translateY(6px);
}

.heat-reveal[data-state='done'] .heat-reveal__item {
  opacity: 1;
  transform: none;
  transition-delay: 0ms;
}

/* --------------------------------------------------------------------------
   12. ENTRANCE CHOREOGRAPHY: [data-reveal]
   Container gets .in from IntersectionObserver. Transform and opacity
   only, fully reversible.
   -------------------------------------------------------------------------- */

[data-reveal] > * {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity .6s var(--ease-out), transform .6s var(--ease-out);
}

[data-reveal].in > * {
  opacity: 1;
  transform: none;
}

/* No nth-child transition-delay ladder here: data-stagger's own JS reads
   the attribute and writes each child's transition-delay inline, and an
   inline style would fight a CSS one targeting the same property rather
   than simply losing to it (the two race on every re-render), so a
   container that carries both data-reveal and data-stagger, which is how
   Agent M's markup pairs them everywhere, must get its delay from JS
   only. A plain data-reveal with no data-stagger just reveals every
   child at once, which is correct for a single-item container (a lone
   .about__copy, .book__frame). */
[data-reveal][data-reveal].in > * {
  transition-delay: 0ms;
}

/* Agent J's data-stagger: the same fade+rise as [data-reveal], on a
   container whose own JS-written inline transition-delay staggers the
   children. CSS supplies only the base transition and the two end
   states, never a delay value. */
[data-stagger] > * {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity .6s var(--ease-out), transform .6s var(--ease-out);
}

[data-stagger].in > * {
  opacity: 1;
  transform: none;
}

/* --------------------------------------------------------------------------
   12A. PARALLAX: [data-parallax], reading --p (0 to 1 scroll progress
   through the element). Agent M confirms this sits on the real <img>
   itself in every case (About, the Treatments opener, three treatment-row
   images, the hot-stone backdrop, the Visit photograph) rather than on a
   wrapping figure, so the transform is built for an image: scaled up so
   a translate never uncovers an edge, then drifted opposite the scroll
   direction. Every
   parallax image's own containing box (figure, card frame, media div)
   carries overflow:hidden so the scale/translate stays clipped to that
   box's own shape and radius. Transform only. -------------------------------------------------------------------------- */

[data-parallax] {
  --p: .5;
  transform: translate3d(0, calc((var(--p, .5) - .5) * -12%), 0) scale(1.16);
}

/* --------------------------------------------------------------------------
   12B. THE PRACTITIONER BUBBLES: .practitioner-card__frame
   The three portraits are circular bubbles that drift on their own, not
   rectangular card tops. The work is split across two elements because a
   single one cannot do both jobs:

     .practitioner-card__frame  the bubble. Owns the circle's size, its
                                halo and drop shadow, and the idle float
                                keyframes. It must NOT take a clip-path,
                                because clip-path clips an element's own
                                box-shadow too and the halo would vanish.
     .practitioner-card__photo  the mask. Owns border-radius 50% and the
                                iris-open reveal. Safe to clip, since it
                                casts no shadow of its own.

   The float runs on transform, so hover uses the separate `scale`
   property to lift the bubble. Writing a transform on hover instead
   would replace the keyframe's transform outright and freeze the drift
   mid-cycle. Same reason ::before carries the ripple ring: it animates
   its own transform independently of its parent's.

   Three durations that share no common factor (7.2s, 8.6s, 6.4s) with
   negative delays so the bubbles start already out of phase. Matching
   periods would let all three sync up and pulse together, which reads
   as a loading state rather than as drifting. -------------------------------------------------------------------------- */

@keyframes bubbleFloat {
  0%   { transform: translate3d(0, 0, 0) rotate(0deg); }
  26%  { transform: translate3d(8px, -19px, 0) rotate(1.3deg); }
  52%  { transform: translate3d(-3px, -9px, 0) rotate(-.5deg); }
  74%  { transform: translate3d(-8px, -15px, 0) rotate(-1.2deg); }
  100% { transform: translate3d(0, 0, 0) rotate(0deg); }
}

/* Steam leaving the stone. Opacity is gone by 58%, so roughly half of
   each cycle is completely still; a ring that expanded continuously
   would read as a notification badge. */
@keyframes bubbleRipple {
  0%   { transform: scale(1); opacity: 0; }
  10%  { opacity: .6; }
  60%  { transform: scale(1.3); opacity: 0; }
  100% { transform: scale(1.3); opacity: 0; }
}

.practitioner-card__frame {
  position: relative;
  display: block;
  /* Sized against the card, not the viewport. At the 760px breakpoint the
     grid goes three across while the page is still narrow, so a column's
     content box drops to about 162px; a viewport-relative width would
     still ask for 208px there and the card's overflow:hidden would shear
     the bubble's sides off. */
  width: min(208px, 100%);
  aspect-ratio: 1;
  margin-inline: auto;
  border-radius: 50%;
  /* Drop shadow, a hairline hugging the rim, then a soft warm bloom
     sitting off the edge. The bloom is what makes it read as floating
     above the cream rather than sitting on it. */
  box-shadow:
    0 20px 34px -18px rgba(42, 33, 26, .5),
    0 0 0 1px rgba(199, 175, 142, .55),
    0 0 30px 2px rgba(199, 175, 142, .38);
  scale: 1;
  animation: bubbleFloat 6.4s ease-in-out infinite;
  transition: scale .45s var(--ease-out), box-shadow .45s var(--ease-out);
  will-change: transform;
}

.practitioner-card--laurette .practitioner-card__frame {
  animation-duration: 7.9s;
  animation-delay: -3.4s;
}

.practitioner-card--naira .practitioner-card__frame {
  animation-duration: 5.6s;
  animation-delay: -1.9s;
}

/* The ripple sits behind the photo (::before precedes the element's own
   children in paint order), so only the part that has expanded past the
   rim is ever visible. */
.practitioner-card__frame::before {
  content: '';
  position: absolute;
  inset: -2px;
  border-radius: 50%;
  border: 1px solid rgba(199, 175, 142, .85);
  animation: bubbleRipple 9s ease-out infinite;
  pointer-events: none;
}

.practitioner-card--laurette .practitioner-card__frame::before { animation-delay: -5.2s; }
.practitioner-card--naira    .practitioner-card__frame::before { animation-delay: -2.6s; }

/* The glass dome: a specular highlight up and to the left, over the
   photo. soft-light keeps the face's own tones rather than washing a
   flat white film across it. */
.practitioner-card__frame::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background:
    radial-gradient(58% 46% at 31% 22%, rgba(255, 255, 255, .5), rgba(255, 255, 255, 0) 68%),
    radial-gradient(70% 60% at 72% 88%, rgba(42, 33, 26, .22), rgba(42, 33, 26, 0) 70%);
  mix-blend-mode: soft-light;
  pointer-events: none;
}

.practitioner-card__photo {
  border-radius: 50%;
  /* Iris open. A circular inset rather than the page's usual bottom-up
     unwrap, so the reveal agrees with the shape instead of fighting it. */
  clip-path: inset(24% round 50%);
  transition: clip-path 1s var(--ease-out), scale .55s var(--ease-out);
}

[data-reveal].in .practitioner-card__photo,
[data-stagger].in .practitioner-card__photo,
.practitioner-card.in .practitioner-card__photo {
  clip-path: inset(0 round 50%);
}

/* Hover lifts the bubble and warms the bloom. Scale only, never
   transform, or the float stops dead for as long as the pointer rests. */
.practitioner-card:hover .practitioner-card__frame,
.practitioner-card:focus-within .practitioner-card__frame {
  scale: 1.055;
  box-shadow:
    0 26px 42px -18px rgba(42, 33, 26, .55),
    0 0 0 1px rgba(199, 175, 142, .8),
    0 0 44px 6px rgba(199, 175, 142, .55);
}

.practitioner-card:hover .practitioner-card__photo,
.practitioner-card:focus-within .practitioner-card__photo {
  scale: 1.06;
}

/* --------------------------------------------------------------------------
   12C. THE MAGNETIC BOOK CTA: [data-magnetic]
   JS writes transform directly, per pointer move, with its own easing;
   a CSS transition on transform here would fight and lag it. Every
   other .btn transition (fill, border, text colour) still applies. -------------------------------------------------------------------------- */

[data-magnetic] {
  transition: background-color .25s var(--ease-out), border-color .25s var(--ease-out),
              color .25s var(--ease-out), filter .25s var(--ease-out);
}

/* --------------------------------------------------------------------------
   13. SECTION: ABOUT (tan, split layout)
   Text left, still-linen.jpg right at full column height: a real <img>
   in .about__media, per Agent M's actual markup (.about__grid >
   .about__copy + .about__media > .about__image). room-1.jpg moved to
   #heroPoster per Agent M's call (see section 9), so this is
   still-linen.jpg, the raking-light linen fold: the room being made
   ready, immediately below the hero. No image smaller than a third of
   the viewport on desktop, per DIRECTION-V3: the media column runs a
   full half of the grid, never a thumbnail.
   -------------------------------------------------------------------------- */

.about__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-lg);
  align-items: center;
}

@media (min-width: 860px) {
  .about__grid {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-xl);
  }
}

.about__copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}

.about__copy p {
  color: var(--ink-soft);
  font-size: var(--fs-md);
  line-height: var(--lh-relaxed);
}

.about__copy p:first-of-type {
  color: var(--ink);
  font-size: var(--fs-lg);
  line-height: var(--lh-snug);
  font-family: var(--font-display);
  font-weight: 400;
}

/* The paragraph spotlight. site.js lights exactly the paragraph crossing a
   band across the middle of the viewport; the rest sit back. Transform and
   opacity only, and the unlit state is deliberately mild: this is meant to
   guide the eye down the column, not to make the other paragraphs hard to
   read for anyone who wants to skim ahead.

   No transition on the [data-reveal] entrance transform: .about__copy
   carries both data-reveal and data-spotlight, and the reveal already owns
   translateY on these same children. The spotlight uses `scale` instead,
   which is a separate property, so the two compose rather than overwrite
   one another. */
[data-spotlight] > * {
  opacity: .5;
  /* translate, not transform. .about__copy carries both data-reveal and
     data-spotlight, and the reveal already owns transform: translateY on
     these same children; writing transform here would overwrite it. The
     standalone translate and scale properties compose with it instead. */
  translate: 0 34px;
  scale: .975;
  transition: opacity .6s var(--ease-out), translate .7s var(--ease-out), scale .7s var(--ease-out);
}

[data-spotlight] > *.is-lit {
  opacity: 1;
  translate: 0 0;
  scale: 1;
}

/* The first paragraph is the display-serif lede, so it gets a little more
   travel than the body copy under it: it is the one that should feel like
   it steps forward. */
[data-spotlight] > *:first-child {
  /* The display-serif lede gets the most travel: it is the line that
     should feel like it steps forward out of the band. */
  translate: 0 46px;
  scale: .955;
}

[data-spotlight] > *:first-child.is-lit {
  translate: 0 0;
  scale: 1;
}

.about__media {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-card);
  aspect-ratio: 4 / 5;
  box-shadow: var(--ground-card-shadow);
}

@media (min-width: 860px) {
  .about__media {
    aspect-ratio: 3 / 4;
  }
}

.about__image {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
  object-position: center;
}

/* --------------------------------------------------------------------------
   14. SECTION: PRACTITIONERS (cream, bubble grid)
   Three white, 16px-radius cards, three across on desktop, one column on
   phone. A circular floating portrait bubble centred at the top of each
   card (section 12B owns the bubble itself), then centred caption below:
   name in Playfair, title and description in Jost, the review pull-quote
   closing the card. Everything centres on the bubble's axis, which is why
   this is the one card type on the page that is not left-aligned.
   -------------------------------------------------------------------------- */

/* One column until 1000px, not 760px. These cards carry a full review
   quote, and centred text in a 236px column breaks to two and three
   words a line, which was readable while the copy was left-aligned and
   is not now. The single-column stack is capped and centred rather than
   run full-bleed, so a card never gets so wide that a 208px bubble looks
   stranded in it. */
.practitioners__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-md);
  max-width: 460px;
  margin-inline: auto;
}

@media (min-width: 1000px) {
  .practitioners__grid {
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-lg);
    max-width: none;
  }
}

.practitioner-card {
  /* The bubble no longer bleeds to the card's top edge, so the card
     supplies the room its halo and ripple need on every side. */
  padding: var(--space-lg) var(--space-md) var(--space-md);
  background: var(--card);
  color: var(--ink);
  border-radius: var(--radius-card);
  overflow: hidden;
  box-shadow: var(--ground-card-shadow);
  text-align: center;
  transform: translateY(0);
  transition: transform .3s var(--ease-out), box-shadow .3s var(--ease-out);
}

/* Elevation grows on hover, not just position: translateY alone makes a
   card look like it slid rather than lifted. The ring layer inside
   --ground-card-shadow-hover stays put; only the near and ambient
   layers deepen. */
.practitioner-card:hover {
  transform: translateY(-4px);
  box-shadow: var(--ground-card-shadow-hover);
}

/* .practitioner-card__frame (the bubble) is styled in section 12B; this
   is the <img> inside it, filling the circle without distortion. No
   aspect-ratio here, because the frame is already a 1:1 box. */
.practitioner-card__photo {
  width: 100%;
  height: 100%;
  display: block;
  background-color: var(--tan-deep);
  object-fit: cover;
  object-position: center;
}

.practitioner-card__body {
  padding-top: var(--space-md);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2xs);
}

.practitioner-card__name {
  font-size: var(--fs-card-name);
  font-weight: 500;
  color: var(--ink);
}

.practitioner-card__title {
  font-size: .8125rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.practitioner-card__desc {
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
  color: var(--ink-soft);
}

.practitioner-card__quote {
  margin-top: var(--space-2xs);
  padding-top: var(--space-sm);
  border-top: 1px solid var(--line);
  font-family: var(--font-display);
  /* Not italic: the Google Fonts request only loads Playfair Display's
     roman weights, so font-style: italic here would be the browser
     synthesizing a slant by matrix transform rather than rendering the
     face's real italic cut, which looks genuinely off on a high-
     contrast display serif. The blockquote's own indent plus this
     smaller size already signal "quotation" without it. */
  font-size: var(--fs-sm);
  line-height: var(--lh-relaxed);
  color: var(--ink);
  hanging-punctuation: first;
}

.practitioner-card__quote cite {
  display: block;
  margin-top: var(--space-2xs);
  font-style: normal;
  font-size: .75rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   15. SECTION: TREATMENTS (espresso, image-led)
   massage-1.jpg opens the section full width in .treatments__opener, the
   client's own real photograph in the section's single highest-
   visibility slot. Selected rows (Hot Stones, Prenatal Massage,
   Reflexology, Natural Lift Facial) carry their own real <img> in
   .treatment-row--media, alternating side via data-side="left|right",
   which is what turns eleven text-only rows into an image-led list
   without needing eleven separate photographs. The press-and-hold
   stone sits over its own layered photographic backdrop. -------------------------------------------------------------------------- */

.treatments__opener {
  margin: 0 0 var(--space-xl);
  border-radius: var(--radius-card);
  overflow: hidden;
  /* This sits on the espresso band, where a drop shadow is invisible; a
     whisper of an inset top highlight (as if warm light from above is
     catching the frame's edge) stands in for it instead, the same
     dark-ground device .review-card uses (section 18). */
  box-shadow: inset 0 1px 0 rgba(247, 241, 232, .08);
}

.treatments__opener img {
  display: block;
  width: 100%;
  height: clamp(240px, 34vw, 460px);
  object-fit: cover;
}

.treatments__group + .treatments__group {
  margin-top: var(--space-xl);
}

.treatments__group-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-sm);
  padding-bottom: var(--space-xs);
  border-bottom: 1px solid var(--line-dark);
}

.treatments__group-name {
  font-size: var(--fs-row-name);
  font-weight: 500;
}

.treatments__group-count {
  font-size: .8125rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--bone-soft);
}

.treatment-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2xs) var(--space-md);
  padding-block: var(--space-md);
  border-bottom: 1px solid var(--line-dark);
}

@media (min-width: 640px) {
  .treatment-row {
    grid-template-columns: 1fr auto;
    align-items: baseline;
  }
}

.treatment-row__name {
  font-size: var(--fs-row-name);
  font-weight: 500;
  color: var(--bone);
}

.treatment-row__desc {
  grid-column: 1 / -1;
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
  color: var(--bone-soft);
  max-width: var(--measure);
}

/* The practitioner attribution line, sitting between the price and the
   description. Deliberately quieter than both: it answers "who" for a
   reader already scanning names from the bubbles above, and should not
   compete with the treatment's own name or price. */
.treatment-row__with {
  font-family: var(--font-body);
  font-size: .8125rem;
  letter-spacing: .04em;
  color: var(--bone-soft);
}

/* Per-treatment booking link. inline-flex so it sits at its own width
   under the description rather than stretching the full column on the
   --media rows, where the body is a grid track. */
.treatment-row__cta {
  margin-top: var(--space-sm);
}

.treatment-row__cta .btn {
  display: inline-flex;
}

.treatment-row__price {
  font-size: var(--fs-md);
  font-variant-numeric: tabular-nums;
  color: var(--bone);
  white-space: nowrap;
}

.treatment-row__price.is-enquiry {
  color: var(--bone-soft);
  font-size: .8125rem;
  letter-spacing: .08em;
  text-transform: uppercase;
}

/* Image-led rows: .treatment-row--media pairs a real photograph with the
   row's own name/price/description, alternating which side the image
   sits on via data-side, so the four illustrated rows (Hot Stones,
   Prenatal Massage, Reflexology, Natural Lift Facial) read as a
   deliberate rhythm rather than a repeated template. */
.treatment-row--media {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-md);
  align-items: center;
  border-bottom: 1px solid var(--line-dark);
  padding-block: var(--space-md);
}

@media (min-width: 640px) {
  .treatment-row--media {
    grid-template-columns: minmax(0, 220px) 1fr;
    border-bottom: none;
  }

  .treatment-row--media[data-side='right'] {
    grid-template-columns: 1fr minmax(0, 220px);
  }

  .treatment-row--media[data-side='right'] .treatment-row__media {
    order: 2;
  }
}

.treatment-row__media {
  margin: 0;
  overflow: hidden;
  border-radius: var(--radius-card);
  aspect-ratio: 4 / 5;
}

@media (min-width: 640px) {
  .treatment-row__media {
    aspect-ratio: 3 / 4;
  }
}

.treatment-row__media img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
}

.treatment-row--media .treatment-row__body {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  padding-block: 0;
}

.treatment-row--media .treatment-row__desc {
  max-width: none;
}

/* The hot-stone hold interaction: a layered photographic backdrop
   (still-heat.jpg parallaxing under still-falling.jpg's whisper of
   motion, screen-blended so it reads as drifting light rather than a
   second flat photo) behind the button and its reveal text, with a
   scrim so both stay legible over real photography. */
.stone-interaction {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-card);
  padding: var(--space-2xl) var(--space-md);
  text-align: center;
}

.stone-interaction__media {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  background: var(--card-dark);
}

.stone-interaction__bg,
.stone-interaction__ambient {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.stone-interaction__ambient {
  opacity: .45;
  mix-blend-mode: screen;
}

.stone-interaction__media::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse 70% 60% at 50% 50%,
    rgba(14, 11, 8, .35) 0%, rgba(14, 11, 8, .72) 100%);
}

.stone-interaction > .stone,
.stone-interaction > #heatStoneHint,
.stone-interaction > .heat-reveal {
  position: relative;
  z-index: 1;
}

/* --------------------------------------------------------------------------
   16. SECTION: FEES (cream, quiet data)
   Real tables on white, 16px-radius panels, a faint topographic texture
   as the section's own ground rather than any representational photo,
   so the numbers stay the one thing the eye reads.
   -------------------------------------------------------------------------- */

/* .fees__ground holds tex-contour.jpg, art-directed as a faint texture
   for a dark ground; on Fees' cream band a plain low-opacity overlay
   read as a washed-out grey smudge, so it runs through multiply blend
   instead, which only ever darkens toward the texture's own warm-black
   tone rather than greying the cream out evenly, at a whisper of
   opacity so the numbers stay the one thing competing for attention. */
.fees__ground {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}

.fees__ground img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: .09;
  mix-blend-mode: multiply;
}

.fees .container {
  position: relative;
  z-index: 1;
}

.fees__note {
  max-width: var(--measure);
  color: var(--ink-soft);
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
  margin-bottom: var(--space-lg);
  margin-inline: auto;
  text-align: center;
}

.fees__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-md);
}

@media (min-width: 820px) {
  .fees__grid {
    grid-template-columns: 1fr 1fr;
  }
}

.fees__panel {
  background: var(--card);
  color: var(--ink);
  border-radius: var(--radius-card);
  padding: var(--space-md);
  overflow-x: auto;
  box-shadow: var(--ground-card-shadow);
  transition: box-shadow .3s var(--ease-out);
}

.fees__panel:hover {
  box-shadow: var(--ground-card-shadow-hover);
}

.fees-table {
  width: 100%;
}

@media (min-width: 480px) {
  .fees-table {
    min-width: 360px;
  }
}

.fees-table caption {
  text-align: left;
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-weight: 500;
  color: var(--ink);
  padding-bottom: var(--space-xs);
}

.fees-table th,
.fees-table td {
  text-align: left;
  padding: .7em .8em;
  font-size: var(--fs-sm);
  border-bottom: 1px solid var(--line);
}

.fees-table thead th {
  font-size: .75rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink-soft);
  border-bottom: 1px solid rgba(42, 33, 26, .3);
}

.fees-table td {
  font-variant-numeric: tabular-nums;
}

.fees-table tbody tr:last-child td {
  border-bottom: none;
}

@media (max-width: 719.98px) {
  .fees-table:has(thead th:nth-child(6)) {
    display: grid;
    grid-template-columns: auto 1fr;
    column-gap: var(--space-md);
  }

  .fees-table:has(thead th:nth-child(6)) caption {
    grid-column: 1 / -1;
    grid-row: 1;
  }

  .fees-table:has(thead th:nth-child(6)) thead,
  .fees-table:has(thead th:nth-child(6)) tbody,
  .fees-table:has(thead th:nth-child(6)) tr {
    display: contents;
  }

  .fees-table:has(thead th:nth-child(6)) thead th {
    grid-column: 1;
    text-align: left;
    border-bottom: 1px solid var(--line);
  }

  .fees-table:has(thead th:nth-child(6)) tbody td {
    grid-column: 2;
    text-align: right;
  }

  .fees-table:has(thead th:nth-child(6)) thead th:nth-child(1),
  .fees-table:has(thead th:nth-child(6)) tbody td:nth-child(1) { grid-row: 2; }
  .fees-table:has(thead th:nth-child(6)) thead th:nth-child(2),
  .fees-table:has(thead th:nth-child(6)) tbody td:nth-child(2) { grid-row: 3; }
  .fees-table:has(thead th:nth-child(6)) thead th:nth-child(3),
  .fees-table:has(thead th:nth-child(6)) tbody td:nth-child(3) { grid-row: 4; }
  .fees-table:has(thead th:nth-child(6)) thead th:nth-child(4),
  .fees-table:has(thead th:nth-child(6)) tbody td:nth-child(4) { grid-row: 5; }
  .fees-table:has(thead th:nth-child(6)) thead th:nth-child(5),
  .fees-table:has(thead th:nth-child(6)) tbody td:nth-child(5) { grid-row: 6; }
  .fees-table:has(thead th:nth-child(6)) thead th:nth-child(6),
  .fees-table:has(thead th:nth-child(6)) tbody td:nth-child(6) { grid-row: 7; }
}

.fees__addon {
  margin-top: var(--space-sm);
  font-size: var(--fs-sm);
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   17. SECTION: VISIT (tan, split layout)
   Hours left; right, still-table.jpg (a real <img>, .visit__image) with
   a floating white address card (.visit__card) anchored over its lower
   edge, echoing About's split at reduced intensity, per Agent M's real
   markup: .visit__grid > .visit__hours + .visit__media > .visit__image
   + .visit__card.
   -------------------------------------------------------------------------- */

.visit__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-lg);
}

@media (min-width: 780px) {
  .visit__grid {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-xl);
    align-items: center;
  }
}

.visit__hours {
  display: flex;
  flex-direction: column;
}

.visit__hours-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-sm);
  padding-block: .6em;
  border-bottom: 1px solid var(--line);
  font-size: var(--fs-sm);
}

.visit__hours-row.is-open-late .visit__hours-time {
  color: var(--ink);
  font-weight: 500;
}

.visit__hours-day {
  color: var(--ink-soft);
}

.visit__hours-time {
  font-variant-numeric: tabular-nums;
  color: var(--ink-soft);
}

.visit__media {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-card);
  min-height: clamp(320px, 44vw, 460px);
  box-shadow: var(--ground-card-shadow);
}

.visit__image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.visit__card {
  position: relative;
  z-index: 1;
  margin: clamp(120px, 24vw, 220px) var(--space-md) var(--space-md);
  background: var(--card);
  color: var(--ink);
  border-radius: var(--radius-card);
  padding: var(--space-lg);
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  box-shadow: var(--ground-card-shadow);
}

.visit__address-block {
  font-style: normal;
  line-height: var(--lh-body);
}

.visit__card a {
  text-decoration: underline;
  text-decoration-color: var(--line);
  text-underline-offset: .15em;
}

/* --------------------------------------------------------------------------
   18. SECTION: REVIEWS (espresso, image-card carousel)
   Dark, borderless-per-DIRECTION-V3-exception raised cards (a hairline
   border stands in for the shadow DIRECTION-V3 withholds on dark
   grounds), each carrying a small circular headshot of the practitioner
   the quote is about, since all three quotes here are the same three
   quotes attributed on the Practitioners cards above.

   Built to Agent J's carousel shape: [data-carousel] is the outer
   wrapper, [data-carousel-view] the clipping viewport (overflow:hidden,
   nothing else — no padding that would let a card show past its own
   edge), [data-carousel-track] the flex row JS translates as a whole,
   and [data-carousel-prev]/[data-carousel-next] real pill buttons for
   keyboard and click control alongside the depth-drift auto-advance.
   JS writes each .review-card's own scale and opacity every frame from
   its distance to centre, so neither property gets a transition here;
   fighting a per-frame write with a CSS transition is exactly what
   would make it lag. -------------------------------------------------------------------------- */

/* .reviews__ground holds tex-linen.jpg, art-directed warm-on-dark, so
   it sits directly on this espresso band with no blend-mode correction
   needed (unlike Fees'/FAQ's cream-band textures above) — soft-light at
   a slightly higher opacity than a straight overlay would use, since
   soft-light is what lets the texture's own highlights read against
   near-black without ever washing the band out to grey. */
.reviews__ground {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}

.reviews__ground img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: .16;
  mix-blend-mode: soft-light;
}

.reviews .container {
  position: relative;
  z-index: 1;
}

.reviews__rating {
  font-size: var(--fs-sm);
  color: var(--bone-soft);
  margin-bottom: var(--space-md);
  text-align: center;
}

/* Matches the hero panel's rating treatment so the two read as the same
   claim stated twice, not two different ones. */
.reviews__rating {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .4em;
}

.reviews__star {
  width: .95em;
  height: .95em;
  margin-top: .04em;
  flex: none;
  color: var(--bone);
}

/* Second call to action, closing the section under the carousel. Matches
   .reviews__rating's centred rhythm above it. */
.reviews__cta {
  margin-top: var(--space-xl);
  text-align: center;
}

.reviews__cta-lede {
  margin-bottom: var(--space-sm);
  color: var(--bone-soft);
}

[data-carousel] {
  position: relative;
}

/* A soft edge, not a hard clip: without it the carousel reads as a
   viewport with items cut off rather than a continuous strip drifting
   past. Transparent at the very edge, opaque by 8%, so a card is never
   abruptly guillotined. */
/* overflow-x is auto, not hidden. With hidden, the strip could not be
   swiped or trackpad-scrolled at all: the only way through it was the two
   arrow buttons and the drift, which is why the third review was so hard
   to reach. The bar itself is hidden in every engine; the scrolling is
   what is wanted, not the chrome. */
[data-carousel-view] {
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -ms-overflow-style: none;
  overscroll-behavior-x: contain;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
  mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
}

[data-carousel-view]::-webkit-scrollbar {
  display: none;
}

[data-carousel-track] {
  display: flex;
  gap: var(--space-md);
  padding-block: var(--space-xs);
}

[data-carousel-track] > .review-card {
  flex: 0 0 min(84%, 360px);
}

@media (min-width: 880px) {
  [data-carousel-track] > .review-card {
    flex: 0 0 min(31%, 380px);
  }
}

[data-carousel-prev],
[data-carousel-next] {
  position: absolute;
  top: 50%;
  z-index: 2;
  transform: translateY(-50%);
  width: 46px;
  height: 46px;
  border-radius: var(--radius-pill);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line-dark);
  background: var(--card-dark);
  color: var(--bone);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color .2s var(--ease-out), border-color .2s var(--ease-out), scale .15s var(--ease-out);
}

[data-carousel-prev]:hover, [data-carousel-next]:hover,
[data-carousel-prev]:focus-visible, [data-carousel-next]:focus-visible {
  border-color: var(--bone-soft);
}

[data-carousel-prev] { left: clamp(-8px, -2vw, 0px); }
[data-carousel-next] { right: clamp(-8px, -2vw, 0px); }

[data-carousel-prev] svg, [data-carousel-next] svg {
  width: 18px;
  height: 18px;
}

.review-card {
  position: relative;
  background: var(--card-dark);
  color: var(--bone);
  border: 1px solid var(--line-dark);
  border-radius: var(--radius-card);
  padding: var(--space-lg) var(--space-md) var(--space-md);
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  /* A drop shadow is invisible on this near-black ground; a whisper of
     an inset top highlight, as if warm light from above is catching the
     card's upper edge, stands in for it instead. */
  box-shadow: inset 0 1px 0 rgba(247, 241, 232, .06);
}

/* .reviews__track carries both data-reveal and data-stagger in the real
   markup, so the generic rules in section 12 would otherwise put a .6s
   transition on this card's opacity and transform — exactly what the
   carousel's own per-frame distance-to-centre write must not fight.
   This wins on source order (declared after section 12) at equal
   selector specificity, so the entrance still happens (JS interpolates
   from the .in state directly) but nothing here fights the ongoing
   scroll-linked write once it starts. */
[data-carousel-track] > .review-card {
  transition: none;
}

/* Hidden rather than styled: the copy in the paragraph below already
   opens and closes with its own straight quotation marks, so this
   decorative glyph on top of it doubles the punctuation. Pick one; the
   paragraph's own marks are the copy gate's verbatim text and stay,
   this ornament does not. Kept in the DOM (aria-hidden already, so
   nothing is lost to assistive tech) rather than asking for a markup
   change, per the file-ownership rule. */
.review-card__mark {
  display: none;
}

/* A small circular headshot of the practitioner each quote is about,
   in the opposite corner from the quote mark so neither crowds the
   other; all three quotes here repeat the exact three attributions on
   the Practitioners cards above, so this is free, no new asset. */
.review-card::after {
  content: '';
  position: absolute;
  top: var(--space-md);
  right: var(--space-md);
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background-color: var(--tan-deep);
  background-size: cover;
  background-position: center;
  box-shadow: 0 0 0 3px var(--card-dark), 0 0 0 4px var(--line-dark);
}

.review-card:nth-of-type(1)::after { background-image: url('assets/team-deborah.jpg'); }
.review-card:nth-of-type(2)::after { background-image: url('assets/team-laurette.jpg'); }
.review-card:nth-of-type(3)::after { background-image: url('assets/team-naira.jpg'); }

.review-card__quote {
  /* Clears the circular avatar (top: var(--space-md), 36px tall) sitting
     in the same top-right corner: the card's own top padding alone
     (var(--space-lg)) is not quite enough room. */
  margin-top: var(--space-xs);
  font-family: var(--font-display);
  /* Not italic, same reasoning as .practitioner-card__quote above: no
     italic axis is loaded for Playfair Display, so this would otherwise
     be a synthesized slant, not the face's real italic cut. */
  font-size: var(--fs-md);
  line-height: var(--lh-relaxed);
  color: var(--bone);
  hanging-punctuation: first;
}

.review-card__attrib {
  font-size: .75rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--bone-soft);
}

/* --------------------------------------------------------------------------
   19. SECTION: FAQ (cream, generous accordion)
   A quiet steam texture as the section's own faint ground; the accordion
   itself stays the plainest thing on the page on purpose.
   -------------------------------------------------------------------------- */

/* .faq__ground holds tex-steam.jpg: the same multiply-blend fix as
   .fees__ground above, since it was also art-directed warm-on-dark and
   washes out flat on this cream band otherwise. */
.faq__ground {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}

.faq__ground img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: .08;
  mix-blend-mode: multiply;
}

.faq .container {
  position: relative;
  z-index: 1;
}

.faq__list {
  max-width: min(760px, 100% - var(--gutter) * 2);
  margin-inline: auto;
}

/* Same shape as .reviews__cta, on the cream band, so the lede takes the
   ink pair rather than the bone pair. */
.faq__cta {
  margin-top: var(--space-xl);
  text-align: center;
}

.faq__cta-lede {
  margin-bottom: var(--space-sm);
  color: var(--ink-soft);
}

/* Agent J measured this deliberately: the accordion animates the real
   height property, not max-height, because max-height animations either
   overshoot (a large fixed cap) or undershoot (a small one) whenever
   the answer's actual wrapped line count changes at a given viewport
   width. overflow:hidden here is what keeps the reveal clean while
   height itself is mid-transition; the transition duration below is a
   default in case JS toggles a class rather than setting its own. */
.faq-item {
  border-bottom: 1px solid var(--line);
  overflow: hidden;
  transition: height .35s var(--ease-out);
}

.faq-item__q {
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  padding-block: var(--space-md);
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-weight: 500;
  color: var(--ink);
  transition: scale .15s var(--ease-out);
}

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

.faq-item__q::after {
  content: '';
  flex: none;
  width: 9px;
  height: 9px;
  /* 1.75px, not 1.5px: this chevron sits beside Playfair 500-weight
     text; a thinner stroke reads faint and disconnected from a medium-
     weight serif label. */
  border-right: 1.75px solid var(--ink-soft);
  border-bottom: 1.75px solid var(--ink-soft);
  transform: rotate(45deg);
  transition: transform .3s var(--ease-out);
}

.faq-item[open] .faq-item__q::after {
  transform: rotate(-135deg);
}

.faq-item__a {
  padding-bottom: var(--space-md);
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   20. SECTION: BOOK (espresso, full-bleed photograph, glass closing panel)
   room-2.jpg, the client's own warm treatment room, full-bleed behind
   the closing call to action, echoing the hero's grading so the page
   visually returns to where it started. Real elements per Agent M's
   markup: .book__stage wraps .book__media (the <img>), .book__scrim
   (a real div, not a pseudo-element) and .container; normal DOM order
   already stacks scrim over photo and content over both, so no z-index
   is load-bearing here, only position for the two absolutely-placed
   layers.

   The detail classes HANDOFF-BOOKING.md section 5 asked for are all
   below. That handoff proposed four equal columns of text; this does
   not, because four columns of loose type floating on a photograph is
   what made this section read as an afterthought in the first place.
   Instead everything lands inside one smoked-glass panel, the same
   material as the floating nav pill, so the closing section is a single
   designed object rather than the hero's layout repeated with more
   words in it. The hours take the wide half, contact the narrow half,
   and the two admin notes run full width across the foot of the panel.
   -------------------------------------------------------------------------- */

/* Full-bleed to the section's own edges: .section's usual padding-block
   would otherwise inset the photograph, so it moves onto .book__stage's
   own content instead via the .container padding two rules down. */
.book.section {
  padding-block: 0;
}

.book {
  text-align: center;
}

.book__stage {
  position: relative;
  overflow: hidden;
}

.book__media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: saturate(.8) contrast(1.02) brightness(.82);
}

/* Lighter than it was. The panel supplies its own local contrast, so
   the scrim no longer has to carry the whole burden of making type
   readable, and at the old strength room-2 was so far down that the
   glass had nothing to be glass in front of. */
.book__scrim {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(ellipse 70% 60% at 50% 45%, rgba(14, 11, 8, .3) 0%, rgba(14, 11, 8, .72) 100%),
    linear-gradient(to bottom, rgba(14, 11, 8, .34), rgba(14, 11, 8, .56));
}

.book__stage .container {
  position: relative;
  padding-block: clamp(80px, 12vh, 140px);
}

.book__frame {
  max-width: 640px;
  margin-inline: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
}

.book__title {
  font-size: var(--fs-shead);
  font-weight: 600;
  color: var(--bone);
  text-shadow: 0 3px 20px rgba(0, 0, 0, .5);
}

.book__lede {
  color: var(--bone-soft);
  text-shadow: 0 2px 10px rgba(0, 0, 0, .4);
}

/* ---- The glass panel ---------------------------------------------------
   Same recipe as the nav pill in section 7: a dual-gradient fill over a
   part-transparent espresso, an ambient drop shadow, an inset top
   highlight, blur plus saturate, and a masked-gradient hairline on
   ::before that is brighter along the top edge than the bottom. Scaled
   up from a 40px pill to a 1040px panel, so the radius, the blur and the
   shadow all grow with it.
   ---------------------------------------------------------------------- */

.book__panel {
  position: relative;
  isolation: isolate;
  max-width: 1040px;
  margin: var(--space-lg) auto 0;
  border-radius: 22px;
  overflow: hidden;
  text-align: left;
  display: grid;
  grid-template-columns: 1fr;
  background:
    linear-gradient(180deg, rgba(247, 241, 232, .10), rgba(247, 241, 232, .025)),
    color-mix(in srgb, var(--espresso) 56%, transparent);
  box-shadow:
    0 34px 80px rgba(14, 11, 8, .55),
    inset 0 1px 0 rgba(247, 241, 232, .14);
  backdrop-filter: blur(18px) saturate(130%);
  -webkit-backdrop-filter: blur(18px) saturate(130%);
}

@media (min-width: 900px) {
  .book__panel {
    grid-template-columns: 1.25fr 1fr;
  }
}

.book__panel::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: inherit;
  padding: 1px;
  pointer-events: none;
  background: linear-gradient(160deg,
    rgba(247, 241, 232, .3) 0%,
    rgba(247, 241, 232, .12) 38%,
    rgba(247, 241, 232, .03) 70%,
    rgba(247, 241, 232, .12) 100%);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  mask-composite: exclude;
}

/* A very slow highlight crossing the glass, roughly once a minute. Long
   and faint on purpose: at this size anything quicker reads as a loading
   shimmer rather than as light moving across a surface. */
@keyframes panelSheen {
  0%   { transform: translate3d(-60%, 0, 0); }
  100% { transform: translate3d(160%, 0, 0); }
}

.book__panel::after {
  content: '';
  position: absolute;
  top: -40%;
  bottom: -40%;
  left: 0;
  z-index: 1;
  width: 34%;
  pointer-events: none;
  background: linear-gradient(100deg, rgba(247, 241, 232, 0), rgba(247, 241, 232, .055), rgba(247, 241, 232, 0));
  animation: panelSheen 52s linear infinite;
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .book__panel {
    background: color-mix(in srgb, var(--espresso) 90%, transparent);
  }
}

.book__panel-main,
.book__panel-side {
  position: relative;
  z-index: 2;
  padding: var(--space-lg) var(--space-md);
}

@media (min-width: 620px) {
  .book__panel-main,
  .book__panel-side {
    padding: var(--space-lg);
  }
}

/* The divider follows the layout: a horizontal rule while the panel is
   stacked, a vertical one once the two halves sit side by side. */
.book__panel-side {
  border-top: 1px solid var(--line-dark);
  display: flex;
  flex-direction: column;
  gap: var(--space-lg);
}

@media (min-width: 900px) {
  .book__panel-side {
    border-top: 0;
    border-left: 1px solid var(--line-dark);
  }
}

.book__details-head {
  font-family: var(--font-body);
  font-weight: 500;
  font-size: .8125rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--bone);
  margin-bottom: var(--space-xs);
}

.book__details-text,
.book__address {
  font-style: normal;
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
  color: var(--bone-soft);
}

/* ---- Live status pill --------------------------------------------------
   No chromatic accent, per DIRECTION-V3: open and closed are told apart
   by lightness and by whether the dot breathes, never by a green light.
   [hidden] until initHours() has decided, so a visitor with JS off never
   sees an empty pill making a claim about nothing.
   ---------------------------------------------------------------------- */

@keyframes statusPulse {
  0%   { box-shadow: 0 0 0 0 rgba(247, 241, 232, .45); }
  70%  { box-shadow: 0 0 0 8px rgba(247, 241, 232, 0); }
  100% { box-shadow: 0 0 0 0 rgba(247, 241, 232, 0); }
}

/* An explicit display:none for the hidden state. The UA sheet's
   [hidden] rule is display:none at its own low specificity, and the
   display:inline-flex below would simply beat it, leaving an empty pill
   visible before initHours() has decided anything. */
.book__status[hidden] {
  display: none;
}

.book__status {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  margin-bottom: var(--space-md);
  padding: 7px 15px;
  border-radius: var(--radius-pill);
  border: 1px solid rgba(247, 241, 232, .2);
  background: rgba(247, 241, 232, .08);
  font-size: .75rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--bone-soft);
}

.book__status-dot {
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: 50%;
  background: var(--bone-soft);
  opacity: .5;
}

.book__status.is-open {
  color: var(--bone);
  border-color: rgba(247, 241, 232, .34);
}

.book__status.is-open .book__status-dot {
  background: var(--bone);
  opacity: 1;
  animation: statusPulse 2.8s ease-out infinite;
}

/* ---- Hours -------------------------------------------------------------
   The rows run to the panel's padding edges rather than stopping at the
   text, so today's highlight reads as a band across the panel instead of
   a floating rectangle. Hence the negative margins and matching padding.
   ---------------------------------------------------------------------- */

.book__hours-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-sm);
  margin-inline: calc(var(--space-md) * -1);
  padding: .55em var(--space-md);
  border-radius: 8px;
  font-size: var(--fs-sm);
  color: var(--bone-soft);
  transition: background-color .4s var(--ease-out), color .4s var(--ease-out);
}

@media (min-width: 620px) {
  .book__hours-row {
    margin-inline: calc(var(--space-lg) * -1);
    padding-inline: var(--space-lg);
  }
}

.book__hours-row + .book__hours-row {
  box-shadow: inset 0 1px 0 var(--line-dark);
}

.book__hours-day {
  position: relative;
}

/* Today. A brighter row plus a marker sitting in the gutter the negative
   margin opened up, so nothing shifts when the class lands. */
.book__hours-row.is-today {
  background: rgba(247, 241, 232, .09);
  color: var(--bone);
}

.book__hours-row.is-today .book__hours-day::before {
  content: '';
  position: absolute;
  left: -13px;
  top: 50%;
  width: 5px;
  height: 5px;
  margin-top: -2.5px;
  border-radius: 50%;
  background: var(--bone);
}

.book__hours-row.is-today .book__hours-time {
  color: var(--bone);
}

/* ---- Contact -----------------------------------------------------------
   Links stack rather than sitting inline, so each is a comfortable tap
   target on a phone without any of them wrapping mid-address.
   ---------------------------------------------------------------------- */

.book__reach {
  display: inline-block;
  margin-top: var(--space-2xs);
  font-size: var(--fs-sm);
  color: var(--bone);
  text-decoration: underline;
  text-decoration-color: rgba(247, 241, 232, .35);
  text-underline-offset: .2em;
  transition: text-decoration-color .25s var(--ease-out);
}

.book__reach:hover {
  text-decoration-color: rgba(247, 241, 232, .9);
}

.book__block .book__reach {
  display: block;
  width: fit-content;
}

.book__reach--map {
  margin-top: var(--space-xs);
}

.book__actions {
  margin-top: var(--space-lg);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
}

/* --------------------------------------------------------------------------
   21. FOOTER (espresso)
   -------------------------------------------------------------------------- */

.site-footer {
  padding-block: var(--space-xl) var(--space-lg);
}

.site-footer__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-lg);
}

@media (min-width: 720px) {
  .site-footer__grid {
    grid-template-columns: 1.4fr 1fr 1fr;
  }
}

.site-footer__brand {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: var(--fs-lg);
  color: var(--bone);
}

.site-footer__col h3 {
  font-family: var(--font-body);
  font-size: .75rem;
  font-weight: 500;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--bone-soft);
  margin-bottom: var(--space-xs);
}

.site-footer__col ul,
.site-footer__col address {
  display: flex;
  flex-direction: column;
  gap: .4em;
  font-style: normal;
  font-size: var(--fs-sm);
  color: var(--bone-soft);
}

.site-footer__col a {
  text-decoration: none;
}

.site-footer__col a:hover,
.site-footer__col a:focus-visible {
  color: var(--bone);
}

.site-footer__bottom {
  margin-top: var(--space-xl);
  padding-top: var(--space-md);
  border-top: 1px solid var(--line-dark);
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--space-sm);
  font-size: .8125rem;
  color: var(--bone-soft);
}

/* --------------------------------------------------------------------------
   22. body.paused
   -------------------------------------------------------------------------- */

body.paused *, body.paused *::before, body.paused *::after { animation-play-state: paused !important; }
body.paused::before, body.paused::after { animation-play-state: paused !important; }

/* --------------------------------------------------------------------------
   23. REDUCED MOTION
   Honoured completely and live in both directions. Kill duration and
   delay everywhere including pseudo-elements; pin every choreographed
   element to its finished state without a blanket transform:none, which
   would break elements that use transform for real positioning (the
   hero crest/caption centring, the ring, the spine).
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    transition-delay: 0s !important;
  }

  [data-reveal] > *,
  [data-stagger] > * {
    opacity: 1;
    transform: none;
  }

  [data-spotlight] > * {
    opacity: 1;
    translate: 0 0;
    scale: 1;
  }

  [data-split='words'] .word-mask__inner {
    transform: none;
  }

  /* The bubbles keep their shape and their halo, they just stop drifting.
     The global rule above already collapses animation-duration, but the
     ripple ring starts its cycle at opacity 0 and would be frozen there
     as a permanent 1px circle sitting 2px off the rim, so hide it
     outright rather than leave a stray outline behind. */
  .practitioner-card__photo {
    clip-path: inset(0 round 50%);
  }

  .practitioner-card__frame::before {
    display: none;
  }

  [data-carousel-track] > .review-card {
    opacity: 1 !important;
    transform: none !important;
  }

  [data-magnetic] {
    transform: none !important;
  }

  .hero__crest {
    transform: translate3d(-50%, -50%, 0);
    opacity: 1;
  }

  .hero__caption {
    transform: translateX(-50%);
    opacity: 1;
  }

  [data-parallax] {
    transform: none;
    opacity: 1;
  }

  .heat-reveal .heat-reveal__item {
    opacity: 1;
    transform: none;
  }

  .slabel__rule {
    transform: scaleX(1);
  }

  .skip-link {
    transition: none;
  }
}

/* --------------------------------------------------------------------------
   24. QUALITY FLOOR, MISCELLANEOUS
   No styled scrollbar anywhere in this file: color-scheme is all that is
   set, on html, at the top of the file. Contrast: --ink on --cream/--tan
   and --bone on --espresso both clear 4.5:1 for body text; --ink-soft and
   --bone-soft are reserved for secondary text only, never for an
   interactive line thinner than the buttons/border rules above, which
   use the full-strength ink/bone pairing.
   -------------------------------------------------------------------------- */

.blob {
  border-radius: 42% 58% 55% 45% / 48% 42% 58% 52%;
}

/* A visitor with increased-contrast enabled expects a visibly wider gap
   than the default AA-passing pair, not just the pair that clears the
   floor. Re-verified against every ground each token can render on
   before shipping, the same discipline as the base tokens: --ink-soft
   3D3128 measures 7.85:1 on --tan and 5.98:1 on --tan-deep; --bone-soft
   DCD2C4 measures 13.4:1 on --espresso and 12.1:1 on --card-dark, both
   comfortably past the already-passing base values. */
@media (prefers-contrast: more) {
  :root {
    --ink-soft: #3D3128;
    --bone-soft: #DCD2C4;
  }
}

/* --------------------------------------------------------------------------
   22. SECTION JUMP ARROWS
   One press a section. Up sits at the top of the screen, down at the foot,
   so each points off the edge it takes you towards. Bare chevrons, no disc
   and no border: this page already has two kinds of pill button and a third
   would read as a third call to action rather than as a direction.

   The colour problem these have that the nav pill does not: they are fixed
   over a page that alternates espresso, tan and cream, so no single ink
   reads on everything underneath them. mix-blend-mode: difference inverts
   the chevron against whatever is behind it, which is the same trick the
   spine used and for the same reason. These are 64x38, so the compositing
   cost that made the spine worth removing on phones does not apply.

   @supports carries the fallback: a bone chevron with a dark rim, which is
   legible on the dark bands and merely quiet on the light ones.
   -------------------------------------------------------------------------- */

.jump {
  position: fixed;
  inset: 0;
  z-index: 25;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: calc(var(--header-h) + 14px) 0
           calc(20px + env(safe-area-inset-bottom, 0px));
}

/* Pushed to the foot whether or not the up arrow is there to share the
   column. space-between would send it to the top the moment it was alone. */
.jump-down { margin-top: auto; }

.jump-btn {
  pointer-events: auto;
  display: grid;
  place-items: center;
  width: 64px;
  height: 38px;
  padding: 0;
  background: none;
  border: 0;
  color: var(--bone);
  cursor: pointer;
  /* Translucent at rest: these sit over photography and over every band
     on the page, and a solid chevron read as a control bolted on top
     rather than as a quiet suggestion. They come up to full on hover and
     on focus, so the affordance is still there when it is wanted. */
  opacity: .5;
  transition: transform .25s var(--ease-out), opacity .35s var(--ease-out);
}

/* .jump-btn sets a display, which outranks the one the hidden attribute
   carries. Without this the script hides nothing. */
.jump-btn[hidden] { display: none; }

.jump-btn svg {
  display: block;
  width: 42px;
  height: auto;
  filter: drop-shadow(0 0 2px rgba(14, 11, 8, .55))
          drop-shadow(0 1px 5px rgba(14, 11, 8, .35));
}

@supports (mix-blend-mode: difference) {
  .jump-btn svg {
    mix-blend-mode: difference;
    filter: none;
    color: #FFFFFF;
  }
}

.jump-up svg { transform: rotate(180deg); }

.jump-btn:hover, .jump-btn:focus-visible { opacity: 1; }
.jump-up:hover { transform: translateY(-3px); }
.jump-down:hover { transform: translateY(3px); }

/* The down arrow breathes while it is the only thing asking to be pressed,
   which is the whole time the hero is on screen. It stops as soon as the
   reader has started moving, so it is an invitation rather than a nag. */
@keyframes jumpNudge {
  0%, 72%, 100% { transform: translateY(0); }
  84%           { transform: translateY(5px); }
}

.jump-down.is-inviting { animation: jumpNudge 3.4s var(--ease-out) infinite; }
.jump-down.is-inviting:hover { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .jump-down.is-inviting { animation: none; }
}
