/* ============================================================
   Dr. Mark McKelvie — portfolio, v2
   Layout rules live here; every number that came off a reference
   site lives in tokens.css with its source noted.
   ============================================================ */

*, *::before, *::after { box-sizing: border-box; }
html {
  -webkit-text-size-adjust: 100%;
  /* Reserve the scrollbar track permanently. Both the intro and the detail
     overlay lock scrolling; without this, removing the scrollbar shifts the
     layout by its width and the card rectangle measured for the detail
     transition is wrong by that much. */
  scrollbar-gutter: stable;
}
body {
  margin: 0;
  background: var(--ivory-50);
  color: var(--ink-900);
  font-family: "Jost", "Avenir Next", Avenir, system-ui, sans-serif;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  letter-spacing: var(--track-body);
  font-weight: 350;
  -webkit-font-smoothing: antialiased;
}
img { max-width: 100%; display: block; }
a { color: inherit; }

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 10000;
  background: var(--teal-600); color: var(--ivory-25);
  padding: 12px 20px; font-size: var(--fs-small);
}
.skip:focus { left: 12px; top: 12px; }

:focus-visible { outline: 2px solid var(--teal-500); outline-offset: 3px; }

/* --- Type primitives --------------------------------------------------- */
.display, .h2, .h3, .vh__quote p {
  font-family: "Cormorant Garamond", Cormorant, Garamond, Georgia, serif;
  font-weight: 300;
  margin: 0;
}
.h2 {
  font-size: var(--fs-h2); line-height: var(--lh-h2);
  letter-spacing: var(--track-h2);
}
.h3 {
  font-size: var(--fs-h3); line-height: var(--lh-h3);
  letter-spacing: var(--track-h3);
}
.h2 em, .h3 em, .vh__quote em, .display em { font-style: italic; color: var(--stone-500); }
.lead {
  font-size: var(--fs-lead); line-height: var(--lh-lead);
  font-weight: 300; margin: 0;
}
.body-sm { font-size: var(--fs-small); color: var(--stone-600); margin: 0; }
p { margin: 0 0 1em; }

.label {
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  font-weight: 400;
  color: var(--stone-600);
  margin: 0;
}
.label--teal { color: var(--teal-500); }

.rule { border: 0; border-top: 1px solid var(--ivory-400); margin: 0; width: 56px; }
.rule--teal { border-color: var(--teal-500); }

/* Navy, not teal — Zax, 2026-09-08: match the "Book" CTA to the intro
   loader's own navy (--navy-900, lifted from the approved logo), so the
   two most prominent brand-color moments on the page (the opening navy
   loader, the CTA a reader actually presses) read as the same colour
   rather than two different ones. Global .btn, so every "Book a
   Consultation" on the site moves together — hero, consult section,
   footer, the enquiry modal's own submit button. */
.btn {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 15px 26px;
  background: var(--navy-900); color: var(--ivory-25);
  font-size: var(--fs-small); letter-spacing: .1em; text-transform: uppercase;
  text-decoration: none; border: 1px solid var(--navy-900);
  transition: background var(--dur-fast) var(--ease-circ),
              color var(--dur-fast) var(--ease-circ);
}
.btn:hover { background: var(--teal-700); border-color: var(--teal-700); }
.btn--ghost { background: transparent; color: var(--ink-900); border-color: var(--ivory-400); }
.btn--ghost:hover { background: var(--ivory-100); color: var(--ink-900); border-color: var(--stone-500); }

/* =====================================================================
   00 · INTRO
   ===================================================================== */
.intro {
  position: fixed; inset: 0; z-index: 9000;
  pointer-events: auto;
}
.intro[data-phase="6"] { display: none; }

/* Navy, not teal. --teal-700 is hue 185deg — cyan-green — and read as
   green over a full screen even though it looks like a dark teal in a
   swatch. --navy-900 is the ink navy lifted from the approved logo, so the
   loading screen is now literally the logo's own dark. */
.intro__half {
  position: absolute; left: 0; width: 100%; height: 50.2%;
  background: var(--navy-900);
  transition: transform .9s var(--ease-expo);
}
.intro__half--top { top: 0; }
.intro__half--bot { bottom: 0; }

.intro__centre {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  transition: opacity .5s var(--ease-circ);
}
.intro__mono {
  font-family: "Cormorant Garamond", Georgia, serif;
  font-size: clamp(58px, 20px + 100vw * .055, 118px);
  line-height: 1; color: var(--ivory-50);
  display: flex;
}
.intro__mono span {
  display: block;
  transition: transform .8s var(--ease-expo), opacity .6s var(--ease-circ);
}
.intro__mono-b { margin-left: -.34em; color: var(--stone-logo); }

.intro__count {
  position: absolute; right: clamp(20px, 4vw, 64px); bottom: clamp(20px, 4vw, 56px);
  display: flex; color: var(--ivory-50);
  font-size: clamp(28px, 12px + 100vw * .022, 54px);
  line-height: 1; font-weight: 300;
  font-variant-numeric: tabular-nums;
  transition: opacity .45s var(--ease-circ);
}
.reel { display: block; height: 1em; overflow: hidden; }
.reel i {
  display: block; height: 1em; font-style: normal;
  transition: none;
}
.reel > span { display: block; transition: transform .16s var(--ease-circ); }

.intro__lineWrap {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%) rotate(0deg);
  transition: transform .68s var(--ease-expo);
}
.intro__line {
  width: 1px; height: 100vh;
  background: rgba(245, 241, 234, .55);
  transform-origin: 50% 0;
  transform: scaleY(0);
  transition: transform .74s var(--ease-expo), height .6s var(--ease-expo),
              opacity .3s linear;
}
.intro__skip {
  position: absolute; left: clamp(20px, 4vw, 64px); bottom: clamp(20px, 4vw, 56px);
  color: var(--ivory-300); opacity: .68;   /* .55 composited to 4.06:1 on navy — under AA */
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; margin: 0;
  transition: opacity .4s linear;
}

/* phase 1 — monogram parts separate, counter goes */
.intro[data-phase="1"] .intro__mono-a,
.intro:is([data-phase="2"],[data-phase="3"],[data-phase="4"],[data-phase="5"]) .intro__mono-a
  { transform: translateY(-.42em); opacity: 0; }
.intro[data-phase="1"] .intro__mono-b,
.intro:is([data-phase="2"],[data-phase="3"],[data-phase="4"],[data-phase="5"]) .intro__mono-b
  { transform: translateY(.42em); opacity: 0; }
.intro:not([data-phase="0"]) .intro__count { opacity: 0; }
.intro:not([data-phase="0"]) .intro__skip { opacity: 0; }

/* phases 2–5 — the line is drawn, rotated, extended */
.intro:is([data-phase="2"],[data-phase="3"],[data-phase="4"],[data-phase="5"]) .intro__line
  { transform: scaleY(1); }
.intro:is([data-phase="3"],[data-phase="4"],[data-phase="5"]) .intro__lineWrap
  { transform: translate(-50%, -50%) rotate(90deg); }
.intro:is([data-phase="4"],[data-phase="5"]) .intro__line { height: 100vw; }
.intro[data-phase="5"] .intro__line { opacity: 0; transition-delay: .12s; }

/* phase 5 — the eyes open */
.intro[data-phase="5"] .intro__half--top { transform: translateY(-100%); }
.intro[data-phase="5"] .intro__half--bot { transform: translateY(100%); }

html.intro-lock, html.intro-lock body { overflow: hidden; height: 100%; }
html.intro-lock .nav { opacity: 0; }

/* =====================================================================
   NAV
   ===================================================================== */
.nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 700;
  transition: opacity .6s var(--ease-circ), background var(--dur-fast) linear;
}
.nav.is-stuck { background: rgba(245, 241, 234, .88); backdrop-filter: blur(10px); }
.nav__inner {
  width: var(--grid-width); margin-inline: auto;
  display: flex; align-items: center; justify-content: space-between;
  gap: 20px; padding: 20px 0;
}
.nav__mark { display: flex; align-items: center; gap: 12px; text-decoration: none; }
.nav__name {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--ink-900);
}
.nav__links { display: flex; gap: clamp(16px, 2vw, 34px); }
.nav__link {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; text-decoration: none; color: var(--stone-600);
  transition: color var(--dur-fast) linear;
}
.nav__link:hover { color: var(--teal-500); }
.nav__cta {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; text-decoration: none; color: var(--ink-900);
  border-bottom: 1px solid var(--stone-500); padding-bottom: 3px;
}

/* =====================================================================
   01 · HERO — scroll-scrubbed video

   Height is a pre-JS fallback only; main.js writes it from HERO_VH so the
   scrub pacing and the scroll distance cannot drift apart. Same contract
   as the journey's BEATS table.
   ===================================================================== */
/* Pre-JS fallback only — main.js writes HERO_VH (1800vh today) over it
   on first layout. Kept short deliberately so a no-JS render is a
   readable poster rather than 16,000px of empty scroll; it is NOT the
   live height, and 300 has never been it since the section was tuned. */
.vh { position: relative; height: 300vh; }
.vh__stage {
  position: sticky; top: 0; height: 100vh;
  overflow: hidden; background: var(--ivory-100);
}

.vh__poster, .vh__canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
/* The portrait's face and shoulders stay centred through the cover crop. */
.vh__poster { object-fit: cover; object-position: 50% 25%; }
.vh__canvas { display: block; opacity: 0; transition: opacity .28s var(--ease-circ); }
.vh__canvas.is-live { opacity: 1; }

/* Dr McKelvie's own signature as a traced vector outline. Ordered pen masks
   follow its strokes, in writing order, after the editorial copy has left. */
.vh__signature {
  display: block;
  position: absolute;
  z-index: 2;
  /* Seated higher than the old, shorter font signature (was clamp(238px, 32vh, 326px)):
     his real signature has two tall descenders, and at 4:3 the first one met his shoulder. */
  top: clamp(214px, 28vh, 310px);
  right: clamp(30px, 4vw, 64px);
  margin: 0;
  color: var(--ink-900);   /* his signature is black ink; the site's own soft black */
  width: clamp(350px, 37.5vw, 548px);
  height: auto;
  overflow: visible;
  opacity: 0;
  pointer-events: none;
  filter: drop-shadow(0 1px 2px rgba(245, 241, 234, .45));
}
.vh__signature-ink { fill: currentColor; }
.vh[data-mode="live"] [data-vh-signature-path] { stroke-dasharray: 1; stroke-dashoffset: 1; }

/* Grade: the footage is a warm hotel atrium and the site is ivory. A flat
   low-alpha ivory lift plus top/bottom falloff ties them together without
   a per-frame canvas filter, which would cost GPU time on every scroll.

   Both ends lift toward ivory rather than darkening. The nav and the scroll
   marker are soft-black type sitting over this, so a dark vignette at the
   edges — the cinematic default — is exactly backwards here. */
.vh__grade {
  position: absolute; inset: 0; pointer-events: none;
  background:
    linear-gradient(to bottom,
      rgba(245, 241, 234, .45) 0%, rgba(245, 241, 234, 0) 16%,
      rgba(245, 241, 234, 0) 82%, rgba(34, 32, 30, .22) 100%),
    rgba(245, 241, 234, .09);
}

/* QUIET VIGNETTE — chosen by Zax 2026-09-08 from ten reveal treatments
   compared in an artifact (link in MEMORY.md), replacing the ivory-panel
   scrim this section used before. No graphic panel: a soft, blurred,
   photographic-style dark vignette sits low-contrast behind each column —
   the footage stays visible edge to edge, nothing is covered. Type turns
   ivory with a soft shadow instead of soft-black on a panel, which is why
   this only reads correctly in LIVE mode; static mode (below) stacks the
   copy under the picture on the page's own ivory ground, so it reverts to
   the normal dark-ink colours there — see the `[data-mode="static"]`
   overrides at the bottom of this block. */
.vh__scrim {
  position: absolute; top: 6%; bottom: 6%; width: 40%;
  pointer-events: none; opacity: 0;
  filter: blur(20px);
}
.vh__scrim--l {
  left: 0;
  background: radial-gradient(68% 88% at 32% 50%,
    rgba(20, 18, 16, .40) 0%, rgba(20, 18, 16, .16) 55%, rgba(20, 18, 16, 0) 78%);
}
.vh__scrim--r {
  right: 0;
  background: radial-gradient(68% 88% at 68% 50%,
    rgba(20, 18, 16, .40) 0%, rgba(20, 18, 16, .16) 55%, rgba(20, 18, 16, 0) 78%);
}

/* left/width are written by main.js from his measured frame edges projected
   through the canvas cover-fit — see layout() in the hero module. Centred a
   little below the middle so a tall column clears the fixed nav. */
.vh__col { position: absolute; top: calc(50% + 22px); transform: translateY(-50%); }
.vh__col .label, .vh__col .vh__quote p, .vh__col .vh__quote cite,
.vh__col .lead, .vh__col .vh__facts dd {
  color: var(--ivory-25);
  text-shadow: 0 1px 10px rgba(0, 0, 0, .45), 0 0 1px rgba(0, 0, 0, .3);
}
.vh__col .vh__facts { border-top-color: rgba(245, 241, 234, .28); }
.vh__col .vh__facts > div { border-bottom-color: rgba(245, 241, 234, .2); }
.vh__col .btn--ghost { color: var(--ivory-25); border-color: rgba(245, 241, 234, .55); }

/* ACCENT COLOUR — four variants for review, 2026-09-08. `--teal-400` on the
   eyebrow ("Physician · Cambridge") measured under 2:1 against the photo —
   Zax flagged it as unreadable, correctly; the whole teal/stone scale is
   tuned for the ivory page, not a photograph. Each variant restyles the
   same three accent spots (eyebrow label + its rule, the quote's italic
   word, the fact labels) without touching layout, type size, or position.
   Toggle with data-hero-accent="1|2|3|4" on <section class="vh" id="top">;
   no attribute (or "1") is Warm Ivory, the default. Delete the three
   unused blocks and this comment once Zax picks one — see MEMORY.md
   2026-09-08. */

/* 1 — WARM IVORY: no separate accent colour at all, the label/rule/dt just
   step down within the same ivory family already proven legible for the
   body copy. Safest option, and the quietest — no cool teal on the hero. */
.vh:not([data-hero-accent]) .label--teal,
.vh[data-hero-accent="1"] .label--teal { color: var(--ivory-200); }
.vh:not([data-hero-accent]) .vh__quote em,
.vh[data-hero-accent="1"] .vh__quote em { color: var(--ivory-300); }
.vh:not([data-hero-accent]) .vh__facts dt,
.vh[data-hero-accent="1"] .vh__facts dt { color: var(--navy-900); text-shadow: 0 1px 6px rgba(245, 241, 234, .55); }
.vh:not([data-hero-accent]) .vh__rule,
.vh[data-hero-accent="1"] .vh__rule { background: var(--ivory-200); }

/* 2 — BRIGHT BRONZE: the brand's warm accent, lightened to actually read on
   a photo (--bronze-onphoto). Eyebrow, rule and fact labels all pick up a
   warm gold; keeps the accent visible but still one family with the ivory
   body type — same warmth, more saturated. */
.vh[data-hero-accent="2"] .label--teal { color: var(--bronze-onphoto); }
.vh[data-hero-accent="2"] .vh__quote em { color: var(--bronze-onphoto); }
.vh[data-hero-accent="2"] .vh__facts dt { color: var(--navy-900); text-shadow: 0 1px 6px rgba(245, 241, 234, .55); }
.vh[data-hero-accent="2"] .vh__rule { background: var(--bronze-onphoto); }

/* 3 — PALE TEAL: the site's own cool counterpoint (Georgie's-teal reasoning
   in tokens.css), same hue as the broken --teal-400, lightened instead of
   abandoned (--teal-onphoto) so the brand's one cool accent survives on
   the hero instead of disappearing into all-warm. */
.vh[data-hero-accent="3"] .label--teal { color: var(--teal-onphoto); }
.vh[data-hero-accent="3"] .vh__quote em { color: var(--ivory-300); }
.vh[data-hero-accent="3"] .vh__facts dt { color: var(--navy-900); text-shadow: 0 1px 6px rgba(245, 241, 234, .55); }
.vh[data-hero-accent="3"] .vh__rule { background: var(--teal-onphoto); }

/* 4 — IVORY + BRONZE SPLIT: eyebrow/label stay neutral ivory (matching the
   body exactly, nothing competes with it), and the brand warmth is spent
   in exactly one place instead — the quote's emphasised word and the fact
   labels — an editorial "one accent, not everywhere" split. */
.vh[data-hero-accent="4"] .label--teal { color: var(--ivory-200); }
.vh[data-hero-accent="4"] .vh__quote em { color: var(--bronze-onphoto); }
.vh[data-hero-accent="4"] .vh__facts dt { color: var(--navy-900); text-shadow: 0 1px 6px rgba(245, 241, 234, .55); }
.vh[data-hero-accent="4"] .vh__rule { background: var(--ivory-200); }
.vh__eyebrow { display: flex; align-items: center; gap: 16px; margin-bottom: clamp(18px, 2.6vh, 30px); }
.vh__rule { display: block; width: 40px; height: 1px; background: var(--teal-600); }

.vh__quote { margin: 0 0 clamp(24px, 3.4vh, 40px); }
/* Sized from --colw (written by layout()), not 100vw: this column is about
   a fifth of the viewport, so viewport-scaled display type overflows it. */
.vh__quote p {
  font-size: clamp(25px, calc(var(--colw, 320px) * .118), 46px);
  line-height: 1.02; letter-spacing: var(--track-h2);
  margin: 0 0 16px;
}
.vh__quote cite {
  font-style: normal; display: block;
  /* The qualification line is already dense. Slightly tighter tracking
     keeps MD MRCS with the rest instead of leaving one credential orphaned. */
  letter-spacing: .18em;
}

/* Also column-scaled: --fs-lead is tuned for a 46ch measure, and this
   column is nearer 24ch. */
.vh__col--r .lead {
  margin: 0 0 clamp(20px, 3vh, 32px);
  font-size: clamp(15px, calc(var(--colw, 320px) * .058), 20px);
}
.vh__facts { margin: 0; display: grid; gap: 0; border-top: 1px solid var(--ivory-300); }
.vh__facts > div {
  display: grid; grid-template-columns: 11ch 1fr; gap: 14px;
  padding: 10px 0; border-bottom: 1px solid var(--ivory-300);
}
/* Below ~340px the term column leaves too little for the value — the
   qualifications row wraps to four lines. Stack instead. */
.vh__col--r[data-narrow="1"] .vh__facts > div { grid-template-columns: 1fr; gap: 3px; }
.vh__facts dt {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--stone-600); padding-top: .25em;
}
.vh__facts dd { margin: 0; font-size: var(--fs-small); }

.vh__actions { display: flex; flex-wrap: wrap; gap: 12px; }

.vh__scrollmark {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: clamp(18px, 4vh, 40px);
  display: flex; align-items: center; gap: 14px;
}
.vh__scrollmark .label { color: var(--ivory-50); }
.vh__scrollmark .rule { width: 40px; border-top-color: rgba(245, 241, 234, .7); }

/* ---------------------------------------------------------------------
   Reveal primitive — the OriginKit "Scroll Text Reveal" effect, rebuilt
   without React, GSAP or framer-motion (this site has no dependencies).

   The reference masks per LINE. This masks per WORD instead, which looks
   the same for type that rises vertically but needs no line measurement,
   survives inline markup like <em>, and — the real win — never has to be
   re-split on resize when the line breaks change.

   overflow:hidden on an inline-block moves its baseline to the bottom
   margin edge. Every word is wrapped, so they all move together and stay
   aligned; the padding/negative-margin pair extends the clip box past the
   descenders without changing where that baseline lands.
   --------------------------------------------------------------------- */
.rv-w {
  display: inline-block; overflow: hidden;
  padding-block: .18em; margin-block: -.18em;
}
.rv-c { display: inline-block; }

/* ---------------------------------------------------------------------
   Static fallback: under 900px, or reduced motion. Everything unpins and
   the hero becomes an ordinary stacked block with the poster on top.
   --------------------------------------------------------------------- */
.vh[data-mode="static"] { height: auto !important; }
.vh[data-mode="static"] .vh__stage {
  position: static; height: auto; overflow: visible;
  padding-bottom: clamp(40px, 7vh, 80px);
}
.vh[data-mode="static"] .vh__canvas,
.vh[data-mode="static"] .vh__scrim,
.vh[data-mode="static"] .vh__grade,
.vh[data-mode="static"] .vh__scrollmark { display: none; }
.vh[data-mode="static"] .vh__poster {
  position: relative; inset: auto;
  width: 100%; height: clamp(280px, 48vh, 460px);
  margin-bottom: clamp(28px, 5vh, 52px);
}
.vh[data-mode="static"] .vh__signature {
  position: relative;
  top: auto; right: auto;
  width: min(540px, calc(100% - (2 * var(--grid-margin))));
  max-width: calc(100% - (2 * var(--grid-margin)));
  margin: -18px var(--grid-margin) 22px auto;
  opacity: .88;
}
.vh[data-mode="static"] [data-vh-signature-path] { stroke-dasharray: none; stroke-dashoffset: 0; }
.vh[data-mode="static"] .vh__col {
  position: static; transform: none;
  left: auto !important; width: auto !important;
  padding-inline: var(--grid-margin);
}
.vh[data-mode="static"] .vh__col--r { margin-top: clamp(26px, 4vh, 44px); }
/* Quiet Vignette's ivory-on-photo type is a LIVE-mode-only treatment — in
   static mode the copy stacks under the picture on the page's own ivory
   ground (no photo behind it any more), so it has to go back to the normal
   dark-ink palette or it is ivory text on an ivory page, invisible. */
.vh[data-mode="static"] .vh__col .label,
.vh[data-mode="static"] .vh__col .vh__quote p,
.vh[data-mode="static"] .vh__col .vh__quote cite,
.vh[data-mode="static"] .vh__col .lead,
.vh[data-mode="static"] .vh__col .vh__facts dd {
  color: var(--ink-900);
  text-shadow: none;
}
.vh[data-mode="static"] .vh__col .label--teal { color: var(--teal-500); }
.vh[data-mode="static"] .vh__col .vh__quote em { color: var(--stone-500); }
.vh[data-mode="static"] .vh__col .vh__facts dt { color: var(--stone-600); text-shadow: none; }
.vh[data-mode="static"] .vh__col .vh__facts { border-top-color: var(--ivory-300); }
.vh[data-mode="static"] .vh__col .vh__facts > div { border-bottom-color: var(--ivory-300); }
.vh[data-mode="static"] .vh__col .vh__rule { background: var(--teal-600); }
.vh[data-mode="static"] .vh__col .btn--ghost { color: var(--ink-900); border-color: var(--ivory-400); }
/* No reveal without the scroll that drives it. */
.vh[data-mode="static"] .rv-w { overflow: visible; }
.vh[data-mode="static"] .rv-c {
  transform: none !important; filter: none !important; opacity: 1 !important;
}

/* =====================================================================
   THE JOURNEY — one pinned section, six stacked stages.
   Height is written by main.js from the BEATS table so pacing lives in
   exactly one place; the value here is only a pre-JS fallback.
   ===================================================================== */
/* Pre-JS fallback only — main.js writes the BEATS total (7910vh today)
   over it on first layout. Same reasoning as .vh above: this number is
   a floor for a no-JS render, not a copy of the live one, and it will
   go stale every time the beat table moves. Do not "sync" it. */
.jy { position: relative; height: 6750vh; }
.jy__stage {
  /* ONE ARM LENGTH FOR EVERYTHING THAT HAS TO CLEAR IT — the branch, the
     node, the card, and the running note's width. See the note beside
     .tree__branch below. */
  --tree-arm: 11vw;
  /* THE BACKING EVERY PIECE OF FIXED FURNITURE WEARS, in one place.
     The chapter mark, the running note, the scroll hint, the click tip and
     the chapter rail are all pinned while whole strips of cards travel
     underneath them, so each needs to stay legible over moving content.

     FEATHERED, NOT A HARD CHIP (2026-09-09). A flat `rgba(ivory,.9)` box is
     invisible on the ivory ground and reads as a pale RECTANGLE the moment
     the ground is anything else — which is exactly what Zax flagged on the
     clinic beat and what was still logged open: "legible and blocking
     nothing, but not beautiful". The fill is lighter now and a blurred
     shadow of the same colour carries it out past its own edge, so it
     fades into whatever is behind instead of drawing a border on it. Same
     idea as the hero's Quiet Vignette, which he picked for this exact
     problem: ivory type over a photograph, softened rather than panelled. */
  --chip-fill: rgba(245, 241, 234, .78);
  --chip-feather: 0 0 13px 9px rgba(245, 241, 234, .58);
  position: sticky; top: 0; height: 100vh; overflow: hidden;
  background: var(--ivory-50);
}
/* Every stage occupies the same box and is faded in and out by progress.
   `visibility` rides along with opacity so a stage that is fully faded
   costs nothing to hit-test and cannot swallow a click meant for the one
   on top of it. */
.jy__layer {
  position: absolute; inset: 0;
  opacity: 0; visibility: hidden;
  pointer-events: none;
}
.jy__layer.is-live { visibility: visible; }
.jy__layer[data-layer="ring"].is-live { pointer-events: auto; }

/* Explicit stacking, and it is load-bearing. A layer at full opacity does
   NOT create a stacking context, so the ring's cards (z-index 10–100, set
   inline) would otherwise escape their layer and paint over the clinic
   image growing on top of them. Giving each layer its own z-index both
   fixes the order and boxes every child's z-index inside its own layer.
   The work layer sits above the close so the node that writes the line
   stays visible over it. */
.jy__layer[data-layer="ring"]   { z-index: 10; }
.jy__layer[data-layer="clinic"] { z-index: 20; }
.jy__layer[data-layer="tree"]   { z-index: 30; }
.jy__layer[data-layer="fin"]    { z-index: 45; }
.jy__layer[data-layer="work"]   { z-index: 50; }

/* z-index 130 keeps the chapter label above the cards' resting stack
   (10 + order) and above a lifted card (100). Without it the ring's own
   top card sweeps over the label at wide spread. */
/* MOVED TOP-LEFT → TOP-RIGHT, 2026-09-09. Zax: "look at the clinic
   section. the label is right on top of the description field it's ruining
   the look, move this label to the top right corner."

   He is right and the old comment below was wrong about the remedy. The
   ivory backing does stop the label being *illegible*, but it cannot stop
   it being *in the way*: on the clinic stage it landed square on the
   "Where the conversations happen." headline, and a chip of uppercase 11px
   type sitting on a 64px serif headline ruins both regardless of how
   readable each one is on its own. Legibility was never the problem.

   Top-right is the one corner nothing occupies for long: the copy column
   and the clinic panel are left-aligned, the ring's cards sit centre-right
   but below this line, and the fixed nav is above it. The backing stays,
   because the ring cards DO pass under it at some scroll positions. */
.jy__mark {
  position: absolute; top: clamp(84px, 12vh, 130px); right: var(--grid-margin);
  z-index: 130; margin: 0;
  display: inline-block;
  text-align: right;
  background: var(--chip-fill);
  box-shadow: var(--chip-feather);
  padding: 5px 10px; margin-right: -10px;
  backdrop-filter: blur(3px);
  /* Written per frame by main.js — see the chapter-change dip in render(). */
  opacity: 1;
}
/* Sits directly under the chapter mark, in the same clear top-right corner,
   so the two read as one small caption block rather than two labels
   competing for attention. Sentence case and a body face on purpose — the
   mark is the LABEL, this is the SENTENCE, and setting both in tracked-out
   uppercase would make neither read. Opacity is written per frame by
   main.js across the work and review stages only. */
.jy__note {
  position: absolute;
  top: calc(clamp(84px, 12vh, 130px) + clamp(24px, 3.4vh, 34px));
  right: var(--grid-margin);
  z-index: 130; margin: 0;
  /* WIDTH IS THE FREE GUTTER, NOT A GUESS (2026-09-09). At `min(34ch,30vw)`
     this caption reached back over the right-hand column of photographs —
     measured 103x19px onto one at 1280x800, 81x62px at 1440x900. The third
     term below is exactly what is left between the outer edge of a
     right-hand `.tree__card` (50% + arm + 14px + its own width) and the
     grid margin, so the note stops where the cards begin at any viewport.
     Every value in it is the same one the card rules read, so narrowing the
     arm or the cards widens this automatically. */
  width: min(34ch, 30vw,
    calc(50vw - var(--tree-arm, 11vw) - 14px - clamp(180px, 20vw, 280px)
         - var(--grid-margin)));
  text-align: right;
  font-size: clamp(12px, min(1.8vh, 1.15vw), 15px);
  line-height: 1.45;
  color: var(--stone-600);
  background: var(--chip-fill);
  box-shadow: var(--chip-feather);
  padding: 6px 10px; margin-right: -10px;
  backdrop-filter: blur(3px);
  opacity: 0;
  pointer-events: none;
}
/* Static mode stacks the stages as ordinary content, where each one already
   has its own heading in the flow — a floating caption would just repeat
   itself above every strip. */
[data-mode="static"] .jy__note { display: none; }

.jy__hint {
  position: absolute; bottom: clamp(26px, 5vh, 54px); left: var(--grid-margin);
  z-index: 130; margin: 0;
  display: inline-block;
  background: var(--chip-fill);
  box-shadow: var(--chip-feather);
  padding: 5px 10px; margin-left: -10px;
  backdrop-filter: blur(3px);
}
/* The click instruction. Faded in and out by main.js across exactly the
   stretch where clicking a card does something.

   MOVED TO THE LEFT, 2026-09-09. It used to sit at the right margin,
   "opposite Keep scrolling on the same baseline, so the bottom of the
   stage reads as one row of quiet instructions". Two things were wrong
   with that. The row of two never existed: the hint is gone by a sixth of
   `words` and the tip does not arrive until `deckIn`, and a sweep of all
   25 beats at three points each found ZERO states where both are visible.
   And the right margin is where the card fan is deliberately cropped by
   the viewport edge (the "ferris wheel seen from the side" silhouette Zax
   asked for on 2026-09-08) — so the outermost card sat on the last word of
   this label, measured 65x19px, at six of those states.

   Left margin instead: same baseline, no competition, and clear of the
   crop. It takes the hint's backing chip too, which it never had — the two
   are a matched pair and only one of them had been made legible over
   moving content. */
.jy__tip {
  position: absolute; bottom: clamp(26px, 5vh, 54px); left: var(--grid-margin);
  z-index: 130; margin: 0; opacity: 0;
  display: inline-flex; align-items: center; gap: 9px;
  color: var(--teal-500);
  background: var(--chip-fill);
  box-shadow: var(--chip-feather);
  padding: 5px 10px; margin-left: -10px;
  backdrop-filter: blur(3px);
  pointer-events: none;
}
.jy__tipMark {
  display: inline-grid; place-items: center;
  width: 19px; height: 19px; border-radius: 50%;
  border: 1px solid var(--teal-500);
}
.jy__tipMark svg { width: 11px; height: 11px; }
.jy__tipMark path, .cardface__more path {
  fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;
}

/* ---------------------------------------------------------------------
   THE CHAPTER RAIL
   A table of contents down the left edge of the pinned journey. It names
   the five chapters, lights the one you are in, and travels to any other.
   Deliberately NOT styled as a button set: no fills, no borders, no
   pills — at this size and weight it reads as a contents list, which is
   the point. A row of buttons would announce itself as a skip control.

   Sits at the same z-index as the rest of the stage furniture (.jy__mark,
   .jy__tip) but takes pointer events back, since those two do not.
   Opacity is driven per-frame from main.js so it arrives with the journey
   and leaves with the closing sequence.

   AT REST IT IS NUMBERS ONLY, AND THAT IS NOT a style preference — it is
   the only position that does not wreck the page. The first build showed
   the full chapter names down the left edge and a sweep caught them
   sitting directly on top of the clinic panel's address block and the
   card takeover copy, both of which start at the grid margin. There is no
   free column at that width. So the rail lives OUTSIDE the margin as a
   narrow strip of numbers, and the names slide out over the content only
   while the reader is pointing at it, which is the one moment covering
   the content is the right thing to do.

   The names are hidden with width/opacity, never display:none, so each
   button keeps its accessible name for screen readers at all times. */
.rail {
  position: absolute;
  left: 10px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 130;
  opacity: 0;
  pointer-events: none;               /* re-enabled per button, below */
  transition: opacity var(--dur-mid) var(--ease-circ);
}
.rail__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }

.rail__b {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 5px 8px 5px 9px;
  /* A BACKING AT REST, not only on hover (2026-09-09). The rail is pinned
     to the vertical centre of the stage and `.work__spine` is a 3px teal
     rule at top:50% — so once the reviews strip has travelled left, that
     rule runs the full width of the screen straight through the rail's
     middle row. A pale number with a saturated rule behind it reads as a
     strikethrough. This is the same translucent ivory `.jy__mark`,
     `.jy__note` and `.jy__hint` already carry for the same reason, and it
     is invisible on the ivory ground — it only shows where something is
     passing behind. */
  background: var(--chip-fill);
  box-shadow: var(--chip-feather);
  backdrop-filter: blur(3px);
  border: 0;
  border-left: 1px solid transparent;
  font: inherit;
  font-size: var(--fs-micro);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--stone-600);
  text-align: left;
  cursor: pointer;
  pointer-events: auto;
  transition: color var(--dur-fast) var(--ease-circ),
              border-color var(--dur-fast) var(--ease-circ),
              background var(--dur-fast) var(--ease-circ);
}
.rail__n {
  color: var(--ivory-400);
  font-variant-numeric: tabular-nums;
  transition: color var(--dur-fast) var(--ease-circ);
}
/* Collapsed by default; the rail expands as a whole, not per row, so the
   list reads as one object rather than a row twitching under the cursor.

   THE NAME IS TAKEN OUT OF FLOW ON PURPOSE. The first version animated
   `max-width` from 0, which is a LAYOUT property — every frame of the
   hover would re-lay-out the rail's subtree, and on this page that lands
   on top of a scroll render loop that is already running every frame.
   Absolutely positioning the name past the button's right edge and
   animating only `opacity` and `transform` gives the identical result on
   the compositor alone, with no layout at any point. The two backdrops
   meet exactly at `left: 100%`, so they read as one bar.

   `pointer-events: none` keeps the wide expanded name from stealing
   clicks meant for the page behind it — only the narrow number strip is
   ever a hit target, which is the whole reason the rail sits outside the
   grid margin in the first place. */
.rail__b { position: relative; }
.rail__t {
  position: absolute;
  left: 100%;
  top: 0;
  padding: 5px 12px 5px 0;
  /* --rail-name-w is the widest name, measured once per layout in
     main.js. Without it each row's backing chip ends at its own text
     width, so the expanded rail has a ragged right edge that lands
     mid-word on whatever is behind it — at 1440x900 it cut the "Every" off
     the opening sentence. One width for all rows makes it read as one
     deliberate panel instead of damage. */
  min-width: var(--rail-name-w, auto);
  white-space: nowrap;
  opacity: 0;
  transform: translateX(-6px);
  pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-circ),
              transform var(--dur-mid) var(--ease-circ);
}
.rail:hover .rail__t,
.rail:focus-within .rail__t { opacity: 1; transform: none; }

/* The backdrop only appears while expanded — the same translucent ivory
   the chapter mark and the scroll hint already use to stay legible over
   moving content, so this is the page's existing answer to that problem
   rather than a new one. */
.rail:hover .rail__b,
.rail:focus-within .rail__b,
.rail:hover .rail__t,
.rail:focus-within .rail__t {
  background: rgba(245, 241, 234, .97);
  backdrop-filter: blur(3px);
}

.rail__b:hover,
.rail__b:focus-visible { color: var(--ink-900); border-left-color: var(--ivory-400); }
.rail__b:hover .rail__n,
.rail__b:focus-visible .rail__n { color: var(--stone-500); }

/* The chapter you are actually in. The teal rule is the only saturated
   mark on the rail, so position reads at a glance even collapsed. */
.rail__b.is-here {
  color: var(--ink-900);
  border-left-color: var(--teal-500);
}
.rail__b.is-here .rail__n { color: var(--teal-500); }

/* Static mode (reduced motion, or under 900px — every phone). The journey
   is ordinary stacked content there, so the rail becomes a horizontal
   contents row at the top of the section instead of a fixed edge column.
   This matters more on mobile than on desktop: .nav__links is display:none
   below 760px, so this is the only navigation a phone gets. */
[data-mode="static"] .rail {
  position: static;
  transform: none;
  opacity: 1;
  margin: 0 0 var(--section-y);
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
[data-mode="static"] .rail::-webkit-scrollbar { display: none; }
[data-mode="static"] .rail__list {
  grid-auto-flow: column;
  grid-auto-columns: max-content;
  gap: 0;
}
[data-mode="static"] .rail__b {
  border-left: 0;
  border-bottom: 1px solid transparent;
  padding: 8px 16px 8px 0;
  gap: 6px;
}
/* Static mode shows the names permanently and inline — there is no hover
   on a phone and nothing to collide with, since the journey is ordinary
   stacked content here. So the name comes back INTO flow, undoing the
   absolute positioning the live rail needs. */
[data-mode="static"] .rail__t {
  position: static;
  opacity: 1;
  transform: none;
  padding: 0;
  background: none;
  backdrop-filter: none;
  pointer-events: auto;
}
[data-mode="static"] .rail__b.is-here { border-bottom-color: var(--teal-500); }
/* The chapter marker is hidden here because the rail replaces it. In static
   mode render() never runs, so .jy__mark is frozen on the FIRST chapter's
   name for the whole stacked section — it says "01-02 Approach & Expertise"
   above the clinic and the reviews too. Harmless while it was the only
   label; actively misleading once the rail sits underneath it repeating the
   same words. The rail carries the real chapter names on this breakpoint. */
[data-mode="static"] .jy__mark { display: none; }

/* The badge on a card face. Always drawn — that is the whole point; a
   hover-only affordance tells you a card is clickable only once you have
   already found out. It fills and grows on hover and on keyboard focus,
   so the pointer and the tab key get the same feedback. */
.cardface__more {
  position: absolute; right: 8px; top: 8px; z-index: 4;
  display: grid; place-items: center;
  width: 22px; height: 22px; border-radius: 50%;
  background: rgba(245, 241, 234, .82);
  border: 1px solid rgba(34, 32, 30, .18);
  color: var(--ink-900);
  backdrop-filter: blur(3px);
  transition: transform var(--dur-fast) var(--ease-circ),
              background var(--dur-fast) var(--ease-circ),
              color var(--dur-fast) var(--ease-circ);
}
.cardface__more svg { width: 13px; height: 13px; }
.cardface:hover .cardface__more,
.cardface:focus-visible .cardface__more {
  transform: scale(1.18);
  background: var(--teal-600);
  border-color: var(--teal-600);
  color: var(--ivory-25);
}
/* The badge must stay upright while the card is splayed, exactly as the
   picture does — it lives inside .cardface__media, which carries the
   card's angle. */
.cardface__more { transform-origin: 50% 50%; }
/* MOVED DOWN 2026-09-09. At `bottom: clamp(56px, 9vh, 92px)` this rule sat
   at y=818 on a 900px window and drew straight through the takeover copy on
   ALL EIGHT cards, and through the bottom of the clinic panel. Exactly the
   bug fixed for `.fin__note` on 2026-09-06 — same rule, different victim —
   so it takes the same offset that fix settled on. fitCopy() reads this
   rule's position rather than restating it, so moving it again moves the
   copy's budget with it. */
.jy__meter {
  position: absolute; left: var(--grid-margin); right: var(--grid-margin);
  bottom: clamp(16px, 2.8vh, 32px);
  height: 1px; background: var(--ivory-300); z-index: 130;
}
.jy__meter span { display: block; height: 100%; width: 0%; background: var(--teal-500); }

/* The rule that draws down the middle at the end of the clinic stage and
   is handed to the tree, whose own spine sits at the same x and width.

   100% → 50% (2026-09-08) → BACK TO 100% (2026-09-09). Zax: "the line
   animation from the start of the work section still stops at the center.
   it should not do that. it should continue going to the bottom center of
   the screen."

   THE 50% WAS REASONED FROM A CONFUSION BETWEEN TWO DIFFERENT LINES. The
   note justifying it pointed at `.work__spine` drawing from a `top: 50%`
   origin — but that is the HORIZONTAL rule in the testimonials, three
   stages later, and where a horizontal rule sits vertically says nothing
   about how far a vertical rail should travel.

   It is also the wrong shape for its own handoff. This rail hands to
   `.tree__spine`, which is 80% of a 220vh track — 176vh, well past the
   bottom of the screen. So a 50% line was jumping to a full-height one at
   the moment of the swap. At 100% the two are the same length on screen
   and the handoff is invisible, which is what it was always meant to be. */
.jy__spine {
  position: absolute; left: 50%; top: 0;
  width: 3px; height: 100%; margin-left: -1.5px;
  background: var(--teal-600);
  transform-origin: 50% 0; transform: scaleY(0);
  z-index: 26;
}

/* The placeholder plate. Every asset the client still owes shows up as one
   of these — deliberately legible as a gap rather than dressed up as
   finished work. Swapping in a real photo is adding data-photo / an <img>;
   nothing else changes. */
.plate {
  position: absolute; inset: 0;
  display: grid; place-items: center; align-content: center; gap: 8px;
  background: var(--ivory-200); color: var(--ivory-400);
  text-align: center; padding: 10px;
}
.plate__mark {
  font-family: "Cormorant Garamond", Georgia, serif;
  font-size: 2.4em; line-height: 1;
}
/* The tag carries the only words on a plate, and it says which asset is
   missing — so it is the one part that has to be readable. --stone-500 on
   --ivory-200 measures 2.4:1, which fails AA at any size and failed it
   worst here, at 8px uppercase. --stone-700 on the same ground is 5.6:1.
   The MARK stays deliberately faint (1.4:1): it is a texture, not text,
   and it is aria-hidden. */
.plate__tag {
  font-size: 9px; letter-spacing: .18em; text-transform: uppercase;
  color: var(--stone-700); max-width: 90%;
}
.plate--fill { position: absolute; inset: 0; }
.plate--tiny { position: static; width: 100%; height: 100%; }
.plate--tiny .plate__mark { font-size: 1.4em; }

/* =====================================================================
   STAGE · THE RING — one circle of cards, seen half at a time.
   ===================================================================== */

/* Sentence sits LEFT of centre: the ring's first half occupies the right
   of the screen, so a centred sentence would collide with it. The box is
   inset from the right by 40% and the text centres inside what's left. */
/* The sentence is LAID OUT WHOLE and revealed word by word — it is not a
   one-word slideshow. Each word arrives on its own slice of the `words`
   beat and then STAYS, so by the end of the beat the reader is looking at
   the complete sentence rather than at its last word. That is why the
   spans are inline-block in normal flow: the line breaks are the browser's
   and the sentence wraps to two lines on a desktop viewport without any
   re-splitting on resize.

   Type size is therefore set by the whole sentence fitting in two lines
   inside a box that is 60% of the viewport, not by how a single word
   looks — a 96px word became eight lines the moment the sentence was
   laid out at once. */
.words {
  position: absolute; left: 0; right: 40%; top: 50%; margin: 0;
  transform: translateY(-50%);
  text-align: center;
  padding-inline: var(--grid-margin);
  font-family: "Cormorant Garamond", Cormorant, Garamond, Georgia, serif;
  font-weight: 300;
  font-size: clamp(26px, 6px + 100vw * .0265, 46px);
  line-height: 1.16; letter-spacing: var(--track-display);
  color: var(--ink-900);
  z-index: 4; pointer-events: none;
  text-wrap: balance;
}
/* NO TRANSITION HERE ON PURPOSE (2026-09-09). These three properties are
   written every frame by render(), straight from the scroll position — see
   the note beside WORD_SPAN in main.js. The values below are only the
   pre-JS resting state. Adding a transition back would reintroduce exactly
   the bug the .cardx block warns about a few hundred lines down: a clock
   and a wheel driving the same property, with the clock losing.

   The `is-on` / `is-out` classes went with it. They existed only to flip
   this rule and nothing else read them. */
.words span {
  display: inline-block;
  opacity: 0;
  transform: translateY(14px);
  filter: blur(6px);
}

.words__full {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  width: min(64ch, calc(100% - var(--grid-margin) * 2));
  text-align: center; margin: 0;
  font-family: "Cormorant Garamond", Georgia, serif;
  font-size: var(--fs-h2); line-height: var(--lh-h2); letter-spacing: var(--track-h2);
}
.js-ready .words__full { display: none; }

.ring__hand { position: absolute; inset: 0; pointer-events: none; }
/* Set while a card is out of the fan. Hover lifts a card by writing to
   the same transform the takeover is animating, so leaving hits live
   during a takeover means the deck can be pulled apart underneath it —
   and a card left hovered snaps when the deck comes back. Note the
   children re-enable hits explicitly, so this has to name them. */
.ring__hand.is-busy .ring__card { pointer-events: none; }

.ring__card {
  position: absolute; left: 0; top: 0;
  width: var(--cw); height: var(--ch);
  margin-left: calc(var(--cw) / -2);
  margin-top: calc(var(--ch) / -2);
  pointer-events: auto;
  will-change: transform, opacity;
}
/* WHY THE HOVERED CARD GIVES UP ITS LAYER HINT, 2026-09-08.
   `will-change: transform` is exactly a promise that the transform will
   change — so the compositor rasterises the card ONCE at its normal size
   and then stretches that one texture for every transform after it. That
   is free for the ring's constant drift (the reason the hint is there),
   but the hover scale is 2x, so it was stretching a 280x200 texture to
   560x400 and the picture went to mush. Zax spotted it immediately.

   Dropping the hint on just the hovered card makes Chrome re-rasterise at
   the size it is actually being drawn at. It costs one raster on hover-in,
   on one card, which is nothing next to a permanently blurry photograph.
   The other twelve keep the hint and keep drifting on the GPU.

   For the record the source images are NOT the main problem here: at 2x on
   a dpr-2 screen the card needs ~1120px of width and the photographs carry
   853-1000, a 1.12-1.31x upscale. That is a little soft; it is not what
   the screenshot showed. Replace them only if it still reads soft after
   this — measured, not assumed. */
.ring__card.is-hover { will-change: auto; }
/* Full-bleed picture, rounded corners — a real playing card is its face,
   not a photo with a label stuck under it. Radius is set as a fraction of
   the card's own width via --cw rather than a fixed px, so it scales with
   the card across breakpoints instead of looking chunkier on small ones. */
.cardface {
  position: relative;
  display: block;
  width: 100%; height: 100%;
  padding: 0; border: 1px solid var(--ivory-300);
  border-radius: calc(var(--cw) * 0.09);
  background: var(--ivory-25);
  cursor: pointer; text-align: left;
  font: inherit; color: inherit;
  box-shadow: 0 10px 30px -18px rgba(34, 32, 30, .5);
  overflow: hidden;
  transition: box-shadow var(--dur-fast) var(--ease-circ),
              border-color var(--dur-fast) var(--ease-circ);
}
/* The "flash" as the info cycle reaches a card. */
.ring__card.is-active .cardface {
  border-color: var(--teal-500);
  box-shadow: 0 22px 50px -22px rgba(28, 74, 77, .55);
}
.cardface__media {
  position: absolute; inset: 0;
  background: var(--ivory-100);
}
/* Turned back by exactly the card's own rotation, so the picture reads
   level however far the card has splayed. Scale is written per card from
   its actual angle rather than a fixed worst case, so the crop is only
   ever as tight as that angle needs — see coverScale() in main.js, which
   now accounts for the card's real (non-square) proportions. */
.cardface__level {
  position: absolute; inset: 0;
  transform: rotate(var(--level, 0deg)) scale(var(--level-s, 1));
  transform-origin: 50% 50%;
}
.cardface__level img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 22%; }
.cardface__level .plate { font-size: 1em; }

/* The tone is declared once, as a custom property on whichever element
   carries data-tone, and read by both the small card face and the
   full-stage takeover panel it grows into. Written as one map rather than
   two so a card and its own expanded state cannot end up different
   colours — that is the whole point of the growth reading as one object.
   Tones 8 and 11 are absent deliberately: those two cards carry
   photographs, so the ground behind them is never seen. */
[data-tone="1"]  { --tone-bg: var(--ivory-100); }
[data-tone="2"]  { --tone-bg: var(--ivory-200); }
[data-tone="3"]  { --tone-bg: var(--ivory-300); }
[data-tone="4"]  { --tone-bg: var(--ivory-200); }
[data-tone="5"]  { --tone-bg: var(--ivory-100); }
[data-tone="6"]  { --tone-bg: var(--ivory-300); }
[data-tone="7"]  { --tone-bg: var(--ivory-200); }
[data-tone="8"]  { --tone-bg: var(--ivory-100); }  /* was relying on the fallback */
[data-tone="9"]  { --tone-bg: var(--ivory-100); }
[data-tone="11"] { --tone-bg: var(--ivory-100); }  /* Women’s health, 16 Sep */
[data-tone="10"] { --tone-bg: var(--ivory-300); }
[data-tone="12"] { --tone-bg: var(--ivory-200); }
[data-tone="13"] { --tone-bg: var(--ivory-300); }
.ring__card .cardface__media { background: var(--tone-bg, var(--ivory-100)); }

/* Caption is off by default — the steady-state card is just its face, per
   the client. It surfaces as a scrim over the bottom of the photo on
   hover/keyboard-focus, and while the info-cycle has this card active, so
   there's still a way to identify a card without a pointer (touch opens
   the detail directly; the always-on label in [data-mode="static"] below
   covers the no-hover case there). */
.cardface__foot {
  position: absolute; left: 0; right: 0; bottom: 0;
  display: block; padding: calc(var(--cw) * 0.05) calc(var(--cw) * 0.065) calc(var(--cw) * 0.06);
  background: linear-gradient(to top,
    rgba(250, 248, 244, .96) 0%, rgba(250, 248, 244, .8) 60%, rgba(250, 248, 244, 0) 100%);
  overflow: hidden;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity var(--dur-fast) var(--ease-circ),
              transform var(--dur-fast) var(--ease-circ);
}
.cardface:hover .cardface__foot,
.cardface:focus-visible .cardface__foot,
.ring__card.is-active .cardface__foot {
  opacity: 1;
  transform: translateY(0);
}
.cardface__chapter {
  /* Was 7px in --stone-500: too small to read and 3.5:1 against a 4.5:1
     requirement. 9px in --bronze-txt is 4.90:1 and still clearly a caption
     rather than a heading. */
  display: block; font-size: 9px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--bronze-txt); margin-bottom: 3px; line-height: 1.2;
}
.cardface__num {
  display: block; font-size: 8px; letter-spacing: .2em;
  color: var(--teal-500); margin-bottom: 2px; line-height: 1.2;
}
.cardface__name {
  display: block;
  font-family: "Cormorant Garamond", Georgia, serif;
  font-size: 13px; line-height: 1.1; letter-spacing: -.01em;
}

/* =====================================================================
   STAGE · THE TAKEOVER (03 · Expertise)

   The picked card grows into this. z-index 15 puts it over the ring
   (layer 10, and over a lifted card's inline z-index 100, which is boxed
   inside that layer's stacking context) and under the clinic image
   (layer 20) that opens after the cycle finishes. The chapter label,
   hint and meter sit at 130 and stay legible on top of it — which is why
   the scrim is weighted to the left, where all three of them live.

   Every value below is written per frame from main.js. Nothing here
   transitions on its own: a CSS transition fighting a scroll position is
   how a scrubbed animation ends up lagging behind the wheel.
   ===================================================================== */
.cardx {
  position: absolute; inset: 0; z-index: 15;
  pointer-events: none;
  opacity: 0;
  /* Grows by uncovering, not by scaling a bitmap — same reason as the
     clinic stage. `round` carries the card's own corner radius up with
     it, so the small rounded rectangle becomes a square-cornered full
     stage rather than snapping flat on the first frame. */
  clip-path: inset(var(--ct, 50%) var(--cr, 50%) var(--cb, 50%) var(--cl, 50%)
                   round var(--cxr, 0px));
  will-change: clip-path, opacity;
}
/* THE MEDIA BOX IS THE OPENED CARD, NOT THE STAGE — and it does not
   animate. It used to be `inset: 0`, i.e. the full 1440x900 stage, with the
   clip-path alone deciding what you saw. That is only correct while the
   thing opens to the whole stage. Once it opens to a smaller rectangle, an
   `inset: 0` picture is cover-fitted to the whole viewport and then looked
   at through a slot: an 853x1280 headshot arrived as a blurred half-face
   scaled to 1440 wide.

   So the box is the final card rectangle, fixed, and the clip-path
   uncovers it. That also preserves the rule this whole build rests on: the
   photograph is never resampled while it grows — the frame opens over a
   picture that is already at its final size and position.

   The four --big-* values are written by layout() in js/main.js, which is
   also what the clip animates towards and what the copy column stops
   short of. One rectangle, three readers. */
.cardx__media {
  position: absolute;
  top: var(--big-t, 12%); right: var(--big-r, 17%);
  bottom: var(--big-b, 12%); left: var(--big-l, 43%);
}
.cardx__media {
  overflow: hidden;
  background: var(--tone-bg, var(--ivory-100));
}
/* max-width:none defeats the global img { max-width: 100% }, which would
   otherwise clamp the width back and leave a bare strip down one edge —
   the same trap already recorded against .clinic__media img. */
.cardx__media img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; max-width: none;
  /* The crop point has to travel, because the frame changes shape under
     it. The card is 5:7 portrait and 22% puts the face where a card wants
     it; the open stage is 16:10 landscape, and holding 22% there drops the
     eyeline into the middle of the frame — the crop reads as an accident
     rather than a portrait. Written per frame from main.js, easing to the
     value that puts the eyeline on the upper third at full width. */
  object-fit: cover; object-position: 50% var(--cxp, 22%);
  /* The panel is now a tall 42%-wide column rather than the whole 16:10
     stage, which is much closer to the card's own 5:7 — so the crop barely
     has to travel any more, and --cxp stays near the card's own 22%. */
  /* Eases from a slight over-scale to 1 as the frame opens, so the
     picture reads as growing with the card instead of being wiped. */
  transform: scale(var(--cxs, 1));
  transform-origin: 50% 50%;
}
/* Both of these need saying. The UA's [hidden] { display: none } is a
   0,1,0 selector, and this sheet's own img { display: block } and
   .plate { display: grid } are the same weight and come later — so the
   `hidden` attribute alone does nothing to either of them. */
.cardx__media img[hidden] { display: none; }
.cardx__media .plate[hidden] { display: none; }
.cardx__media .plate { background: transparent; }
.cardx__media .plate__mark { font-size: clamp(90px, 16vw, 220px); }
.cardx__media .plate__tag  { font-size: var(--fs-micro); }

/* Ivory laid over the left of the frame so the copy has something to sit
   on whatever the photograph turns out to be. It fades in with the copy,
   not with the growth — the card should be seen whole first. */
/* The scrim is gone. It was a full-stage ivory wash, there because the
   copy sat ON the photograph and soft-black type does not survive a
   mid-luminance picture. The copy has its own column beside the card now
   and never crosses it, so there is nothing left to protect. Removed from
   the markup too — a scrim at opacity 0 is a thing the next person has to
   work out the purpose of. */
/* Info panel — the copy that rises in once the card owns the stage. */
/* The wash behind the copy. Wide and soft: solid enough over the text to
   hold soft-black type at AA, gone entirely by the time it reaches the
   card, so the deck reads through the rest of the stage. z-index 14 puts it
   above the ring layer and below the opened card.

   The 55% stop is what makes it read as light rather than as a panel — a
   two-stop gradient from opaque to nothing has a visible edge, and a
   visible edge is a panel. */
.jy__wash {
  position: absolute; top: 0; bottom: 0; left: 0;
  /* Runs all the way to the card's own left edge, not to where the copy
     stops — the extra 44px of gap is where the fade finishes, so the wash
     never meets the picture with a visible step. */
  right: calc(100% - var(--big-l, 57%));
  z-index: 14; opacity: var(--cxo, 0);
  pointer-events: none;
  /* Deliberately weak, and the strength is measured rather than judged.
     Sampled off the rendered frame where the deck is densest behind the
     copy, the background runs 213–242 (mean 237) — soft-black type on 213
     is 10.5:1, against 14.4:1 on bare ivory. Contrast was never what was at
     risk here; visual noise was. So the wash only has to settle the type,
     not hide what it is standing on. */
  background: linear-gradient(90deg,
    rgba(245, 241, 234, .52) 0%,
    rgba(245, 241, 234, .48) 58%,
    rgba(245, 241, 234, .26) 84%,
    rgba(245, 241, 234, 0)  100%);
}
[data-mode="static"] .jy__wash { display: none; }

/* UNDER THE CARD, NOT BESIDE IT (2026-09-09). It used to be a column down
   the left of the stage, which is what forced the card off-centre to make
   room. Zax asked for the card centred and chose "copy below it", so the
   two are now one centred block: `left`/`right` are the card's OWN edges,
   so the copy is exactly as wide as the photograph above it and aligns to
   it on both sides — an image with its caption, rather than two things
   sharing a screen. `--info-top` is written by layout() from the same
   rectangle, so the gap under the card cannot drift.

   Sizes are `min(Xvh, Yvw)` for the reason recorded on .clinic__info: the
   global --fs-* scale is width-only, and this block has a fixed height to
   live in. A wide-but-short window would otherwise get a headline with no
   room for it. */
.jy__stage > .rinfo {
  position: absolute;
  left: var(--big-l, 27%); right: var(--big-r, 27%);
  top: var(--info-top, 64%);
  width: auto; max-width: none;
  z-index: 16;
  opacity: var(--cxo, 0);
  transform: translateY(var(--cxy, 22px));
  pointer-events: none;
}
.rinfo__chapter {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--bronze-txt); margin: 0 0 6px;
}
/* --rinfo-scale and --rinfo-measure are written by fitCopy() in main.js,
   which measures the tallest card's copy and then widens the measure and
   scales the type only as far as it has to. Both default to the designed
   values, so this block still reads as the design when it fits unaided. */
.jy__stage > .rinfo .h2 {
  font-size: calc(clamp(20px, min(4.4vh, 3.4vw), 34px) * var(--rinfo-scale, 1));
  line-height: 1.06;
  margin: clamp(4px, 0.8vh, 10px) 0 clamp(6px, 1.2vh, 14px);
}
.jy__stage > .rinfo .rinfo__copy p {
  font-size: calc(clamp(13px, min(2vh, 1.4vw), 18px) * var(--rinfo-scale, 1));
  line-height: 1.5;
}
.rinfo__copy p { max-width: var(--rinfo-measure, 46ch); }
.rinfo__copy .ph {
  font-size: var(--fs-small); color: var(--stone-600);
  border-left: 1px solid var(--stone-500); padding-left: 14px; margin-top: 20px;
}

/* =====================================================================
   STAGE · THE CLINIC
   ===================================================================== */
.clinic__media {
  position: absolute; inset: 0; margin: 0;
  /* Grows by uncovering rather than scaling, so a real photograph will not
     be stretched on the way out of the card. `round` carries the card's
     own corner radius out with it — without it the photograph starts as a
     square-cornered rectangle inside a rounded card and the handoff shows. */
  clip-path: inset(var(--ct, 40%) var(--cr, 40%) var(--cb, 40%) var(--cl, 40%)
                   round var(--cclr, 0px));
  will-change: clip-path, filter;
}
/* Bleed the content past the clip on every side. `filter: blur()` samples
   beyond the element's own edges, so content flush to the boundary blurs
   into transparency and leaves a pale halo around the whole viewport once
   the blur ramps up. Oversizing the child pushes that halo outside the
   clip, where it is never seen. */
.clinic__media .plate--fill { inset: -70px; }
.clinic__media .plate { background: var(--ivory-300); }
/* The four views of the clinic, stacked in the order they are seen. Each
   one fades in ON TOP of the one below, which stays at full opacity —
   cross-fading a pair together would let the ivory ground show through the
   middle of every transition and read as a flicker. Opacity is written per
   frame from main.js; the count is whatever is in the markup. */
.clinic__view {
  position: absolute; inset: -70px;
  /* An img is a replaced element, so `width:auto` resolves to the file's own
     intrinsic width and the inset box is ignored — the picture sat letterboxed
     until this was made explicit. The +140px matches the -70px overscan the
     blur and the parallax both need. */
  width: calc(100% + 140px); height: calc(100% + 140px);
  /* and the global `img { max-width:100% }` would otherwise clamp that width
     straight back to 100%, leaving a 70px strip bare down the right edge. */
  max-width: none;
  object-fit: cover;
  opacity: 0;
}
/* The first view is the ground everything else fades over, so it is on
   before any progress is written — including in static mode, which never
   writes any. */
.clinic__view:first-child { opacity: 1; }
.clinic__view.plate { padding: clamp(20px, 4vw, 48px); }
.clinic__view.plate .plate__mark { font-size: clamp(90px, 16vw, 220px); }
.clinic__view.plate .plate__tag  { font-size: var(--fs-micro); }

/* THE PANEL IS SIZED BY THE SCREEN, NOT ONLY BY ITS CONTENTS (2026-09-09).
   It used to have no height constraint at all: `bottom` pinned it and its
   own content decided how tall it grew. A headline, a five-line paragraph
   and four fact rows come to roughly 700px, so on any window shorter than
   about 950px it simply grew off the top of the stage and the headline was
   clipped — which is what Zax screenshotted ("adjust the description field
   to match the size of the screens").

   Two halves to the fix, and it needs both:
   1. A hard ceiling, so it can never be taller than the stage minus the
      clearance the nav and the chapter label need.
   2. Type and row spacing that answer to viewport HEIGHT as well as width.
      Every size here was previously keyed to `vw` only (via the global
      --fs-* scale), which is why a wide-but-short window got a 64px
      headline it had no room for. `min(Xvh, Yvw)` takes whichever
      dimension is the binding constraint, so the panel shrinks to fit
      rather than overflowing — and the ceiling above is then never
      actually reached on any sane window. */
.clinic__info {
  position: absolute; left: var(--grid-margin); bottom: clamp(40px, 8vh, 120px);
  width: min(46ch, 44vw); z-index: 20;
  max-height: calc(100% - clamp(96px, 15vh, 160px) - clamp(40px, 8vh, 120px));
  background: var(--ivory-25);
  padding: clamp(16px, min(2.6vh, 3vw), 38px);
  opacity: 0; transform: translateY(18px);
  will-change: opacity, transform;
}
.clinic__info .h2 {
  font-size: clamp(22px, min(5.2vh, 4.4vw), 40px);
  line-height: 1.04;
  margin: clamp(6px, 1.2vh, 12px) 0 clamp(8px, 1.6vh, 18px);
}
.clinic__info .lead {
  font-size: clamp(13px, min(2.2vh, 1.55vw), 19px);
  line-height: 1.45;
}
.clinic__facts { margin: clamp(10px, 1.8vh, 18px) 0 0; border-top: 1px solid var(--ivory-300); }
.clinic__facts > div {
  display: grid; grid-template-columns: 11ch 1fr; gap: 14px;
  padding: clamp(5px, 1vh, 10px) 0; border-bottom: 1px solid var(--ivory-300);
}
.clinic__facts dt {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--stone-600);
}
.clinic__facts dd {
  margin: 0;
  font-size: clamp(12px, min(1.9vh, 1.35vw), 16px);
  line-height: 1.45;
}

/* =====================================================================
   STAGE · TESTIMONIALS — the spine continues, branches, then pulls back.
   ===================================================================== */
.tree__zoom {
  position: absolute; inset: 0;
  transform-origin: 50% 50%;
  will-change: transform;
}
/* TRACK LENGTH IS THE SPACING CONTROL. Every item's position is
   `data-at × 100%` of this height, and the reveal maths in main.js reads
   `trackH` at runtime, so shortening the track pulls the whole strip
   tighter without touching a single data-at or a single beat threshold.

   Sized from the cards, not by eye. At 1440px a testimonial card is 280px
   wide, so 210px of 4:3 plate plus ~40px of caption ≈ 250px tall. Items
   alternate sides, so the pair that can actually collide is every OTHER
   one: same-side pitch = 2 × 0.0785 × height.
     320vh → 502px pitch → 252px of dead space between same-side cards.
     220vh → 345px pitch →  95px. Close, clearly not touching.
   With two empty entries removed, 220vh × 7/9 rounds to 172vh. The
   `branches` beat drops in step so the real photographs retain their
   established speed without carrying two invisible slots. */
.tree__track {
  position: absolute; left: 0; top: 0; width: 100%; height: 172vh;
  will-change: transform;
}
/* ENDS ON THE LAST BRANCH, not at the bottom of the track — 2026-09-08.
   It was `bottom: 0`, i.e. the full 220vh track, so the rail carried on
   below the last photograph with nothing attached to it and was still
   heading downward at the moment the testimonials were supposed to take
   over. 80% is the last item's own `data-at` (the nine sit at .250 →
   .800), so the rail now stops exactly where the last branch meets it.
   While you are scrolling the photographs it still crosses the whole
   screen top to bottom, which is correct — the end only comes into view
   at the end, and drive() lands it on screen centre (see the tree block
   in main.js). Keep this in step with the last data-at if items change. */
.tree__spine {
  position: absolute; left: 50%; top: 0; height: 80%;
  width: 3px; margin-left: -1.5px; background: var(--teal-600);
}
.tree__list { list-style: none; margin: 0; padding: 0; }
.tree__item {
  position: absolute; left: 0; right: 0;
  top: calc(var(--at) * 100%);
  height: 0;
}
/* The vertical strip holds the WORK PHOTOGRAPHS again as of 2026-09-06 —
   4:3 landscape, which is the shape this stage was built around when it
   held testimonial screenshots. The .tree__card img rules below were never
   removed and are correct as they stand.

   ONE ARM LENGTH, TWO USERS. The branch's length and the card's distance
   from the spine were 16vw and 16vw written out twice; the review cards are
   wider than the screenshot plates they replaced (280 -> 320) and at 16vw
   the left-hand ones reached back under the "05 — In their words" label.
   Shortened to 11vw, and both read it from here so they cannot drift. */
/* DECLARED ON THE STAGE, not on the tree layer (moved 2026-09-09).
   `.jy__note` now sizes itself against this too, and it is a child of the
   stage rather than of the layer — scoped to the layer, the note silently
   fell back to a hard-coded copy of the value and would not have followed
   a change made here. */
.tree__branch {
  position: absolute; top: 0; height: 2px; width: var(--tree-arm, 11vw);
  background: var(--teal-500);
  transform: scaleX(0); will-change: transform;
}
.tree__item[data-side="l"] .tree__branch { right: 50%; transform-origin: 100% 50%; }
.tree__item[data-side="r"] .tree__branch { left: 50%;  transform-origin: 0 50%; }
.tree__node {
  position: absolute; top: -4px; width: 9px; height: 9px; border-radius: 50%;
  background: var(--ivory-50); border: 2px solid var(--teal-600);
  opacity: 0; will-change: opacity;
}
/* THE TWO RULES THE ARM CHANGE MISSED (fixed 2026-09-09). The comment
   above says the branch and the card both read --tree-arm "so they cannot
   drift" — but these two kept the old literal 16vw. With the arm at 11vw
   that put every node 5vw (72px at 1440) past the end of its own branch,
   which lands it INSIDE the photograph: measured 218px inside on the
   left-hand cards, 54px on the right, on all nine items. */
.tree__item[data-side="l"] .tree__node { right: calc(50% + var(--tree-arm, 11vw) - 4px); }
.tree__item[data-side="r"] .tree__node { left:  calc(50% + var(--tree-arm, 11vw) - 4px); }
.tree__card {
  position: absolute; top: 0; margin: 0;
  width: clamp(180px, 20vw, 280px);
  opacity: 0; will-change: opacity, transform;
}
.tree__item[data-side="l"] .tree__card { right: calc(50% + var(--tree-arm, 11vw) + 14px); transform: translateY(-50%); }
.tree__item[data-side="r"] .tree__card { left:  calc(50% + var(--tree-arm, 11vw) + 14px); transform: translateY(-50%); }
.tree__card .plate {
  position: relative; inset: auto; width: 100%; aspect-ratio: 4 / 3;
  border: 1px solid var(--ivory-300);
}

/* A REVIEW CARD, not a screenshot of one.
   The eight slots hold real Google reviews now (see the markup for
   provenance). Set as type rather than pasted in as screenshots: Google's
   blue-and-white does not sit on an ivory page, a screenshot cannot be read
   by anything that is not looking at pixels, and type can be sized to the
   card instead of the card being sized to whatever pixels arrived.

   Wider than the old image card (280 -> 320 max) because a paragraph needs
   a line length; `auto` height because a quote is as long as it is, and the
   reveal maths positions these from their centres, so an uneven set is
   fine. */
/* The review card. It sits on the HORIZONTAL strip now — the work and the
   reviews swapped content on 2026-09-06 — so it is styled by `.rv` alone
   rather than by the layout it happens to be in, and both selectors are
   listed so moving it again costs nothing. */
/* Height-aware sizing for this card lives in the [data-mode="live"] block
   further down — it must NOT apply to the static/mobile list. — 2026-09-16.
   An upper card's BOTTOM is pinned to the rule (see [data-side="t"] below),
   so it grows UPWARD and a tall one runs off the top of the screen. The
   stars are the first ~29px of it, so they are the first thing lost —
   which is exactly what Zax saw: the whole quote present, no stars.
   Headroom above the rule is 0.5H - 12vh - 14px = 0.38H - 14. At 1440 wide
   the tallest of the eight real quotes measures 282px, so it clipped on any
   viewport shorter than ~779px — an ordinary laptop window.
   Capping the height would clip a quote and hiding the overflow would lie,
   so the type shrinks instead. min(vw, vh) means the vh term only ever wins
   on a SHORT viewport: at 1440x900 both values here are identical to what
   they were before this change, so nothing moves on a tall screen. */
.tree__card.rv,
.work__card.rv {
  width: clamp(200px, 21vw, 300px);
  background: var(--ivory-25);
  border: 1px solid var(--ivory-200);
  padding: clamp(15px, 1.6vw, 22px);
}
/* A quote is as tall as it is. The horizontal strip hangs its cards off a
   rule at a fixed offset, so an uneven set simply has uneven bottoms —
   which is right for quotes and would be wrong for photographs. */
.work__card.rv { height: auto; }
.rv__stars {
  display: block; color: var(--bronze-txt);
  font-size: 11px; letter-spacing: .22em; margin-bottom: 11px;
}
.rv__quote { margin: 0; }
.rv__quote p {
  margin: 0;
  font-family: "Cormorant Garamond", Cormorant, Garamond, Georgia, serif;
  font-size: clamp(15px, 1.15vw, 18px);
  line-height: 1.42; font-weight: 300;
  color: var(--ink-900);
}
.rv__foot {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px;
  margin-top: 13px; padding-top: 11px;
  border-top: 1px solid var(--ivory-200);
}
.rv__who {
  font-style: normal; font-size: var(--fs-small);
  color: var(--ink-900); letter-spacing: .01em;
}
/* Three of the eight are real but published without a name on the clinic's
   own material. "Verified patient" says exactly that — it does not invent
   one, and it does not pretend the review is anonymous by choice. */
.rv__who--anon { color: var(--stone-600); }
.rv__src {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--stone-600);
}

/* THE UPPER REVIEW CARDS WERE LOSING THEIR STARS — 2026-09-16.
   An upper card's BOTTOM is pinned to the rule (see [data-side="t"] below),
   so it grows UPWARD, and a tall one runs off the top of the screen. The
   stars are the first ~29px of it, so they are the first thing lost — which
   is exactly what was reported: the whole quote present, no stars above it.

   Headroom above the rule is 0.5H - 12vh - 14px = 0.38H - 14. At 1440 wide
   the tallest of the eight real quotes measured 282px, so it clipped on any
   viewport SHORTER THAN ~779px — an ordinary laptop window, which is why it
   was invisible in testing at 900px.

   Capping the height would clip a quote and hiding the overflow would lie,
   so the type shrinks instead. min(vw, vh) means the vh term only wins on a
   short viewport: measured at 1440x900 the values here are identical to the
   unscoped rules above, so nothing moves on a tall screen.

   SCOPED TO data-mode="live" ON PURPOSE. In static mode — reduced motion, or
   under 900px wide — the cards are an ordinary vertical list with nothing
   above them to clip, and applying this shrank the phone's quote from 15px
   to 13px for no reason. Measured 1440x900 282px tall vs 328 headroom,
   1440x760 262 vs 275, 1366x640 222 vs 229. */
[data-mode="live"] .tree__card.rv,
[data-mode="live"] .work__card.rv {
  padding: clamp(12px, min(1.6vw, 2.5vh), 22px);
}
[data-mode="live"] .rv__quote p {
  /* 13px floor, not 15px: below ~700px of viewport the quote has to give
     something up, and a smaller quote is better than a beheaded card. */
  font-size: clamp(13px, min(1.15vw, 1.95vh), 18px);
}
/* height:auto is load-bearing, exactly as it is on .work__card img: the
   width/height attributes on the tag land as presentational hints, and
   aspect-ratio is silently ignored unless height is auto. Without it each
   testimonial rendered at the file's intrinsic 1080px height inside a
   280px-wide card — 1107px tall against a 226px gap between items, so the
   whole strip overlapped itself. */
.tree__card img {
  width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover;
  border: 1px solid var(--ivory-300);
  background: var(--ivory-200);
}
.tree__card figcaption { margin-top: 8px; }

/* The work photographs open too, so they carry the same affordance the ring
   cards do — a persistent badge and a full-card hit area. Same reasoning as
   .cardface__more: a hover-only cue tells you a thing is clickable only
   once you have already found out. (.tree__card is already positioned, a
   few rules up, so the badge and the hit area have something to anchor to.) */
.tree__open {
  position: absolute; inset: 0;
  border: 0; padding: 0; background: transparent;
  cursor: pointer; z-index: 3;
}
.tree__more {
  position: absolute; right: 8px; top: 8px; z-index: 4;
  display: grid; place-items: center;
  width: 22px; height: 22px; border-radius: 50%;
  background: rgba(245, 241, 234, .82);
  border: 1px solid rgba(34, 32, 30, .18);
  color: var(--ink-900);
  backdrop-filter: blur(3px);
  pointer-events: none;
  transition: transform var(--dur-fast) var(--ease-circ),
              background var(--dur-fast) var(--ease-circ),
              color var(--dur-fast) var(--ease-circ);
}
.tree__more svg { width: 13px; height: 13px; }
.tree__more path {
  fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;
}
.tree__open:hover ~ .tree__more,
.tree__open:focus-visible ~ .tree__more,
.tree__card:hover .tree__more {
  transform: scale(1.18);
  background: var(--teal-600);
  border-color: var(--teal-600);
  color: var(--ivory-25);
}
/* The review cards on the other strip are not buttons — no badge there. */
.tree__card.rv .tree__more, .work__card.rv .tree__more { display: none; }

/* =====================================================================
   STAGE · THE WORK — same vocabulary, turned ninety degrees.
   ===================================================================== */
/* Same control, on the other axis — see .tree__track above.
   Tightened twice. The first pass (420vw → 300vw) was sized for nine work
   photographs 274px wide. This strip holds eight REVIEW cards now, which
   are wider, and eight items over the same 0.220–0.840 range means a wider
   step as well — so it opened back up. Step = 0.0886 × width:
     300vw → 383px step →  63px of empty rule, and a lot of it, because a
             card and its neighbour also sit on opposite sides of the rule.
     245vw → 313px step →  13px, with the card capped at 300px instead of
             320. Reads as one continuous strip.
   `travel` in BEATS drops by the same 18% (670 → 547), so the rule still
   passes under the viewport at exactly the rate it did. */
.work__track {
  position: absolute; left: 0; top: 0; height: 100%; width: 245vw;
  will-change: transform;
}
/* Stops at 96%, which is exactly where the end node sits — the rule ends
   ON the node rather than running past it to the edge of the track. */
/* STARTS AT SCREEN CENTRE, 2026-09-08. It used to start at the track's
   left edge (`left: 0`), which during `lineRight` — when the track has not
   travelled yet — is the far left of the screen, so the rule appeared to
   draw from the left margin rather than continuing out of the vertical
   rail's endpoint. `left: 50vw` puts its origin exactly where .tree__spine
   now ends (screen centre), so the line reads as one continuous stroke
   that goes down, stops on the last branch, then turns and runs right.
   Width still ends at 96% of the track, which is where .work__end sits, so
   the end node is unmoved. Every testimonial (data-at .220 → .840 of a
   245vw track = 54vw → 206vw) is right of 50vw, so none is orphaned. */
.work__spine {
  position: absolute; left: 50vw; top: 50%; width: calc(96% - 50vw);
  height: 3px; margin-top: -1.5px; background: var(--teal-600);
  transform-origin: 0 50%; transform: scaleX(0);
}
.work__list { list-style: none; margin: 0; padding: 0; }
.work__item {
  position: absolute; top: 0; bottom: 0;
  left: calc(var(--at) * 100%);
  width: 0;
}
.work__branch {
  position: absolute; left: 0; width: 2px; height: 12vh;
  background: var(--teal-500);
  transform: scaleY(0); will-change: transform;
}
.work__item[data-side="t"] .work__branch { bottom: 50%; transform-origin: 50% 100%; }
.work__item[data-side="b"] .work__branch { top: 50%;    transform-origin: 50% 0; }
.work__node {
  position: absolute; left: -4px; width: 9px; height: 9px; border-radius: 50%;
  background: var(--ivory-50); border: 2px solid var(--teal-600);
  opacity: 0; will-change: opacity;
}
.work__item[data-side="t"] .work__node { bottom: calc(50% + 12vh - 4px); }
.work__item[data-side="b"] .work__node { top:    calc(50% + 12vh - 4px); }
.work__card {
  position: absolute; left: 0; margin: 0;
  width: clamp(190px, 19vw, 290px);
  transform: translateX(-50%);
  opacity: 0; will-change: opacity;
}
.work__item[data-side="t"] .work__card { bottom: calc(50% + 12vh + 14px); }
.work__item[data-side="b"] .work__card { top:    calc(50% + 12vh + 14px); }
.work__card .plate {
  position: relative; inset: auto; width: 100%; aspect-ratio: 4 / 3;
  border: 1px solid var(--ivory-300);
}
/* A real photograph occupies exactly the box its pending plate held, so a
   card that has been filled sits on the rule at the same height as one
   still waiting. */
.work__card img {
  /* height:auto is load-bearing — the width/height attributes on the tag land
     as presentational hints, and aspect-ratio is ignored unless height is auto. */
  width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover;
  border: 1px solid var(--ivory-300);
  background: var(--ivory-200);
}
.work__card figcaption { margin-top: 8px; }
/* Hollow, and the same element that later carries to the centre and
   writes the closing line — so it never detaches from the rule. */
.work__end {
  position: absolute; left: 96%; top: 50%;
  width: 15px; height: 15px; margin: -7.5px 0 0 -7.5px; border-radius: 50%;
  background: var(--ivory-50); border: 3px solid var(--teal-600);
  opacity: 0; z-index: 40;
  will-change: transform, opacity;
}

/* =====================================================================
   STAGE · THE CLOSE — the line writes itself, then the social row.
   ===================================================================== */
.jy__layer[data-layer="fin"] { display: grid; place-items: center; }
.fin__cta {
  width: min(1000px, calc(100% - var(--grid-margin) * 2));
  height: auto; overflow: visible;
}
/* Stroke first, fill after — the letterforms are drawn rather than faded in.
   dasharray is set generously long in JS; text outlines have no single
   measurable path length, so this is tuned to look right rather than
   computed exactly. */
.fin__trace {
  font-family: "Cormorant Garamond", Georgia, serif;
  font-style: italic; font-weight: 300;
  font-size: 108px;
  fill: var(--ink-900); fill-opacity: 0;
  stroke: var(--ink-900); stroke-width: 1;
  paint-order: stroke fill;
}
/* BELOW THE PROGRESS RULE, NOT THROUGH IT. At 46ch this wrapped to three
   lines, and .jy__meter — a full-width 1px rule at bottom: clamp(56px, 9vh,
   92px) — ran straight through the second of them. Full width puts the
   whole sentence on one line at any desktop size, and dropping it under the
   rule means the two can never meet again however it wraps. */
/* NOT UPPERCASE. Inheriting .label put 92 characters of letter-spaced
   caps across the bottom of the page. We read words by their shape, and
   uppercase removes the ascenders and descenders that give them one — fine
   for "Enquiries", not for a sentence. Same size and spacing, so it still
   reads as fine print. */
.fin__note {
  position: absolute; bottom: clamp(16px, 2.8vh, 32px);
  left: var(--grid-margin); right: var(--grid-margin);
  margin: 0; max-width: none; text-align: center;
  text-transform: none; letter-spacing: .04em;
  font-size: var(--fs-small);
}
.fin__actions {
  position: absolute; left: 50%; top: calc(50% + clamp(90px, 14vh, 150px));
  transform: translateX(-50%);
  opacity: 0; will-change: opacity;
}
.fin__social {
  position: absolute; left: 50%; top: calc(50% + clamp(160px, 23vh, 250px));
  transform: translateX(-50%);
  display: flex; gap: 18px; list-style: none; margin: 0; padding: 0;
  opacity: 0; will-change: opacity;
}
.fin__social a {
  display: grid; place-items: center; width: 40px; height: 40px;
  color: var(--stone-600);
  transition: color var(--dur-fast) var(--ease-circ);
}
.fin__social a:hover { color: var(--teal-600); }
.fin__social svg {
  width: 22px; height: 22px;
  fill: none; stroke: currentColor; stroke-width: 1.1;
  stroke-linecap: round; stroke-linejoin: round;
}
.fin__social svg .dot { fill: currentColor; stroke: none; }

/* =====================================================================
   DETAIL — opens by uncovering from the card's own rectangle
   ===================================================================== */
.detail {
  position: fixed; inset: 0; z-index: 8000;
  clip-path: inset(var(--ct, 40%) var(--cr, 40%) var(--cb, 40%) var(--cl, 40%));
  transition: clip-path .74s var(--ease-expo);
}
.detail__ground { position: absolute; inset: 0; background: var(--ivory-25); }
.detail__back {
  position: absolute; left: var(--grid-margin); top: clamp(22px, 4vh, 40px);
  z-index: 3;
  display: inline-flex; align-items: center; gap: 9px;
  background: none; border: 0; padding: 8px 0; cursor: pointer;
  font: inherit; color: var(--ink-900);
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase;
  opacity: 0; transition: opacity .4s var(--ease-circ) .28s;
}
.detail__back span { font-size: 15px; }
.detail__inner {
  position: absolute; inset: 0;
  width: var(--grid-width); margin-inline: auto;
  display: grid; grid-template-columns: repeat(24, 1fr); gap: var(--grid-gap);
  align-content: center;
  padding: clamp(80px, 13vh, 130px) 0 clamp(40px, 7vh, 70px);
  overflow-y: auto;
}
.detail__media { grid-column: 2 / span 8; margin: 0; align-self: center; }
.detail__media img {
  width: 100%; height: clamp(300px, 58vh, 560px);
  object-fit: cover; object-position: 50% 18%;
  background: var(--ivory-100);
}
.detail__plate { display: none; }
.detail__media.is-plate img { display: none; }
.detail__media.is-plate .detail__plate {
  display: grid; place-items: center;
  height: clamp(300px, 58vh, 560px);
  background: var(--ivory-200);
  font-family: "Cormorant Garamond", Georgia, serif;
  font-size: clamp(58px, 8vw, 130px); color: var(--ivory-400);
}
.detail__body { grid-column: 12 / span 11; align-self: center; }
.detail__body .h2 { margin: 12px 0 24px; }
.detail__chapter {
  display: block; font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--bronze-txt); margin-bottom: 6px;
}
.detail__body p { max-width: 52ch; }
.detail__body .ph {
  font-size: var(--fs-small); color: var(--stone-600);
  border-left: 1px solid var(--stone-500); padding-left: 14px; margin-top: 26px;
}
.detail__inner > * {
  opacity: 0; transform: translateY(14px);
  transition: opacity .5s var(--ease-circ) .22s, transform .6s var(--ease-expo) .22s;
}
.detail.is-open { clip-path: inset(0 0 0 0); }
.detail.is-open .detail__back { opacity: 1; }
.detail.is-open .detail__inner > * { opacity: 1; transform: none; }

/* Scroll lock for the detail panel. NOT `height: 100%` — on this page that
   collapses a ~46,700px document to a single viewport, which forces scrollY
   to 0 while the panel is open. The height comes back on close but the
   scroll position does not, so the reader is dumped at progress 0 and the
   whole pinned journey (01–02 and 03) reads as broken. Pinning the body at
   its current offset instead keeps the scroll position AND every
   getBoundingClientRect the journey's progress is derived from, so nothing
   moves behind the panel. `--lockY` is written by openDetail. */
html.detail-lock, html.detail-lock body { overflow: hidden; }
html.detail-lock body {
  position: fixed; top: var(--lockY, 0px); left: 0; right: 0; width: 100%;
}

/* =====================================================================
   04 · ABOUT — physician profile. Portrait right this time (hero's ran
   left) so the page doesn't repeat the same asymmetry twice in a row.
   ===================================================================== */
.about {
  width: var(--grid-width); margin-inline: auto;
  display: grid; grid-template-columns: repeat(24, 1fr); gap: var(--grid-gap);
  padding: var(--section-y) 0;
  border-top: 1px solid var(--ivory-300);
  align-items: center;
}
.about__body { grid-column: 1 / span 13; }
.about__body .h2 { margin: 14px 0 24px; max-width: 18ch; }
.about__body .lead { max-width: 46ch; margin-bottom: clamp(22px, 3vw, 34px); }
.about__facts { margin: 0; border-top: 1px solid var(--ivory-300); }
.about__facts > div {
  display: grid; grid-template-columns: 11ch 1fr; gap: 16px;
  padding: 11px 0; border-bottom: 1px solid var(--ivory-300);
}
.about__facts dt {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--stone-600); padding-top: .25em;
}
.about__facts dd { margin: 0; font-size: var(--fs-small); }
.about__portrait { grid-column: 16 / span 8; margin: 0; }
/* THE WHOLE PHOTOGRAPH, UNCROPPED — Dr McKelvie, 2026-09-09: "on the about
   section. please use the whole photo of the doctor, don't crop it."
   It was `height: clamp(320px, 52vh, 520px)` with `object-fit: cover` and
   `object-position: 50% 14%`, which at 1440x900 rendered a 900x1350 image
   into a 425x468 box — 169px, a quarter of the picture, cut off the bottom.
   `height: auto` lets it fall to its own 2:3 ratio. The width/height
   attributes on the tag supply that ratio, and `height: auto` is what makes
   the browser honour them — the same note is on `.work__card img`.
   The column is taller than this leaves it (the bio copy beside it sets the
   section's height), so nothing below moves. */
.about__portrait img {
  width: 100%; height: auto;
  background: var(--ivory-100);
}
.about__portrait figcaption { margin-top: 12px; }

/* 05 · JOURNAL — DELETED 2026-09-09. The section was removed from
   index.html on 2026-09-09 and ~50 lines of .journal / .jr* rules were
   left behind. Verified dead before deleting: zero matches for
   `id="journal"`, `class="journal`, or any `jr` class in the markup.
   Removed rather than left commented because the `.jr` prefix is a
   known trap in this sheet — naming the journey `.jr` once put
   `height: 3100vh` on all three journal links (HANDOFF.md). Leaving
   dead `.jr*` rules here keeps that trap armed. */

.consult {
  width: var(--grid-width); margin-inline: auto;
  display: grid; grid-template-columns: repeat(24, 1fr); gap: var(--grid-gap);
  padding: var(--section-y) 0;
  border-top: 1px solid var(--ivory-300);
}
.consult__l { grid-column: 1 / span 12; }
.consult__l .h2 { margin: 14px 0 20px; }
.consult__l p { max-width: 40ch; margin-bottom: 28px; }
.consult__r { grid-column: 15 / span 10; }
.consult__r dl { margin: 0; border-top: 1px solid var(--ivory-300); }
.consult__r dl > div {
  display: grid; grid-template-columns: 14ch 1fr; gap: 16px;
  padding: 15px 0; border-bottom: 1px solid var(--ivory-300);
}
.consult__r dt {
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--stone-600);
}
.consult__r dd { margin: 0; font-size: var(--fs-small); }

.foot {
  width: var(--grid-width); margin-inline: auto;
  border-top: 1px solid var(--ivory-300);
  padding: 30px 0 44px;
}
.foot__inner { display: flex; flex-wrap: wrap; gap: 8px 40px; }
/* Sentence case for the same reason as .fin__note: "Registered with the
   GMC (United Kingdom) and the PRC (Philippines)" is 66 characters, which
   is a sentence however small it is set. */
.foot__inner p {
  margin: 0; font-size: var(--fs-small);
  letter-spacing: .04em; text-transform: none;
  color: var(--stone-600);
}

/* =====================================================================
   NARROW — the arc does not survive a phone, so it becomes a grid
   ===================================================================== */
@media (max-width: 900px) {
  .vh__facts > div { grid-template-columns: 1fr; gap: 2px; }
  .about { grid-template-columns: repeat(12, 1fr); }
  .about__portrait { grid-column: 1 / span 12; order: -1; }
  .about__body { grid-column: 1 / span 12; margin-top: 30px; }
  .consult { grid-template-columns: repeat(12, 1fr); }
  .consult__l, .consult__r { grid-column: 1 / span 12; }
  .consult__r { margin-top: 34px; }
  .detail__inner { grid-template-columns: repeat(12, 1fr); align-content: start; }
  .detail__media { grid-column: 1 / span 12; }
  .detail__media img,
  .detail__media.is-plate .detail__plate { height: clamp(220px, 34vh, 320px); }
  .detail__body { grid-column: 1 / span 12; margin-top: 26px; }
}

/* =====================================================================
   STATIC FALLBACK — narrow viewports AND reduced-motion.
   None of the journey survives a phone: a ring seen half at a time, a
   horizontal travel section and a branching spine all assume a wide
   viewport and a mouse-driven scroll. Rather than shrink them into
   something unreadable, every pinned section unpins and becomes ordinary
   stacked content. main.js sets data-mode="static" for either condition
   and skips all the transform maths, so nothing here fights inline styles.
   ===================================================================== */
[data-mode="static"] { height: auto !important; }
[data-mode="static"] .jy__stage {
  position: static; height: auto; overflow: visible;
  padding: var(--section-y) 0;
  width: var(--grid-width); margin-inline: auto;
  display: block;
}
/* Every stage stops overlapping and simply stacks. */
[data-mode="static"] .jy__layer {
  position: static; inset: auto;
  opacity: 1 !important; visibility: visible !important;
  pointer-events: auto !important;
  display: block; margin-bottom: var(--section-y);
}
[data-mode="static"] .jy__mark { position: static; margin-bottom: 18px; }

/* Ring → sentence as one line, then a plain two-column card grid. */
[data-mode="static"] .words,
[data-mode="static"] .jy__meter,
[data-mode="static"] .jy__spine,
[data-mode="static"] .jy__hint { display: none; }
/* In static mode the deck is a plain two-column grid of cards that are
   obviously tappable, and there is no fan to instruct anyone about. */
[data-mode="static"] .jy__tip { display: none; }
[data-mode="static"] .words__full {
  display: block !important; position: static; transform: none;
  text-align: left; width: auto; margin: 0 0 34px;
}
/* Horizontal scroller, not a stacked grid — Zax, 2026-09-08: a 2-column
   grid of 9 cards (and the testimonial/work lists below, same request)
   made the mobile page enormously long just to show a deck that's a
   single scrollable row on desktop. Cards keep their own width and the
   row scrolls sideways instead of the page scrolling down through them;
   scroll-snap so a swipe lands on a card, not half of two. */
[data-mode="static"] .ring__hand {
  position: static; display: flex; gap: 12px; pointer-events: auto;
  overflow-x: auto; overflow-y: visible; padding: 4px 4px 10px;
  scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch;
}
[data-mode="static"] .ring__card {
  position: static; flex: 0 0 auto; width: 78vw; max-width: 320px; height: auto;
  margin: 0; scroll-snap-align: start;
  transform: none !important; opacity: 1 !important;
}
/* --cw/--ch are only ever written by layout() in the live (non-static)
   branch, so every calc(var(--cw) * …) below — border-radius, the
   caption's own padding — would otherwise resolve to an invalid value and
   fall back to 0. Static mode gets its own fixed numbers instead. */
[data-mode="static"] .cardface {
  height: 230px; box-shadow: none; border-radius: 12px;
}
/* Nothing is rotated here, so the picture must not be counter-rotated
   either — the custom properties survive a switch out of the live mode. */
[data-mode="static"] .cardface__level { transform: none !important; }
/* The caption's default state is hover-revealed, which doesn't exist on a
   touch grid — leaving it hidden here would mean the only way to identify
   a card is to open it. Pin it visible instead of relying on :hover. */
[data-mode="static"] .cardface__foot {
  padding: 10px 12px 12px;
  opacity: 1 !important; transform: none !important;
}
/* The whole takeover is a scroll-scrubbed effect; static mode has no
   progress value to scrub, so the panel and its copy go with it. The
   same copy is still reachable there by tapping a card (the detail
   overlay), which is the only way in on touch anyway. */
[data-mode="static"] .cardx { display: none; }

/* Clinic → image at natural size above its copy, no growth, no spine.
   The -70px blur bleed has to be undone here: with no clip-path to contain
   it, the oversized child hangs off both edges and pushes the whole
   document sideways. */
[data-mode="static"] .clinic__media {
  position: relative; inset: auto; clip-path: none !important;
  /* Stay close to the source's landscape ratio so the reception desk and
     seating survive the narrow crop instead of reducing to one detail. */
  filter: none !important; height: clamp(220px, 30vh, 300px);
  margin-bottom: 26px; overflow: hidden;
}
[data-mode="static"] .clinic__media .plate--fill { inset: 0; }
/* Static mode never writes a progress value, so the cross-fade never runs.
   Use the final reception view as the representative clinic image: it is
   the newly completed arrival photograph and the strongest small-screen
   summary of the space. */
[data-mode="static"] .clinic__view {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  opacity: 0;
}
[data-mode="static"] .clinic__view:not(:last-child) { display: none; }
[data-mode="static"] .clinic__view:last-child { opacity: 1; }
[data-mode="static"] .clinic__info {
  position: static; width: auto; padding: 0; background: none;
  opacity: 1 !important; transform: none !important;
}

/* Tree and work → plain lists; spines, branches and nodes all go. */
[data-mode="static"] .tree__zoom,
[data-mode="static"] .tree__track,
[data-mode="static"] .work__track {
  position: static; width: auto; height: auto; transform: none !important;
}
[data-mode="static"] .tree__spine,
[data-mode="static"] .work__spine,
[data-mode="static"] .tree__branch,
[data-mode="static"] .work__branch,
[data-mode="static"] .tree__node,
[data-mode="static"] .work__node,
[data-mode="static"] .work__end { display: none; }
/* Horizontal scroller, same reasoning and same request as .ring__hand
   above — 8 testimonials and 9 work photos each used to be a tall stacked
   grid; both are now a single sideways-scrolling row. The work strip now
   contains seven real photographs. */
[data-mode="static"] .tree__list,
[data-mode="static"] .work__list {
  display: flex; gap: clamp(14px, 3vw, 22px);
  overflow-x: auto; overflow-y: visible; padding: 4px 4px 10px;
  scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch;
}
[data-mode="static"] .tree__item,
[data-mode="static"] .work__item {
  position: static; flex: 0 0 auto; width: min(72vw, 300px); height: auto; inset: auto;
  scroll-snap-align: start;
}
[data-mode="static"] .tree__card,
[data-mode="static"] .work__card {
  position: static; width: auto; transform: none !important; opacity: 1 !important;
}

/* Finale → the line is set as ordinary type; nothing is traced. */
[data-mode="static"] .fin__cta { width: 100%; clip-path: none !important; }
[data-mode="static"] .fin__trace {
  fill-opacity: 1 !important; stroke: none !important;
}
[data-mode="static"] .fin__note { position: static; margin-top: 20px; }
[data-mode="static"] .fin__actions,
[data-mode="static"] .fin__social {
  position: static; transform: none; opacity: 1 !important; margin-top: 24px;
}

/* The 900px block that used to sit here held one rule, `.cardx .rinfo`,
   and it never matched anything: `.rinfo` is a SIBLING of `.cardx`, not a
   descendant — it is styled everywhere else as `.jy__stage > .rinfo`. A
   leftover from the pre-2026-09-09 layout, when the copy was a column
   beside the card. Removed with the empty query around it. */

@media (max-width: 760px) {
  .nav__links { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .intro { display: none; }
  /* `.words span` dropped from this list 2026-09-09 — it no longer has a
     transition to switch off. It is scrubbed from the scroll position now,
     and reduced motion takes the static path anyway (isStatic() is
     `reduced || width < 900`), where `.words` is display:none. */
  .ring__card, .cardface, .detail, .detail__inner > *,
  .rinfo { transition: none !important; }
}

/* =====================================================================
   ENQUIRY — the modal every "Book" CTA opens.

   It sits at the end of the sheet on purpose: it is above everything,
   including the nav (700) and the detail overlay's ground, so nothing in
   the journey can paint over it.

   The scroll lock reuses the same mechanism the detail overlay uses —
   `enq-lock` pins the body at -lockY rather than collapsing the document,
   because the journey derives its progress from getBoundingClientRect and
   a collapsed document would rewind it.
   ===================================================================== */
.enq { position: fixed; inset: 0; z-index: 9500; }
.enq[hidden] { display: none; }
.enq__ground {
  position: absolute; inset: 0;
  background: rgba(34, 32, 30, .42);
  backdrop-filter: blur(3px);
  opacity: 0; transition: opacity .32s var(--ease-circ);
}
.enq.is-open .enq__ground { opacity: 1; }

.enq__panel {
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%) translateY(14px);
  width: min(560px, calc(100% - 32px));
  max-height: calc(100dvh - 40px); overflow-y: auto;
  /* SCROLL SHADOWS, and they earn their complexity. The panel fits without
     scrolling at 900px tall, but on a 720px laptop the last two contact
     routes fall below the fold with nothing to say they are there — a
     scrollable dialog that does not look scrollable is a dialog with
     content nobody finds.

     The `local` layers are ivory covers pinned to the content, so they
     scroll away as you move; the `scroll` layers are soft shadows pinned to
     the frame. Where the content is longer than the box the cover moves off
     and the shadow shows through, so the hint appears ONLY when there is
     something below — no JS, no measuring, and nothing to keep in sync. */
  background:
    linear-gradient(var(--ivory-50) 34%, rgba(245, 241, 234, 0)) top / 100% 28px no-repeat local,
    linear-gradient(rgba(245, 241, 234, 0), var(--ivory-50) 66%) bottom / 100% 28px no-repeat local,
    radial-gradient(farthest-side at 50% 0,   rgba(34, 32, 30, .16), rgba(34, 32, 30, 0)) top / 100% 11px no-repeat,
    radial-gradient(farthest-side at 50% 100%, rgba(34, 32, 30, .16), rgba(34, 32, 30, 0)) bottom / 100% 11px no-repeat,
    var(--ivory-50);
  border: 1px solid var(--ivory-200);
  /* Measured, not guessed: at the roomier clamp(24px, 4vw, 44px) the panel
     came to 962px of content in an 860px box on a 900px-tall screen, so the
     clinic's address sat below a fold. Tightened until the whole thing fits
     without scrolling at 900px; below that it scrolls, and the shadows
     above are what say so. */
  padding: clamp(20px, 2.6vw, 30px);
  opacity: 0;
  transition: opacity .32s var(--ease-circ), transform .42s var(--ease-expo);
}
.enq.is-open .enq__panel { opacity: 1; transform: translate(-50%, -50%); }

.enq__x {
  position: absolute; top: 12px; right: 12px;
  width: 36px; height: 36px; display: grid; place-items: center;
  background: transparent; border: 1px solid var(--ivory-300);
  border-radius: 50%; color: var(--stone-700); cursor: pointer;
  transition: background var(--dur-fast) var(--ease-circ),
              color var(--dur-fast) var(--ease-circ);
}
.enq__x:hover { background: var(--teal-600); border-color: var(--teal-600); color: var(--ivory-25); }
.enq__x svg { width: 15px; height: 15px; }
.enq__x path { fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; }

.enq__panel .h2 { margin: 6px 0 10px; font-size: clamp(26px, 3.4vw, 34px); line-height: 1.05; }
.enq__lead { font-size: var(--fs-small); color: var(--stone-600); margin: 0 0 16px; max-width: 46ch; }

/* Off-screen rather than display:none — a hidden input is not submitted by
   some bots, and the point is to give them something to fill in. */
.enq__hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

.enq__field { display: block; margin: 0 0 12px; }
.enq__field span {
  display: block; margin-bottom: 5px;
  font-size: var(--fs-micro); letter-spacing: var(--track-label);
  text-transform: uppercase; color: var(--stone-600);
}
.enq__field input,
.enq__field textarea {
  width: 100%; font: inherit; font-size: 15.5px; color: var(--ink-900);
  background: var(--ivory-25);
  border: 1px solid var(--ivory-300); border-radius: 0;
  padding: 10px 13px; resize: vertical;
  transition: border-color var(--dur-fast) var(--ease-circ);
}
.enq__field input:focus, .enq__field textarea:focus {
  outline: none; border-color: var(--teal-500);
}
.enq__send { width: 100%; justify-content: center; margin-top: 4px; cursor: pointer; }
.enq__send[disabled] { opacity: .55; cursor: default; }

.enq__status {
  margin: 10px 0 0; font-size: var(--fs-small); color: var(--stone-600);
  min-height: 1.2em;
}
.enq__status[data-tone="ok"]  { color: var(--teal-600); }
.enq__status[data-tone="bad"] { color: #8C3A2B; }

.enq__direct { margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--ivory-300); }
.enq__direct ul { list-style: none; margin: 8px 0 0; padding: 0; }
.enq__direct li { margin-bottom: 5px; font-size: 14.5px; }
.enq__direct a { color: var(--ink-900); text-decoration-color: var(--ivory-400); }
.enq__direct a:hover { color: var(--teal-600); }
.enq__where { margin: 10px 0 0; font-size: var(--fs-small); color: var(--stone-600); }

html.enq-lock, html.enq-lock body { overflow: hidden; }
html.enq-lock body {
  position: fixed; top: var(--lockY, 0px); left: 0; right: 0; width: 100%;
}

@media (prefers-reduced-motion: reduce) {
  .enq__ground, .enq__panel { transition: none; }
}

/* =========================================================================
   SECTION REVEAL — About and Consult sit in ordinary document flow, after
   the pinned journey ends, and were the only two sections on the page with
   no arrival of their own: everything inside `.jy` is choreographed to a
   scroll position; these two just appeared. One fade-and-rise the first
   time each enters view (see main.js, IntersectionObserver, one-shot).

   Gated behind `html.js-ready` (set at the very top of main.js) so a
   visitor with JavaScript off — or a crawler — never has content sitting
   at opacity:0 waiting for a script that didn't run. No JS, no hiding.
   ========================================================================= */
html.js-ready [data-reveal] {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity var(--dur-slow) var(--ease-circ),
              transform var(--dur-slow) var(--ease-circ);
}
html.js-ready [data-reveal].is-revealed { opacity: 1; transform: none; }
html.js-ready .consult__r[data-reveal] { transition-delay: .1s; }
html.js-ready .about__portrait[data-reveal] { transition-delay: .12s; }

