/* =============================================================================
   MASTER: clinical-ivyrehab-static - concept preview by PXLNorth
   Parameterized from the operator-approved northsimcoephysio.com recreation of
   the ivyrehab blueprint (templates/blueprints/clinical/physio-ivyrehab).
   Structure and density come from the blueprint; every color and font below is
   a slot the tailor pass fills per prospect.

   TYPE PAIRING
   ------------
   The font custom properties and the Google Fonts link are content-driven
   (content.json fonts object). The shipped default is the source build's
   pairing: Newsreader, a humanist serif with a genuinely drawn italic that the
   mission / friction / testimonial headlines lean on, plus Instrument Sans for
   the dense practical detail a patient actually reads. The tailor pass picks a
   pairing for each prospect's identity and documents the choice in the run.
   Never Fraunces (operator taste rule).

   COLOR SLOTS - every slot reads the fill-static palette (paletteMode pro);
   theme.json SHOULD pin the full scale via its pro object (see template.json).
   The prospect's real site scheme drives the slots, logo colors are accents:
     teal slot          : the prospect's PRIMARY brand color (nav links, pills,
                          footer, mission band, star rows)
     wine slot          : the prospect's secondary HOT color - the blueprint
                          spends it on exactly ONE band (friction) plus the
                          pull-quote text. Map it to a real sampled secondary,
                          never an imported hue they don't use.
     tint slots         : pale tints of the primary (testimonial field, form
                          card, tile placeholders)
     ink slots          : near-black text scale
   Master-specific slot names fall back to derived standard keys when unpinned
   so an unpinned theme still renders a coherent AA page, never an empty var.
   Brand-derived alpha tints use color-mix from the vars, so the whole page
   re-themes from the palette alone.
   ============================================================================= */

:root {
  --teal: #2a597b;
  --teal-deep: #244b68;
  --teal-darker: #19354a;
  --teal-light: #9fbcd0;
  --tint: #e2e9ee;
  --tint-soft: #f2f5f8;
  --wine: #1f6157;
  --wine-light: #226a5f;
  --wine-deep: #164740;
  --ink: #191f24;
  --ink-soft: #3a444b;
  --grey: #5f6870;
  --grey-line: #dfe2e5;
  /* The two neutral canvases. Their base values moved to --canvas-base / --paper-warm-base
     at the foot of this block so the presentation matrix's surface-temperature axis can mix
     them; custom properties resolve regardless of declaration order, and at the neutral
     default the mix is a colour with itself, so these two are byte-identical to what they
     were. See the SURFACE TEMPERATURE note below. */
  --paper: color-mix(in srgb, var(--temp-tint) var(--temp-amt), var(--canvas-base));
  --paper-warm: color-mix(in srgb, var(--temp-tint) var(--temp-amt), var(--paper-warm-base));
  /* Review gold. NOT a brand slot: a filled review star is a cross-site
     convention the reader decodes before they read the palette, the same way
     the Google mark beside it is Google's colours and not ours. The default is
     the deep gold measured on the approved medical demo (3.15:1 on its tint,
     4.06:1 on the white chip - both clear the 3:1 bar for a meaningful
     graphic). A prospect whose testimonial tint kills that contrast is fixed by
     PINNING palette.reviewGold in theme.json, never by hard-coding here. */
  --gold: #B0700A;

  --serif: "Newsreader", Georgia, "Times New Roman", serif;
  --sans: "Instrument Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

  --wrap: 1240px;
  --gutter: clamp(1.25rem, 4vw, 3.5rem);
  --radius: 14px;

  /* ---------- SUB-NICHE PRESENTATION MATRIX (TASTE_LEDGER #69) ----------------
     Seven axes resolved per sub-niche from templates/variants/<niche>.json by
     orchestrator/src/lib/presentation.ts; the contract is docs/PRESENTATION_MATRIX.md
     and this master's honest declaration is template.json `$presentation`. Every value
     arrives as a FINISHED LITERAL (the resolver does the arithmetic; a mustache template
     has none), and every one carries a fallback, so a fill from any older path - or a
     template.json with no $presentation block - renders exactly the page this master
     rendered before this layer existed.

     THE DELTA FORM, and why two of these are not bound directly. The resolver's neutral
     values are FLEET values, not this master's: tracking `normal` is -0.005em where the
     approved heading-ink block ships -0.02em, and eyebrow tracking `upper` is 0.2em where
     this master ships 0.18em. Binding those straight in would silently overwrite two
     operator-approved decisions on every build, including the ones with no matrix row at
     all. So the axis is applied as a DELTA from the fleet neutral: at `normal`/`upper` the
     delta is exactly 0 and the master's own values survive byte-for-byte; `tight`/`open`
     and `sentence` move from there. -------------------------------------------------- */
  --display-scale: 1.1;
  /* Per-band emphasis multiplier, set by .section[data-emph] / [data-compact] below.
     Declared here so every display heading can multiply by it unconditionally. */
  --emph-scale: 1;
  --display-weight: 600;
  --display-track: -0.02em;
  --display-track-delta: calc(var(--display-track) + 0.005em);
  --eyebrow-case: uppercase;
  --eyebrow-track: 0.2em;
  --eyebrow-track-delta: calc(var(--eyebrow-track) - 0.2em);
  /* Accent discipline: how far the ONE accent hue reaches. Never WHICH hue it is
     (ledger #67 - accents come from the business's real scheme). Reach 1 is the approved
     render; `reserved` (0.6) mutes the repeated accent element toward body grey, and
     `assertive` (1.4) is clamped at full strength because there is nothing above it. */
  --accent-reach: 1.4;
  --accent-pct: min(100%, calc(var(--accent-reach) * 100%));
  /* Rhythm 1 IS the TASTE_LEDGER #52 0.6x section rhythm this master already ships - the
     axis varies AROUND the approved rhythm, it does not reintroduce the pre-#52 gaps. All
     three clamp terms scale together, or the floor eats the change (#52's own fix note). */
  --rhythm-scale: 1.18;
  --rhythm: calc(clamp(2.1rem, 4.2vw, 3.9rem) * var(--rhythm-scale));
  /* Measure. Applied as a CEILING (`min()`) against each block's own approved measure, so
     `standard` (60ch) leaves every one of them untouched and `narrow` (52ch) genuinely
     narrows. `wide` is a DECLARED NO-OP on this master: its widest text block already sits
     at 60ch by the approved composition, and widening it would be a taste change nobody
     approved rather than a presentation axis. Reported, not hidden. */
  --measure: 60ch;

  /* SURFACE TEMPERATURE (axis 2, `$presentation.temperatureMode: "css"`). The two NEUTRAL
     canvases are mixed here; the brand tints (--tint / --tint-soft) are deliberately NOT,
     because they are derived from the prospect's real brand hue and a temperature shift
     has no business moving a brand colour. THE AMOUNT IS NOT A CSS DECISION: fillStatic
     walks the mix down from 5% until every ink this master paints on these grounds still
     clears 4.5:1, so `--temp-amt` is whatever survived that guard (0% is a legitimate
     answer and is recorded with the blocking pair in runs/<domain>/presentation.json).
     `--canvas-base` carries that name on purpose - it is the fallback tint the resolver
     emits for `neutral`, where the mix is a base with itself and therefore a no-op. */
  --canvas-base: #ffffff;
  --paper-warm-base: #fbfbfc;
  --temp-tint: #B08256;
  --temp-amt: 5%;
}

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }

/* ANCHOR JUMPS MUST NOT PARK THEIR TARGET UNDER THE STICKY NAV.
   Measured before this rule landed (1440, master-filled proof): every in-page
   jump — nav links, the nav CTA, the hero wizard's no-JS fallback, all four
   friction CTAs — put the target at sectionTop 0 beneath a sticky .nav whose
   bottom edge sits at 84px, so the first content line rendered 23-56px UNDER
   the nav. Every element that can be an anchor target now reserves the nav's
   height plus breathing room above itself.

   --nav-h is MEASURED at runtime by main.js, because the nav's real height is
   driven by the brand logo's aspect ratio (101px at 1440 on the approved demo,
   not the 78px min-height the .wrap declares) and so differs per prospect and
   per viewport. The values here are the fallback for a no-JS render. */
:root { --nav-h: 102px; }
@media (max-width: 960px) { :root { --nav-h: 86px; } }
[id] { scroll-margin-top: calc(var(--nav-h) + 1.25rem); }

body {
  margin: 0;
  font-family: var(--sans);
  font-size: clamp(1rem, 0.97rem + 0.16vw, 1.0625rem);
  line-height: 1.65;
  color: var(--ink-soft);
  background: var(--paper);
  overflow-x: hidden;
}

img { max-width: 100%; display: block; }

/* THE [hidden] ATTRIBUTE MUST BEAT ANY COMPONENT'S OWN display RULE.
   The UA sheet's `[hidden] { display: none }` is a plain type-less selector, so
   ANY class rule that sets `display` outranks it and the element stays on screen
   while still reporting itself as hidden to a reader of the markup. The approved
   medical demo carries this rule for exactly that reason (its own comment: the
   hero form's `display: flex` kept it on screen underneath its own confirmation
   panel), and THIS MASTER SHIPPED THE #66 WIZARD WITHOUT IT for one round.
   Found on the eyemaxx refill, 2026-08-29, by reading the built page rather than
   the template: three of this file's own rules were silently defeating `hidden`
   in the markup -
     .hero-step[data-step-panel="2"]  display: flex        (step 2)
     .hero-stepper                    display: flex        ("Step 1 of 2")
     .hero-back                       display: inline-flex (the Back button)
   so every build that authored hero.wizard.services rendered the wizard FULLY
   UNFOLDED: one six-field bar with a live step caption and a Back button beside
   it, breaking no-JS clauses (2) and (3) of $heroWizard as written, and at 390
   filling the first viewport with a stacked six-field form.
   It is `!important` on purpose: this must win against a component rule that has
   not been written yet, which is the whole point of a fleet template. Do not
   scope it and do not remove it. */
[hidden] { display: none !important; }

h1, h2, h3, h4 {
  font-family: var(--serif);
  color: var(--ink);
  font-weight: 400;
  line-height: 1.12;
  margin: 0;
  letter-spacing: calc(-0.01em + var(--display-track-delta, 0em));
}

p { margin: 0 0 1.05em; }
p:last-child { margin-bottom: 0; }

a { color: inherit; }

.wrap { max-width: var(--wrap); margin-inline: auto; padding-inline: var(--gutter); }

.skip-link {
  position: absolute; left: -9999px; top: 0; z-index: 200;
  background: var(--ink); color: #fff; padding: 0.7rem 1.1rem; border-radius: 0 0 8px 0;
  font-size: 0.9rem;
}
.skip-link:focus { left: 0; }

:where(a, button, input, select, textarea):focus-visible {
  outline: 3px solid var(--wine-light);
  outline-offset: 2px;
}

/* ---------- shared bits -------------------------------------------------- */

.eyebrow {
  font-family: var(--sans);
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: calc(0.18em + var(--eyebrow-track-delta, 0em));
  text-transform: var(--eyebrow-case, uppercase);
  margin: 0 0 1.1rem;
}

.serif-italic { font-style: italic; }

.pill {
  display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
  font-family: var(--sans);
  font-size: 0.9375rem;
  font-weight: 600;
  line-height: 1;
  padding: 0.95rem 1.6rem;
  border-radius: 999px;
  border: 1.5px solid transparent;
  text-decoration: none;
  cursor: pointer;
  transition: background-color .18s ease, color .18s ease, border-color .18s ease, transform .18s ease;
  white-space: nowrap;
}
.pill:hover { transform: translateY(-1px); }

.pill-teal { background: var(--teal); color: #fff; }
.pill-teal:hover { background: var(--teal-deep); }

.pill-wine { background: var(--wine); color: #fff; }
.pill-wine:hover { background: var(--wine-deep); }

.pill-white { background: #fff; color: var(--ink); }
.pill-white:hover { background: var(--tint); }

.pill-ghost-dark { background: transparent; color: var(--ink); border-color: color-mix(in srgb, var(--ink) 28%, transparent); }
.pill-ghost-dark:hover { background: var(--ink); color: #fff; border-color: var(--ink); }

.pill-ghost-light { background: transparent; color: #fff; border-color: rgba(255,255,255,.55); }
.pill-ghost-light:hover { background: #fff; color: var(--ink); border-color: #fff; }

/* Section rhythm. Operator directive TASTE_LEDGER #52, 2026-08-29: "reduce the gap
   between sections vectrically by 40% on all templates". EVERY term of the clamp is
   scaled x0.6, not just the preferred value - a clamp floor left at 3.5rem would eat
   the whole change on a phone, which is exactly the failure #52's fix note names.
   3.5/7/6.5 -> 2.1/4.2/3.9. MEASURED at 1440: 7vw = 100.8px resolves inside the old
   min/max, 4.2vw = 60.5px inside the new, so a section PAIR (one band's bottom plus
   the next band's top) goes 201.6px -> 121.0px. At 390: 27.3px -> 16.4px per side,
   i.e. 112px -> 67.2px floored by the min, 54.6 -> 32.8 by the vw term. Ported from
   the approved medical demo (runs/northsimcoephysio.com/redesign), which carries the
   identical values and the identical measurement.
   2026-08-30: the literal became `var(--rhythm)`, which is that exact clamp times the
   presentation matrix's DENSITY axis (rhythm-scale 1 = this line, unchanged). Before that
   it was a hardcoded literal in all four masters and 10 of 10 live clinical builds shipped
   it byte-identical - an emergency dental practice and a cosmetic studio breathed at the
   same rate because the axis had no range at all, not because 1.0x was right for both. */
.section { padding-block: var(--rhythm); }

/* ---------- SECTION EMPHASIS (presentation axis 4) ------------------------
   The renderer that `variantEmphasis` / `variantCompact` never had. Those two flags have
   been authored for 22 niches since the pack era and reached `data` on every master fill,
   and NO master's markup referenced either token (0 hits across all four, measured
   2026-08-30) - so the one structural lever the fleet already had was inert. `data-emph`
   is the section this sub-niche LEADS on: more air around it and a larger head. Both are
   RELATIVE to whatever `--rhythm` the density axis already set, so the two axes compose
   instead of fighting. Every band in this master is a `.section`, so unlike the law master
   there is no full-bleed exception to write a second pair of rules for. */
.section[data-emph] { padding-block: calc(var(--rhythm) * 1.22); }
.section[data-compact] { padding-block: calc(var(--rhythm) * 0.72); }
/* A compact band keeps its content and gives up its air and its display size. This is a
   SEPARATE multiplier from --display-scale rather than an override of it, so a sub-niche
   that asked for a grand display (cosmetic dentistry at 1.25x) still gets it in the bands
   it de-emphasises - the two compose, one does not silently cancel the other. */
.section[data-emph] { --emph-scale: 1.06; }
.section[data-compact] { --emph-scale: 0.88; }

/* ---------- TYPE REGISTER (presentation axis 1) --------------------------
   Weight, scale and tracking are custom properties above; these rules are the part a
   number cannot carry - the SHAPE of the setting. The master's own approved setting is the
   editorial serif, which is why `editorial` / `transitional-serif` is deliberately absent:
   a page that resolves it renders exactly as this master always has.

   WEIGHT CONTRACT (template.json `$fontWeights`). Nothing here invents a weight. Serif
   headings take --display-weight, which the resolver clamps to
   `$presentation.headingWeights` [500, 600] - the serif upright cuts every tailored
   `fonts.googleHref` is REQUIRED to request - so blueprint-gate's faux-bold check cannot
   regress through this layer. The sans-led h1 below is the one place a 700 appears, and it
   is safe for the same reason from the other side: `$fontWeights` already requires the
   SANS at 400/500/600/700 of every tailor, because .group-label and .footer h3 use 700
   today. It is set beside the family it belongs to so the two can never drift apart.

   `grotesque` is what makes an EMERGENCY page possible at all. Three matrix rows
   (dentist:emergency, veterinary:emergency-urgent, doctor:*) ask for the h1 in the text
   face at heavy weight, and the `h1-h4 { font-family: var(--serif) }` base rule above
   forbade it outright: an editorial serif reads as decoration to somebody in acute pain at
   9pm on a phone.

   TWO VOCABULARIES, ONE SET OF RULES. `displayRegister.class` has a canonical seven-value
   vocabulary (editorial-serif / transitional-serif / humanist-serif / modern-serif /
   neo-grotesque / geometric-sans / humanist-sans) and a same-day three-value shorthand
   (editorial / humanist / grotesque) that the resolver accepts rather than fights. Both
   reach `body[data-register]`, so BOTH are matched here: a niche row authored against
   either spelling paints, instead of silently doing nothing because it picked the other
   name. The serif ids that are not humanist all mean "the master's own editorial setting"
   and are deliberately unmatched - that IS the approved render. */
body[data-register="humanist"] h1,
body[data-register="humanist"] h2,
body[data-register="humanist"] h3,
body[data-register="humanist"] h4,
body[data-register="humanist-serif"] h1,
body[data-register="humanist-serif"] h2,
body[data-register="humanist-serif"] h3,
body[data-register="humanist-serif"] h4 { line-height: 1.26; }
body[data-register="humanist"],
body[data-register="humanist-serif"] { line-height: 1.74; }

body[data-register="grotesque"] h1,
body[data-register="grotesque"] h2,
body[data-register$="-sans"] h1,
body[data-register$="-sans"] h2,
body[data-register="neo-grotesque"] h1,
body[data-register="neo-grotesque"] h2 { font-family: var(--sans); line-height: 1.05; }
body[data-register="grotesque"] h1,
body[data-register$="-sans"] h1,
body[data-register="neo-grotesque"] h1 { font-weight: 700; }
body[data-register="grotesque"] h2,
body[data-register$="-sans"] h2,
body[data-register="neo-grotesque"] h2 { font-weight: 600; }
/* The eight display headings are flipped to upright 600 by the HEADING INK block at the
   foot of this file, which is later and therefore wins on equal specificity. Raise the
   sans-led rules by one attribute so the register actually lands. */
body[data-register="grotesque"] .hero h1,
body[data-register="grotesque"] .mission h2,
body[data-register="grotesque"] .split-intro h2,
body[data-register="grotesque"] .friction h2,
body[data-register="grotesque"] .about h2,
body[data-register="grotesque"] .reviews-head h2,
body[data-register="grotesque"] .firstvisit h2,
body[data-register="grotesque"] .book h2,
body[data-register="neo-grotesque"] .hero h1,
body[data-register="neo-grotesque"] .mission h2,
body[data-register="neo-grotesque"] .split-intro h2,
body[data-register="neo-grotesque"] .friction h2,
body[data-register="neo-grotesque"] .about h2,
body[data-register="neo-grotesque"] .reviews-head h2,
body[data-register="neo-grotesque"] .firstvisit h2,
body[data-register="neo-grotesque"] .book h2,
body[data-register$="-sans"] .hero h1,
body[data-register$="-sans"] .mission h2,
body[data-register$="-sans"] .split-intro h2,
body[data-register$="-sans"] .friction h2,
body[data-register$="-sans"] .about h2,
body[data-register$="-sans"] .reviews-head h2,
body[data-register$="-sans"] .firstvisit h2,
body[data-register$="-sans"] .book h2 { font-family: var(--sans); font-style: normal; }
body[data-register="grotesque"] .hero h1,
body[data-register="neo-grotesque"] .hero h1,
body[data-register$="-sans"] .hero h1 { font-weight: 700; }

/* ---------- 1. utility bar ----------------------------------------------- */

.utility {
  background: var(--ink);
  color: rgba(255,255,255,.82);
  font-size: 0.8125rem;
  letter-spacing: 0.01em;
}
.utility .wrap {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.6rem;
  min-height: 42px; padding-block: 0.5rem;
}
.utility a { text-decoration: none; }
.utility a:hover { color: #fff; text-decoration: underline; }
/* Utility items carry their meaning-icon too, not just the buttons (#56, operator
   03:00 "add icons for all"). Light brand so they read as marks rather than
   competing with the copy they label. */
.utility .wrap > span { display: inline-flex; align-items: center; gap: 0.45em; }
.utility .ico { stroke: var(--teal-light); flex: none; }
.utility-sep { margin-left: auto; }
.utility-strong { color: #fff; font-weight: 600; }

@media (max-width: 720px) {
  .utility { font-size: 0.75rem; }
  .utility .wrap { justify-content: center; gap: 0.35rem 1rem; }
  .utility-sep { margin-left: 0; }
  .utility-hide-sm { display: none; }
}

/* ---------- 2. nav -------------------------------------------------------
   The blueprint runs a solid brand-colored nav with a knocked-out wordmark.
   Many real prospect logotypes are multi-tone and collapse to a blob on a
   brand field, and most clinic sites run a white header, so this master keeps
   the nav white and carries the true logo. The brand color is spent on the
   mission band, the testimonial tint and the footer instead.
   ------------------------------------------------------------------------- */

.nav {
  position: sticky; top: 0; z-index: 60;
  background: rgba(255,255,255,.96);
  backdrop-filter: saturate(140%) blur(8px);
  border-bottom: 1px solid var(--grey-line);
}
.nav .wrap {
  display: flex; align-items: center; gap: 1.25rem;
  min-height: 78px; padding-block: 0.75rem;
}
.nav-logo { display: block; flex: 0 0 auto; }
.nav-logo img { width: clamp(148px, 17vw, 200px); height: auto; }
/* Square badge / icon-mark logos (brandLogo.badge): size by HEIGHT so a 1:1
   mark never inflates the sticky bar. Additive + content-gated - wide-lockup
   prospects and the oracle build render byte-identically without the flag. */
.nav-logo img.logo-badge { width: auto; height: 52px; }
.nav-wordmark { font-family: var(--serif); font-size: clamp(1.15rem, 2vw, 1.45rem); color: var(--ink); letter-spacing: -0.01em; white-space: nowrap; }

.nav-links {
  display: flex; align-items: center; gap: clamp(1rem, 2vw, 1.9rem);
  margin-left: auto;
  list-style: none; padding: 0; margin-block: 0;
}
.nav-links a {
  text-decoration: none;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--ink-soft);
  padding-block: 0.4rem;
  border-bottom: 2px solid transparent;
}
.nav-links a:hover { color: var(--teal-deep); border-bottom-color: var(--teal); }

.nav-cta { flex: 0 0 auto; }

/* Nav conversion controls (#56). The primary CTA is filled in the build's accent
   so it wins the row; the call control sits beside it as a bordered secondary and
   carries the accent in its ICON. The primary's icon rides currentColor (white on
   the filled accent) because an accent-on-accent icon would be invisible. */
.nav-actions { display: flex; align-items: center; gap: 0.6rem; flex: 0 0 auto; }
.nav-actions .pill { padding: 0.8rem 1.25rem; font-size: 0.875rem; }
/* One icon register for the whole build: line icons, currentColor by default,
   overridden to the accent wherever the control is a secondary option. */
.ico {
  flex: 0 0 auto;
  fill: none; stroke: currentColor; stroke-width: 2;
  stroke-linecap: round; stroke-linejoin: round;
}
/* The sprite every .ico references. Out of flow and out of the a11y tree; it must
   NOT be display:none, which in some engines stops <use> resolving the symbols. */
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
/* .book-call aligns on the BASELINE of its text, which would drop an icon below
   it; centre just the icon against the line it labels. */
.book-call .ico { align-self: center; }
.nav-call .ico, .form-alt .ico { stroke: var(--teal); transition: stroke .18s ease; }
.nav-call:hover .ico, .form-alt:hover .ico { stroke: #fff; }

/* MEANING-ICONS ON THE SECTION CTAs WHOSE href COMES FROM CONTENT (#56).
   Every hardcoded control on this page already carries the symbol for what it
   does; the five CTAs the tailor authors (mission, services foot, each friction
   item, reviews foot, first visit) carried none, which is the last hole in the
   "one symbol per meaning" contract the sprite was built for.

   The icon has to be EARNED by the link's meaning, not stamped on every pill:
   the approved demo gives its four #book CTAs the calendar and deliberately
   gives its one #contact CTA nothing (index.html:286, 302, 309, 323, 503).
   fill-static's mustache-lite has no equality helper, so the markup always
   emits the calendar and the href decides whether it renders. A CTA pointing
   anywhere else than the booking section shows its label alone, exactly as the
   demo does — a build cannot end up announcing "book" over a link that does
   not book. #book is this master's own booking anchor (index.html section id). */
.pill > .ico-cta { display: none; }
.pill[href="#book"] > .ico-cta { display: block; }

.nav-toggle {
  display: none;
  margin-left: auto;
  background: none; border: 1px solid var(--grey-line); border-radius: 10px;
  width: 44px; height: 44px; cursor: pointer;
  align-items: center; justify-content: center;
  color: var(--ink);
}
.nav-toggle span, .nav-toggle span::before, .nav-toggle span::after {
  content: ""; display: block; width: 20px; height: 2px; background: currentColor; border-radius: 2px;
}
.nav-toggle span { position: relative; }
.nav-toggle span::before { position: absolute; top: -6px; }
.nav-toggle span::after { position: absolute; top: 6px; }

@media (max-width: 960px) {
  .nav .wrap { min-height: 66px; }
  .nav-toggle { display: inline-flex; }
  .nav-links {
    position: absolute; inset: 100% 0 auto 0;
    flex-direction: column; align-items: stretch; gap: 0;
    background: #fff; border-bottom: 1px solid var(--grey-line);
    padding: 0.5rem var(--gutter) 1.1rem;
    margin-left: 0;
    display: none;
  }
  .nav[data-open="true"] .nav-links { display: flex; }
  .nav-links a { padding-block: 0.85rem; border-bottom: 1px solid var(--grey-line); }
  .nav-links li:last-child a { border-bottom: 0; }
  /* Both conversion controls survive on a phone (#56 asks for a 390 fit, and an
     icon-only collapse is the accepted form). The CTA used to be display:none
     here, which dropped the page's primary conversion control on every phone.
     The labels stay in the accessibility tree via .nav-label, and .nav-call
     carries its number in aria-label as well. */
  .nav-toggle { margin-left: 0; order: 9; }
  .nav-actions { margin-left: auto; order: 8; gap: 0.4rem; }
  .nav-actions .pill { padding: 0; width: 44px; height: 44px; }
  .nav-label {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap; border: 0;
  }
}
@media (max-width: 600px) { .nav .wrap { gap: 0.65rem; } }

/* ---------- 3. hero -------------------------------------------------------
   Blueprint: a three-panel band of real clinic photography, edge to edge, with
   a centred white serif H1 over it and ONE conversion control. The blueprint's
   control is a multi-location zip finder; scaled to a single-location clinic
   that becomes a two-field callback request plus the real street address.
   Stage three real photos (panel A desktop-left, B centre + the mobile hero,
   C desktop-right); a lone panel spans the full band as a fallback.
   ------------------------------------------------------------------------- */

.hero { position: relative; isolation: isolate; background: var(--ink); }

.hero-panels {
  position: absolute; inset: 0; z-index: -2;
  display: grid; grid-template-columns: 1fr 1fr 1fr;
}
.hero-panels img { width: 100%; height: 100%; object-fit: cover; }
.hero-panels .panel-b { object-position: 50% 35%; }
.hero-panels img:only-child { grid-column: 1 / -1; }

.hero::after {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background:
    linear-gradient(180deg, rgba(20,26,25,.62) 0%, rgba(20,26,25,.48) 42%, rgba(20,26,25,.80) 100%),
    linear-gradient(90deg, rgba(31,37,35,.30) 0%, rgba(31,37,35,0) 30%, rgba(31,37,35,0) 70%, rgba(31,37,35,.30) 100%);
}

.hero-inner {
  padding-block: clamp(3.25rem, 8vw, 6.5rem);
  min-height: clamp(520px, 74vh, 720px);
  display: flex; flex-direction: column; justify-content: center; align-items: center;
  text-align: center;
}

.hero h1 {
  color: #fff;
  font-size: calc(clamp(1.95rem, 1.05rem + 3.7vw, 4rem) * var(--display-scale, 1));
  max-width: 22ch;
  text-wrap: balance;
  text-shadow: 0 2px 24px rgba(0,0,0,.42);
  margin-bottom: clamp(1.4rem, 3vw, 2.1rem);
}

/* ONE accent-colored hero word (TASTE_LEDGER #41: "punchy headlines... wisely use
   color for accent word"). Rendered only when content.json authors hero.h1Accent;
   with no accent authored the h1 is a plain string and this rule never applies.
   --teal-light is the LIGHT brand slot (palette.brandLight, "light brand on dark
   surfaces"): the h1 sits white-on-photo, so the darker --teal would sink into the
   scrim. #41's pixel-contrast duty is the TAILOR'S: after filling, verify this word
   against the prospect's own hero photo at 390 and 1440 (the gate's contrast walk
   reads it as hero text), and if brandLight is too pale on that ground, re-pin
   theme.json's brandLight rather than hard-coding a color here.
   font-style is forced NORMAL on purpose: <em> is semantic emphasis for the accent
   word, NOT the retired accent-ITALIC headline formula (#35 root cause 3). The
   operator asked for a COLOR accent; italic here would revive the look they cut. */
.h1-accent {
  color: var(--teal-light);
  font-style: normal;
}

/* The bar is a two-step wizard (#66). The plate, radius and shadow are what they
   always were; what changed is that the plate holds one of two PANELS. The WIDTH
   grows with the step because step 2 carries five controls instead of three:
   620px is right for name/phone/button and would force the appointment fields
   onto a second row, so step 2 opens the plate to 880px, which fits service +
   issue + when + submit on one bar-height row at 1440 (MEASURED on the demo: 864px
   of inner width against ~700px of content). It transitions, so the plate visibly
   grows into the second step rather than jumping.
   A build with no hero.wizard.services renders ONE panel and never gets
   data-step="2", so it reads exactly as the single-row bar it always was. */
.hero-form {
  background: rgba(255,255,255,.96);
  padding: 0.5rem;
  border-radius: 999px;
  width: min(620px, 100%);
  box-shadow: 0 18px 44px rgba(0,0,0,.28);
  transition: width .22s ease;
}
.hero-form[data-step="2"] { width: min(880px, 100%); }
/* The PANELS are the flex rows now, not the form - the form holds exactly one
   visible panel at a time and [hidden] takes the other out of the layout. */
.hero-step {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
  width: 100%;
}
.hero-form input,
.hero-form select {
  flex: 1 1 150px; min-width: 0;
  border: 0; background: transparent;
  font: inherit; font-size: 0.9375rem;
  color: var(--ink);
  padding: 0.75rem 1.1rem;
  border-radius: 999px;
}
/* The selects sit on the same white plate as the inputs, so they have to lose the
   platform chrome or step 2 reads as a different control family from step 1. The
   caret is drawn back in as a background mark with room reserved in the right
   padding. It is the PLACEHOLDER grey (#8b9491, the same literal this file already
   uses for input placeholders) rather than a brand slot: a data-URI cannot read a
   CSS custom property, so a themed caret would mean hard-coding one prospect's hex
   into the master. Neutral grey is correct on every palette. */
.hero-form select {
  appearance: none; -webkit-appearance: none;
  padding-right: 2.1rem;
  text-overflow: ellipsis;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none' stroke='%238b9491' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 1rem center;
  cursor: pointer;
}
.hero-form input::placeholder { color: #8b9491; }
/* The selects' own "placeholder" is a real empty-valued first option, so it needs
   styling to match rather than inheriting the chosen-value ink. */
.hero-form select:has(option[value=""]:checked) { color: #8b9491; }
.hero-form input:focus, .hero-form select:focus { outline: 2px solid var(--teal); outline-offset: -1px; }
.hero-form .pill { flex: 0 0 auto; }
.hero-next { text-decoration: none; }

/* Wizard chrome under the bar. White on photography, so it carries the same
   shadow the other hero support lines do.
   The caption sits directly UNDER the primary pill. `justify-content: flex-end`
   was the demo's first attempt and it was WRONG at the in-between widths
   (operator, "alignment bad"): flex-end assumes the pill is at the bar's right,
   which is only true while the bar's row fits on one line. Where the row wraps,
   or below 860 where the bar becomes a column, the pill sits at the LEFT and the
   caption floated off to the right on its own. A fixed justify cannot follow a
   control whose position it does not measure.
   So the row starts at flex-start and its left edge is driven by
   --stepper-offset, which main.js measures from the ACTIVE panel's pill on load,
   on every step change and on resize. Plain px with a 0 fallback: before the
   script runs - and the stepper is [hidden] until it does - the row is simply
   left-aligned, never mispositioned. */
.hero-stepper {
  display: flex; align-items: center; gap: 0.85rem;
  justify-content: flex-start;
  padding-left: var(--stepper-offset, 0px);
  width: min(880px, 100%);
  margin: 0.85rem 0 0;
  font-family: var(--sans); font-size: 0.8125rem;
  color: rgba(255,255,255,.92);
  text-shadow: 0 1px 10px rgba(0,0,0,.55);
}
.hero-back {
  display: inline-flex; align-items: center; gap: 0.35rem;
  font: inherit; font-weight: 600; color: #fff;
  background: rgba(255,255,255,.14);
  border: 1px solid rgba(255,255,255,.42);
  border-radius: 999px;
  padding: 0.3rem 0.8rem 0.3rem 0.6rem;
  cursor: pointer;
}
.hero-back .ico { stroke-width: 2.2; }
.hero-back:hover { background: rgba(255,255,255,.26); }

/* The hero's preview-honest confirmation takes the form's place in the layout, on
   the same white plate so it stays fully legible over the photography. */
.hero-done {
  width: min(620px, 100%); margin: 0;
  background: rgba(255,255,255,.96);
  border-radius: 20px;
  padding: 1.05rem 1.35rem;
  color: var(--ink);
  font-size: 0.9375rem; line-height: 1.6; text-align: left;
  box-shadow: 0 18px 44px rgba(0,0,0,.28);
}
.hero-done a { color: var(--teal-deep); font-weight: 600; white-space: nowrap; }

/* Phone-first hero (no published email): the one conversion control is the phone.
   Same shadow weight as the form bar so the hero keeps its anchor. */
.hero-call {
  font-size: clamp(1rem, 0.9rem + 0.5vw, 1.1875rem);
  padding: 1.05rem 2.4rem;
  box-shadow: 0 18px 44px rgba(0,0,0,.28);
}

.hero-objection {
  margin: 1.15rem 0 0;
  color: #fff;
  font-size: clamp(0.95rem, 0.9rem + 0.2vw, 1.0625rem);
  text-shadow: 0 1px 12px rgba(0,0,0,.5);
}

.hero-where {
  margin: 1.5rem 0 0;
  color: rgba(255,255,255,.86);
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  text-shadow: 0 1px 10px rgba(0,0,0,.5);
}
.hero-where a { color: #fff; font-weight: 600; text-decoration: none; white-space: nowrap; border-bottom: 1px solid rgba(255,255,255,.5); }
.hero-where a:hover { border-bottom-color: #fff; }

@media (max-width: 860px) {
  /* Blueprint's mobile hero collapses the three panels into one strip.
     Panels may ship bare (<img>) or wrapped in <picture> by the fill's webp
     wiring, so hide the hidden panels' WRAPPERS too and stack every panel on
     one explicit 1fr track — panel B sits flush at the top of the band with
     no reserved rows above it. */
  .hero-panels { grid-template-columns: 1fr; grid-template-rows: 1fr; grid-auto-rows: 0; }
  .hero-panels > * { grid-area: 1 / 1 / 2 / 2; }
  .hero-panels .panel-a, .hero-panels .panel-c,
  .hero-panels picture:has(> .panel-a), .hero-panels picture:has(> .panel-c) { display: none; }
  .hero-panels .panel-b { display: block; object-position: 50% 50%; }
  .hero-panels img:only-child { display: block; object-position: 50% 50%; }
  .hero-inner { min-height: 78vh; justify-content: flex-end; padding-bottom: 4rem; }
  .hero h1 {
    max-width: 100%;
    text-wrap: normal;
    font-size: calc(clamp(1.6rem, 7.1vw, 2.4rem) * var(--display-scale, 1));
  }
  /* The wizard stacks here: the plate is a rounded card and both steps become a
     column of full-width controls. The step-2 width bump is pointless at this
     size, so both steps share the one width. */
  .hero-form { border-radius: 18px; padding: 0.75rem; }
  .hero-form[data-step="2"] { width: min(620px, 100%); }
  .hero-step { flex-direction: column; align-items: stretch; gap: 0.6rem; }
  .hero-form input,
  .hero-form select {
    flex: 0 0 auto; width: 100%;
    border-radius: 11px;
    /* background-COLOR, not the `background` shorthand: the shorthand resets
       background-image and would silently delete the select's drawn caret. */
    background-color: #fff;
    box-shadow: inset 0 0 0 1px var(--grey-line);
    padding: 0.85rem 1rem;
  }
  .hero-form select { padding-right: 2.1rem; }
  .hero-form .pill { width: 100%; }
  .hero-stepper { width: min(620px, 100%); }
  .hero-call { width: 100%; }
  .hero-where { margin-top: 1.1rem; }
}

/* ---------- 3b. band texture (TASTE_LEDGER #48) --------------------------- */
/* "Textured gradients, never bare." A large gradient band rendered as a single
   flat ramp reads as a CSS artefact and bands on 8-bit displays. Two layers,
   both felt rather than seen, both inline data / gradients so no asset ships:

     hairline  a 1-in-7px diagonal at ~2% on EVERY big band (mission, friction,
               first-visit, booking), written into each band's own background
               stack as its FIRST layer so it sits above the colour ramp.
     grain     a 120px fractalNoise tile at 10% on the two saturated colour
               bands, where the ramp is widest and the banding worst.

   Ported from the approved demo (css/style.css:515-545, 675-690, 1186-1199,
   1330-1342). The grain is a ::before rather than a background layer because it
   must sit over the gradient without the gradient's own background-size /
   -position / -repeat lists having to grow a fourth entry everywhere. */
.mission, .friction { position: relative; isolation: isolate; }
.mission::before, .friction::before {
  content: ""; position: absolute; inset: 0;
  pointer-events: none; z-index: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: 120px 120px;
  opacity: .10;
}
.mission > .wrap, .friction > .wrap { position: relative; z-index: 1; }

/* ---------- 4. mission band ---------------------------------------------- */

.mission {
  background-image:
    repeating-linear-gradient(135deg, rgba(255,255,255,.028) 0 1px, rgba(255,255,255,0) 1px 7px),
    linear-gradient(115deg, var(--teal-darker) 0%, var(--teal) 55%, var(--teal-light) 130%);
  color: #fff;
}
.mission .wrap {
  display: grid; gap: clamp(1.6rem, 4vw, 3.5rem);
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
  align-items: center;
}
.mission h2 {
  color: #fff;
  font-style: italic;
  font-size: calc(clamp(1.75rem, 1.1rem + 2.4vw, 3rem) * var(--display-scale, 1) * var(--emph-scale, 1));
  margin-bottom: 1.1rem;
  text-wrap: balance;
}
/* MEASURE (presentation axis 6). A ceiling, never a floor - see the --measure note in
   :root. `standard` resolves to 60ch, so this block is unchanged at the default. */
.mission p { color: rgba(255,255,255,.9); max-width: min(60ch, var(--measure, 60ch)); }
.mission-cta { display: flex; justify-content: flex-start; }

@media (max-width: 860px) {
  .mission .wrap { grid-template-columns: 1fr; }
}

/* ---------- 5. services (two labelled groups, photo tiles) ---------------- */

.services { background: var(--paper); }

.split-intro {
  display: grid; gap: clamp(1rem, 3vw, 3rem);
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: start;
  margin-bottom: clamp(2.5rem, 5vw, 4rem);
}
.split-intro h2 {
  color: var(--teal-deep);
  font-style: italic;
  font-size: calc(clamp(1.75rem, 1.1rem + 2.4vw, 2.9rem) * var(--display-scale, 1) * var(--emph-scale, 1));
  text-wrap: balance;
}
.split-intro p { color: var(--grey); max-width: min(52ch, var(--measure, 52ch)); }

@media (max-width: 800px) { .split-intro { grid-template-columns: 1fr; } }

.group-label {
  font-family: var(--sans);
  font-size: 0.75rem; font-weight: 700;
  letter-spacing: 0.2em; text-transform: uppercase;
  margin: 0 0 1.4rem;
}
.group-label.teal { color: var(--teal-deep); }
.group-label.wine { color: var(--wine); }

.tile-row {
  display: grid; gap: clamp(1.25rem, 2.6vw, 2rem);
  grid-template-columns: repeat(3, minmax(0, 1fr));
  margin-bottom: clamp(2.5rem, 5vw, 3.75rem);
}
.tile-row:last-of-type { margin-bottom: clamp(2rem, 4vw, 3rem); }

.tile { display: block; text-decoration: none; color: inherit; }
.tile-media {
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border-radius: 10px;
  background: var(--tint);
}
.tile-media img {
  width: 100%; height: 100%; object-fit: cover;
  transition: transform .5s cubic-bezier(.2,.7,.3,1);
}
.tile:hover .tile-media img { transform: scale(1.045); }
.tile h3 {
  font-size: clamp(1.125rem, 1rem + 0.45vw, 1.4rem);
  border-top: 1px solid var(--grey-line);
  padding-top: 0.9rem;
  margin-top: 0.9rem;
}
.tile p { color: var(--grey); font-size: 0.9375rem; margin-top: 0.45rem; }

/* PHOTO-LESS TILE (batch 7 #2b, 2026-08-30). A tile whose item has no image.src
   used to render as bare text starting at the top of its cell while its
   photo-bearing siblings started below a 4:3 block: measured at 1440 on
   hamiltonback-chiro, heading top -14px against 280px for the two tiles beside
   it. Mid-row that reads as a hole, so it was worked around TWICE in one night
   by re-ordering items to put such a tile last - a content decision taken for a
   CSS reason, which is exactly the kind of debt a master is supposed to absorb.
   WHAT THIS IS: the cell keeps its ground. The card fills the full row height
   (grid items stretch, so its box already matches its siblings') and paints the
   same tint the empty .tile-media placeholder already paints, with the same 10px
   radius - so the row reads as three cards of one family, one of which is set in
   type instead of photographed. That works at ANY position in the row, which the
   re-ordering workaround did not: nothing here depends on being first or last.
   WHY NOT A RESERVED 4:3 BLANK: it aligns the headings but leaves a literal
   empty box, which is the hole with extra steps. WHY NOT AN ICON OR STOCK ART:
   rule 79 - we would be inventing imagery for a service we have no picture of,
   and blueprint-gate's icon-grids check bans the icon-tile signature outright.
   The heading rule that separates a photo tile's title from its image would sit
   on the panel's own top edge here, so it is dropped and a short brand rule
   takes its place: the title still starts against a horizontal mark, which is
   what made the two shapes read as one family in the first place.
   TONE: text on --tint is an established, gate-passing pairing in this master
   (the whole .reviews band is body copy on --tint), so this introduces no new
   contrast question; the blurb moves from --grey to --ink-soft because it now
   sits on the tint rather than on paper. */
.tile-text {
  height: 100%;
  display: flex; flex-direction: column; justify-content: center;
  background: var(--tint);
  border-radius: 10px;
  padding: clamp(1.35rem, 2.4vw, 1.9rem);
}
.tile-text h3 { border-top: 0; padding-top: 0; margin-top: 0; }
.tile-text h3::before {
  content: ""; display: block;
  width: 2.5rem; height: 3px; border-radius: 2px;
  background: var(--teal);
  margin-bottom: 0.95rem;
}
.tile-text p { color: var(--ink-soft); }

@media (max-width: 900px) { .tile-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .tile-row { grid-template-columns: 1fr; } }

.services-foot { text-align: center; }

/* ---------- 6. friction band (the ONE hot band) --------------------------- */

.friction {
  background-image:
    repeating-linear-gradient(135deg, rgba(255,255,255,.028) 0 1px, rgba(255,255,255,0) 1px 7px),
    linear-gradient(118deg, var(--wine-deep) 0%, var(--wine) 48%, var(--wine-light) 100%);
  color: #fff;
}
.friction .eyebrow { color: rgba(255,255,255,.75); }
.friction h2 {
  color: #fff; font-style: italic;
  font-size: calc(clamp(1.75rem, 1.1rem + 2.4vw, 3rem) * var(--display-scale, 1) * var(--emph-scale, 1));
  margin-bottom: clamp(2.25rem, 5vw, 3.5rem);
  max-width: 20ch;
  text-wrap: balance;
}
/* AUTO-FIT, NOT repeat(4) (port C7, 2026-08-30). The hard four-column track made the
   friction band's item COUNT structural rather than editorial: a practice with three
   honest objection-removers got an empty column, and the two-item band the emergency
   sub-niche rows ask for was impossible. The 200px floor is measured, not guessed: at the
   1240px wrap a fifth track can never fit (5*200 + 4*46 = 1184 > 1128 of content), and at
   the 980px breakpoint where these rules stop applying a fourth still does - so every band
   that renders four columns today still renders four, and a 3- or 2-item band now composes
   instead of leaving a hole. Rule 79 in layout form: a section shrinks to its real data. */
.friction-grid {
  display: grid; gap: clamp(1.75rem, 3.2vw, 2.5rem);
  grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr));
}
.friction-item { display: flex; flex-direction: column; align-items: flex-start; }
.friction-item img.f-icon { width: 40px; height: 40px; margin-bottom: 1.1rem; }
.friction-item h3 {
  color: #fff; font-weight: 500;
  font-size: clamp(1.0625rem, 1rem + 0.35vw, 1.3rem);
  margin-bottom: 0.5rem;
}
.friction-item p { color: rgba(255,255,255,.88); font-size: 0.9375rem; margin-bottom: 1.25rem; }
.friction-item .pill { margin-top: auto; }

@media (max-width: 980px) { .friction-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 520px) { .friction-grid { grid-template-columns: 1fr; } }

/* ---------- 7. who-we-are mosaic ------------------------------------------
   The blueprint's careers mosaic alternates real staff portraits against solid
   color tiles. When a prospect publishes no staff photography, dressing stock
   models up as their team would be a fabrication, so the photo cell binds an
   unlabelled real treatment photograph (or drops) and the color tiles carry
   the prospect's real, published copy only.
   ------------------------------------------------------------------------- */

.about { background: var(--paper); }
.about .wrap {
  display: grid; gap: clamp(1.75rem, 4vw, 3.25rem);
  grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr);
  align-items: start;
}
.about h2 {
  font-style: italic;
  font-size: calc(clamp(1.75rem, 1.1rem + 2.4vw, 2.9rem) * var(--display-scale, 1) * var(--emph-scale, 1));
  margin-bottom: 1.1rem;
  text-wrap: balance;
}
/* ACCENT DISCIPLINE (presentation axis 2, second field). The eyebrow is the one accent
   element that repeats on every band, so it is where "how far does the accent reach" is
   legible. Reach 1 is 100% of the real accent - the approved render, unchanged. `reserved`
   (0.6) mixes it toward body grey rather than toward a NEW hue: ledger #67 is absolute
   that an accent comes from the business's real scheme, so discipline may only turn the
   volume down on their colour, never introduce one. `assertive` clamps at full strength
   because there is nothing above the real hue to reach for. The two eyebrows on DARK bands
   (.friction, .firstvisit) are deliberately excluded - muting light ink on a dark ground
   spends legibility, not emphasis. */
.about .eyebrow { color: color-mix(in srgb, var(--wine) var(--accent-pct, 100%), var(--grey)); }
.about-copy p { color: var(--grey); }
.about-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1.75rem; }

.mosaic {
  display: grid; gap: 12px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* Text starts at a FIXED offset from the tile top, identical in every tile
   (TASTE_LEDGER #61: "standardize starting point of text, not depending on copy
   length"). These were bottom-aligned (justify-content: flex-end), so a long bio
   pushed its own name up and no two tiles started at the same height. Top
   alignment plus a reserved row for the name and two for the role means a role
   that wraps no longer shoves its neighbour's copy out of line. */
.mosaic-cell {
  border-radius: 10px; overflow: hidden;
  min-height: 210px;
  padding: 1.5rem;
  display: flex; flex-direction: column; justify-content: flex-start;
}
.mosaic-photo { padding: 0; position: relative; }
.mosaic-photo img { width: 100%; height: 100%; min-height: 210px; object-fit: cover; }
.mosaic-teal { background: var(--teal); color: #fff; }
.mosaic-wine { background: var(--wine); color: #fff; }
.mosaic-ink  { background: var(--ink); color: #fff; }
/* Composure: with an odd cell count (e.g. no usable photo) the last tile spans
   the full row instead of leaving an empty grid slot. */
.mosaic > .mosaic-cell:nth-child(odd):last-child { grid-column: 1 / -1; }
/* min-height reserves one line for the name and TWO for the role, in each
   element's own em, so every tile hands its copy the same starting y. */
.mosaic-cell h3 {
  color: #fff; font-style: italic;
  font-size: 1.1875rem; line-height: 1.2;
  min-height: 1.2em;
  margin-bottom: 0.5rem;
}
.mosaic-cell .role {
  font-family: var(--sans); font-style: normal;
  font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase;
  color: rgba(255,255,255,.72);
  line-height: 1.45; min-height: 2.9em;
  display: block; margin-bottom: 0.75rem;
}
.mosaic-cell p { font-size: 0.875rem; line-height: 1.55; color: rgba(255,255,255,.9); }

/* ---- the NAMED people cell (see the mosaic comment in index.html) ----------
   A portrait INSIDE the named person's own tile, directly above their name and
   role, so the photograph is labelled by construction rather than by a caption
   that can drift away from it. The tile drops its padding and re-applies it to
   the text children only, so the photo runs edge to edge across the tile top.
   object-position sits high in the frame because a portrait's subject does: a
   centred cover crop on a 4:3 box cuts foreheads. The reserved h3/.role heights
   above still apply, so a person tile and a plain tile start their copy at the
   same y. A cell with no photo.personName is untouched by every rule here. */
.mosaic-person { padding: 0; }
.person-photo { display: block; aspect-ratio: 4 / 3; overflow: hidden; }
.person-photo img { width: 100%; height: 100%; object-fit: cover; object-position: center 22%; display: block; }
.mosaic-person > h3 { padding: 1.25rem 1.5rem 0; margin-bottom: 0.5rem; }
.mosaic-person > .role,
.mosaic-person > p { padding-inline: 1.5rem; }
.mosaic-person > :last-child { padding-bottom: 1.5rem; }

/* Caption for the NEUTRAL mosaic photograph. It states what the picture actually
   is (a treatment room, equipment, hands at work) so an unlabelled photo can
   still say something true. Same opaque-plate reasoning as .photo-credit below:
   >0.85 opacity so the gate's text-contrast walk reads THIS as the backdrop
   instead of walking through to the section paper. Bottom LEFT, so a photo that
   carries both a caption and a licence credit does not stack them. */
.photo-caption {
  position: absolute; left: 8px; bottom: 8px;
  max-width: calc(100% - 16px);
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
  background: rgba(17,22,21,.92);
  font-family: var(--sans); font-size: 0.6875rem; line-height: 1.45;
  color: #fff;
}
/* Per-image credit chip: for imagery whose licence demands the attribution be
   shown ON the photo. Deliberately unobtrusive but never washed out — the plate
   is >0.85 opaque on purpose, so the gate's text-contrast walk reads THIS as the
   backdrop (a translucent plate makes it walk past to the section paper and
   score white-on-white) and overlay-legibility samples a dark crop. Wraps rather
   than ellipsing: a truncated attribution is not a displayed attribution. */
.photo-credit {
  position: absolute; right: 8px; bottom: 8px;
  max-width: calc(100% - 16px);
  padding: 0.25rem 0.6rem;
  border-radius: 999px;
  background: rgba(17,22,21,.92);
  font-family: var(--sans); font-size: 0.6875rem; line-height: 1.45;
  color: #fff;
}
.photo-credit a { color: #fff; text-decoration: underline; text-underline-offset: 2px; }

@media (max-width: 900px) {
  .about .wrap { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
  .mosaic { grid-template-columns: 1fr; }
}

/* ---------- 8. testimonials ---------------------------------------------- */

.reviews { background: var(--tint); position: relative; overflow: hidden; }
/* THE REVIEWS PULL-UP, ported with the #52 rhythm from the approved medical demo.
   The operator asked twice for this band and everything under it to move up (02:19
   "move whole page up 50% FROM TESTIMONIALS", then 02:42 "move testimonials and
   anything below up 75px"). 75px does not exist inside this band alone - MEASURED on
   the demo the eyebrow sat 58px below the band's top edge - so the band's own top
   padding goes to zero and the remainder is taken from the whitespace ABOVE it, by
   eating the preceding band's empty trailing padding with a negative margin. Nothing
   with ink in it is overlapped: a section's bottom padding is empty by definition.
   The vw ramp is what keeps this honest at 390, where 75px is not there to give:
   -44.6px at 1440 (-74.9 with the padding, the 75 asked for) and -12.1px on a phone.
   The phone floor is -8px rather than -24px on the demo's own measurement: -24px left
   10px of air under the band above at 390 where -8px leaves 22px, and the two are
   identical at 1440 and 901, so the softer floor costs nothing. */
.reviews {
  padding-top: 0;
  margin-top: clamp(-45px, -3.1vw, -8px);
}
.reviews .eyebrow { color: color-mix(in srgb, var(--teal-deep) var(--accent-pct, 100%), var(--grey)); }
.reviews-head { text-align: center; margin-bottom: clamp(2.25rem, 5vw, 3.5rem); }
.reviews-head h2 {
  font-style: italic;
  font-size: calc(clamp(1.6rem, 1.05rem + 2.1vw, 2.75rem) * var(--display-scale, 1) * var(--emph-scale, 1));
  max-width: 22ch; margin-inline: auto;
  text-wrap: balance;
}
/* `> p`, not a descendant selector: the eyebrow now lives inside .reviews-hero
   and is also a <p> in this subtree, so a descendant rule would hand it the
   sub-line's colour and margin. */
.reviews-head > p {
  color: var(--grey); margin-top: 0.9rem;
  max-width: 58ch; margin-inline: auto;
  text-wrap: balance;
}

/* ---- section-top photograph (TASTE_LEDGER #66) ----------------------------
   The photo runs the FULL band width from the section's top edge and is masked
   to nothing just below the star strip, so the eyebrow, heading and stars sit
   ON imagery and every quote card below sits on clean tint. The mask dissolves
   the layer itself, so the tail is the band's own --tint showing through rather
   than a painted approximation of it.

   Two layers, both carrying the identical mask so they die together:
     img                    z-index -2  the photograph
     .reviews-hero::before  z-index -1  the legibility wash + a texture layer
   Text is in normal flow above both. A real <img>, eager and never lazy: a lazy
   band image stays undecoded while images-load still reports fine, and being an
   <img> child of the text's ancestor is what puts this text inside the gate's
   overlay-legibility sweep at all.

   EVERYTHING here is scoped to .has-photo, which the template only writes when
   reviews.headerPhoto.src is authored. With no photo the whole treatment
   collapses and the head reads exactly as it did before this existed - no
   padding, no wash, no empty slot. */
.reviews-hero { position: relative; z-index: 2; }
.reviews-hero.has-photo {
  /* The photo bleeds UP through the section's own top padding so it starts at the
     band edge, which is why this tracks .reviews's padding-top exactly. That padding
     is now ZERO (the #52 pull-up above), so there is nothing left to bleed through
     and this is 0px. Moving one without the other leaves either a strip of bare tint
     above the photo or a photo that starts above the band. */
  --reviews-bleed: 0px;
  /* Bottom padding and the fade share one value: the layer holds full opacity
     to the star strip's bottom edge and the ENTIRE fade happens in the padding
     underneath it (#66 asked for the fade to be harder, not longer). */
  --reviews-pad-b: clamp(1.3rem, 2.6vw, 2.1rem);
  padding-top: clamp(1.5rem, 3vw, 1.75rem);
  padding-bottom: var(--reviews-pad-b);
  --reviews-fade: linear-gradient(to bottom,
    #000 0%,
    #000 calc(100% - var(--reviews-pad-b)),
    rgba(0,0,0,.45) calc(100% - var(--reviews-pad-b) * 0.62),
    rgba(0,0,0,.08) calc(100% - var(--reviews-pad-b) * 0.22),
    rgba(0,0,0,0) 100%);
}
/* The photo ships EITHER bare (<img>) or wrapped in <picture> by the fill's webp
   wiring, and which one it is depends on whether the build had real image files
   to convert - the oracle refill is bare, every deployed build is wrapped. So the
   masked layer is whichever of the two is the direct child, and when it is the
   picture the img simply fills it. Scoping this to `> .reviews-hero-photo` alone
   was the bug this comment exists to prevent: on a real build the img is a
   GRANDCHILD, the rule missed it, and the photograph dropped into normal flow as
   a full-width banner above the eyebrow with no mask on it at all. */
.reviews-hero.has-photo > picture,
.reviews-hero.has-photo > .reviews-hero-photo,
.reviews-hero.has-photo::before {
  position: absolute;
  left: 50%; transform: translateX(-50%);
  width: 100vw; max-width: none;
  top: calc(-1 * var(--reviews-bleed));
  height: calc(100% + var(--reviews-bleed));
  pointer-events: none;
  -webkit-mask-image: var(--reviews-fade);
          mask-image: var(--reviews-fade);
}
.reviews-hero.has-photo > picture { display: block; z-index: -2; }
.reviews-hero.has-photo > picture > .reviews-hero-photo { width: 100%; height: 100%; }
/* 52% rather than the top of the frame: the band shows only a slice of the
   photo's height at desktop widths, and a crop through the top of a room photo
   lands on ceiling. */
.reviews-hero-photo { z-index: -2; object-fit: cover; object-position: center 52%; }
/* The wash is a HALO, not a sheet. A flat veil heavy enough to carry the small
   caps eyebrow washes the whole photograph to near-white, which is the opposite
   of the pop this was asked for; so the veil is thin at the edges where nothing
   sits on top and concentrates into an ellipse behind the centre column where
   every word of this band lives. The first layer is the build's diagonal
   texture (#48: gradients are never bare). */
.reviews-hero.has-photo::before {
  content: ''; z-index: -1;
  background-image:
    repeating-linear-gradient(135deg, color-mix(in srgb, var(--ink) 2%, transparent) 0 1px, transparent 1px 7px),
    radial-gradient(108% 86% at 50% 42%,
      rgba(255,255,255,.90) 0%,
      rgba(255,255,255,.62) 46%,
      rgba(255,255,255,0) 82%),
    linear-gradient(to bottom,
      rgba(255,255,255,.14) 0%,
      rgba(255,255,255,.12) 55%,
      color-mix(in srgb, var(--tint) 78%, transparent) 100%);
}
/* At phone widths the band's aspect approaches the photograph's own, so `cover`
   shows nearly the entire frame - including whatever high-key corner of it has
   no contrast left after the wash. Widening the box past the viewport zooms
   `cover` back into the middle of the frame, where the subject is. .reviews
   clips the overhang; the wash layer stays at 100vw. */
@media (max-width: 700px) {
  .reviews-hero.has-photo > picture,
  .reviews-hero.has-photo > .reviews-hero-photo { width: 175vw; }
  .reviews-hero-photo { object-position: 52% 56%; }
}
/* No mask support (older Safari/Firefox): the photo would otherwise end in a
   hard horizontal seam across the band. Drop both layers and keep the flat
   tint - the band then reads exactly as it did before the photo existed. */
@supports not ((-webkit-mask-image: linear-gradient(#000, transparent)) or (mask-image: linear-gradient(#000, transparent))) {
  .reviews-hero.has-photo > picture,
  .reviews-hero-photo, .reviews-hero.has-photo::before { display: none; }
}

/* ---- stars (TASTE_LEDGER #49 / #57) ---------------------------------------
   Two stacked copies of one glyph set: a track in faint ink and a gold fill
   clipped to the real rating. The fill fraction is DATA, never decoration -
   --stars-pct defaults to 0%, so a row whose rating did not come through draws
   an EMPTY bar rather than a flattering full one. Per card the integer rating
   drives it through data-stars; the head strip carries a decimal rating and
   sets --stars-pct inline (round(rating / 5 * 100)%). */
.star-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.stars-bar {
  position: relative; display: inline-block; line-height: 0;
  --stars-pct: 0%;
}
.stars-bar[data-stars="1"] { --stars-pct: 20%; }
.stars-bar[data-stars="2"] { --stars-pct: 40%; }
.stars-bar[data-stars="3"] { --stars-pct: 60%; }
.stars-bar[data-stars="4"] { --stars-pct: 80%; }
.stars-bar[data-stars="5"] { --stars-pct: 100%; }
.stars { display: block; height: 17px; width: auto; }
.stars-track { fill: color-mix(in srgb, var(--ink) 18%, transparent); }
.stars-fill {
  position: absolute; left: 0; top: 0; height: 100%;
  fill: var(--gold);
  clip-path: inset(0 calc(100% - var(--stars-pct)) 0 0);
}
.stars-bar-lg .stars { height: 27px; }

/* Source attribution: stars + the Google mark on a white chip, the standard
   review-widget register. No count, no badge, no claim beyond the source. */
.rating-strip {
  display: inline-flex; align-items: center; gap: 0.85rem;
  margin-top: 1.15rem;
  padding: 0.6rem 1.15rem;
  background: #fff;
  border-radius: 999px;
  box-shadow: 0 6px 20px color-mix(in srgb, var(--teal-darker) 10%, transparent);
}
.rating-sep { width: 1px; height: 20px; background: var(--grey-line); }
.g-mark { display: block; width: 20px; height: 20px; }

.review-stack {
  position: relative; z-index: 2;
  max-width: 760px; margin-inline: auto;
  /* The rhythm used to live in the separator rows' own padding. Now that every
     rating sits inside its card, the stack owns the spacing itself. */
  display: flex; flex-direction: column; align-items: stretch;
  gap: clamp(1.1rem, 2.4vw, 1.9rem);
}
.quote-card {
  position: relative; z-index: 2;
  background: #fff;
  border-radius: 12px;
  padding: clamp(1.6rem, 3.5vw, 2.6rem);
  box-shadow: 0 12px 34px color-mix(in srgb, var(--teal-darker) 9%, transparent);
  text-align: center;
}
/* Readability (#57: "change tesimonial font and color be easier to read"). The
   quote was a serif in --wine, which is legal contrast but tiring over a
   60-word patient story that people actually READ. Body sans in --ink takes
   over with a looser line-height; the serif keeps the headings. */
.quote-card blockquote {
  margin: 0;
  font-family: var(--sans);
  font-size: clamp(1rem, 0.95rem + 0.4vw, 1.1875rem);
  line-height: 1.62;
  color: var(--ink);
  max-width: 56ch; margin-inline: auto;
}
.quote-card cite {
  display: block; margin-top: 1.1rem;
  font-family: var(--sans); font-style: normal;
  font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--grey);
}
/* Card order is copy, stars, name, date: the words lead, the rating and the
   attribution sign off under them. When the stars render they own the gap, so
   the cite drops its own. */
.card-stars {
  display: flex; justify-content: center;
  margin-top: clamp(1.15rem, 2.2vw, 1.55rem);
  margin-bottom: clamp(0.7rem, 1.3vw, 0.95rem);
}
.card-stars + cite { margin-top: 0; }
/* When the review was written (#63), from its real publishedAt and never
   approximated. Numeric dd/mm/yyyy at footnote size, dropped out of the name's
   uppercase register so the name still leads the attribution, and in --ink-soft
   rather than --grey so the smaller type does not also get fainter. Tabular
   figures so a column of dates lines up on the slashes. */
.quote-date {
  display: block; margin-top: 0.35rem;
  text-transform: none; letter-spacing: 0.02em; font-weight: 500;
  font-variant-numeric: tabular-nums;
  font-size: 0.625rem; color: var(--ink-soft);
}

/* Photo collage bleeding around the quote stack, per the blueprint: the images
   tuck BEHIND the card corners and run off the page edge. Positioned against
   .review-stack (the 760px column) so the overlap is the same at every width. */
.reviews-bleed {
  position: absolute; z-index: 1;
  width: 300px; height: 210px;
  border-radius: 10px; overflow: hidden;
  /* The inset hairline keeps a pale photo from dissolving into the tint band -
     without it a blown-out frame reads as a missing image. */
  box-shadow: 0 14px 36px color-mix(in srgb, var(--teal-darker) 18%, transparent),
              inset 0 0 0 1px color-mix(in srgb, var(--teal-darker) 12%, transparent);
}
.reviews-bleed img { width: 100%; height: 100%; object-fit: cover; }
.bleed-left  { left: -170px; top: 22%; }
.bleed-right { right: -170px; top: 58%; }
/* THIRD tile, beside the LAST quote card (TASTE_LEDGER #66, operator 02:21:
   "SHOULD HAVE IMAGE SOMEWHERE FOR LAST TESTIMONIAL" - the collage ran out above the
   final card). PORTRAIT, not a third landscape rectangle: three identical rectangles
   read as a grid, and a collage is the thing the blueprint asks for. Anchored to the
   BOTTOM of the stack (bottom, top:auto) so it stays level with the last card however
   tall the cards run; the other two are anchored by percentage from the top, which
   would have drifted with the card count. Left side, far enough below .bleed-left
   (22%) not to crowd it.
   OPTIONAL like its two siblings: it renders only when reviews.bleedLow.src is
   authored, and with nothing authored the collage is exactly the two-tile collage it
   was. Stage a PORTRAIT source here - a landscape photo cropped into this box loses
   its subject. */
.bleed-low {
  width: 220px; height: 290px;
  left: -150px; bottom: -6px; top: auto;
}

@media (max-width: 1080px) {
  .reviews-bleed { width: 240px; height: 175px; }
  .bleed-left  { left: -130px; }
  .bleed-right { right: -130px; }
  .bleed-low   { width: 170px; height: 225px; left: -105px; }
}
@media (max-width: 900px) {
  .reviews-bleed { display: none; }
}

.reviews-foot { text-align: center; margin-top: clamp(2rem, 4vw, 3rem); position: relative; z-index: 2; }

/* ---------- 9. first visit (dark band) ------------------------------------ */

.firstvisit {
  background-color: var(--ink);
  background-image:
    repeating-linear-gradient(135deg, rgba(255,255,255,.022) 0 1px, rgba(255,255,255,0) 1px 7px);
  color: rgba(255,255,255,.86);
}
.firstvisit .eyebrow { color: var(--teal-light); }
.firstvisit h2 {
  color: #fff; font-style: italic;
  font-size: calc(clamp(1.75rem, 1.1rem + 2.4vw, 2.9rem) * var(--display-scale, 1) * var(--emph-scale, 1));
  max-width: 20ch; margin-bottom: 1rem;
  text-wrap: balance;
}
.firstvisit-lede { max-width: min(56ch, var(--measure, 56ch)); margin-bottom: clamp(2.25rem, 5vw, 3.25rem); }
.steps {
  display: grid; gap: clamp(1.5rem, 3vw, 2.5rem);
  grid-template-columns: repeat(3, minmax(0, 1fr));
  list-style: none; padding: 0; margin: 0 0 clamp(2rem, 4vw, 3rem);
}
.steps li { border-top: 1px solid rgba(255,255,255,.22); padding-top: 1.25rem; }
.step-num {
  display: block;
  font-family: var(--serif); font-style: italic;
  font-size: 2.1rem; color: var(--teal-light); line-height: 1;
  margin-bottom: 0.75rem;
}
.steps h3 { color: #fff; font-size: 1.1875rem; margin-bottom: 0.5rem; }
.steps p { font-size: 0.9375rem; color: rgba(255,255,255,.8); }

@media (max-width: 800px) { .steps { grid-template-columns: 1fr; } }

/* ---------- 10. booking form ---------------------------------------------- */

.book {
  background-color: var(--paper);
  background-image:
    repeating-linear-gradient(135deg, rgba(31,37,35,.016) 0 1px, rgba(31,37,35,0) 1px 7px);
}
.book .wrap {
  display: grid; gap: clamp(1.75rem, 4vw, 3.5rem);
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  align-items: start;
}
.book .eyebrow { color: color-mix(in srgb, var(--teal-deep) var(--accent-pct, 100%), var(--grey)); }
.book h2 {
  font-style: italic;
  font-size: calc(clamp(1.75rem, 1.1rem + 2.4vw, 2.9rem) * var(--display-scale, 1) * var(--emph-scale, 1));
  margin-bottom: 1rem;
  text-wrap: balance;
}
.book-aside p { color: var(--grey); }
/* The phone and the email are ONE designed unit (#58): same face, size, weight
   and colour for both values, the same .small label register on both, baseline
   alignment within each line and one shared gap between them. */
.book-contacts {
  display: flex; flex-direction: column; align-items: flex-start;
  gap: 0.6rem; margin-top: 1.4rem;
}
/* Borrowed from the approved medical demo, WITH its reason: both values used to
   be the DISPLAY serif at 1.5rem, which on this page is the voice of headlines
   rather than of data - and at that size a 26-character address does not fit a
   390px column beside its label, so the pair stopped reading as one unit. They
   are now the body sans at 600, matching every other read-it-and-act-on-it value
   on the page, with tabular figures so the phone digits sit on an even rhythm. */
.book-call {
  display: inline-flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
  font-family: var(--sans); font-size: 1.0625rem; font-weight: 600;
  letter-spacing: -0.005em;
  font-variant-numeric: tabular-nums;
  line-height: 1.35;
  color: var(--teal-deep); text-decoration: none;
}
.book-email { overflow-wrap: anywhere; max-width: 100%; }
.book-call:hover { text-decoration: underline; }
/* The label carries this section's accent (the same --teal-deep as its eyebrow)
   rather than the neutral grey, so "IN A HURRY?" / "PREFER TO EMAIL?" read as the
   prompts they are and clear AA with room to spare on the white band. */
.book-call .small {
  font-family: var(--sans); font-size: 0.75rem; font-weight: 600;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--teal-deep);
}

.form-card {
  background: var(--tint-soft);
  border: 1px solid var(--grey-line);
  border-radius: var(--radius);
  padding: clamp(1.35rem, 3vw, 2.25rem);
}
.field-grid { display: grid; gap: 1rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.field { display: flex; flex-direction: column; gap: 0.4rem; }
.field.full { grid-column: 1 / -1; }
.field label {
  font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.04em; color: var(--ink);
}
.field input, .field select, .field textarea {
  font: inherit; font-size: 0.9375rem;
  color: var(--ink);
  background: #fff;
  border: 1px solid var(--grey-line);
  border-radius: 9px;
  padding: 0.75rem 0.9rem;
  width: 100%;
}
.field textarea { min-height: 120px; resize: vertical; }
.field input:focus, .field select:focus, .field textarea:focus {
  outline: 2px solid var(--teal); outline-offset: -1px; border-color: var(--teal);
}
/* space-between opens the real separation between the primary and its two
   options; the tight 0.5rem MINIMUM column gap is what lets all three sit on one
   row inside the card at 1440. Below that width the row wraps and the stacked
   form reads correctly, which is what the 560px block below states outright. */
.form-actions {
  margin-top: 1.35rem;
  display: flex; flex-wrap: wrap; align-items: center;
  justify-content: space-between;
  gap: 0.75rem 0.5rem;
}
/* The quietest thing in the row on purpose: sans, lowercase, small, muted, so it
   separates the primary from its alternatives without competing with either. */
.form-or {
  font-family: var(--sans); font-size: 0.8125rem; font-weight: 500;
  color: var(--grey); line-height: 1;
  flex: 0 0 auto;
}
.form-opts { display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.6rem; }
.form-alt { padding-inline: 0.9rem; }
/* 49px is the natural height of a pill carrying a 17px icon. Stated outright
   rather than left to the layout, because align-items is `center` above - and it
   was already doing the job at 390, where the row becomes a COLUMN and stretch
   equalises width, not height. */
.form-actions .pill { min-height: 49px; }

/* Success state. Sized and padded like the form card it replaces (it carries
   .form-card too) so the column does not jump when the two swap. */
.form-done { text-align: center; }
.form-done-mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 52px; height: 52px; border-radius: 50%;
  background: var(--teal); margin-bottom: 1.1rem;
}
.form-done-mark svg { fill: none; stroke: #fff; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.form-done h3 {
  font-family: var(--serif); font-style: italic; font-weight: 500;
  font-size: clamp(1.35rem, 1.1rem + 1vw, 1.75rem);
  color: var(--ink); margin: 0 0 0.7rem;
}
.form-done p { color: var(--ink-soft); font-size: 0.9375rem; line-height: 1.6; margin: 0 auto; max-width: 46ch; }
/* Two classes on purpose: `.form-done p` above sets `margin: 0 auto`, which is
   (0,1,1) and would otherwise beat a single-class rule and flatten this margin to
   zero. The demo reaches for !important here; the extra class is the same win
   without spending one. */
.form-done .form-done-alt {
  margin-top: 1.1rem; padding-top: 1.1rem;
  border-top: 1px solid var(--grey-line);
  font-weight: 600;
}
.form-done-alt a { color: var(--teal-deep); overflow-wrap: anywhere; }

/* Phone-first booking (no published email): the mailto form drops, the band
   centers, and the phone is the conversion control. */
.book-phone .wrap { grid-template-columns: minmax(0, 62ch); justify-content: center; text-align: center; }
.book-phone-cta { margin-top: 1.6rem; font-size: 1.0625rem; padding: 0.95rem 2.2rem; }

@media (max-width: 900px) { .book .wrap { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .field-grid { grid-template-columns: 1fr; } }
/* On a phone the primary takes a full row of its own and the two options sit in
   a clearly separated row beneath it, with the divider centred between the two
   rows - the same relationship the wide row states horizontally. The stacked gap
   drops to 0.7rem because the word now sits in the middle of it. */
@media (max-width: 560px) {
  .form-actions { flex-direction: column; align-items: stretch; gap: 0.7rem; }
  .form-actions > .pill { width: 100%; }
  .form-or { text-align: center; }
  .form-opts { justify-content: center; gap: 0.6rem; }
  .form-opts .pill { flex: 1 1 auto; padding-inline: 0.9rem; }

  /* A long address wraps below its label here while the shorter phone number
     stays inline, so the two lines stop matching. Stacking BOTH keeps the pair
     one consistent unit at this width. */
  .book-call { flex-direction: column; align-items: flex-start; gap: 0.15rem; }
}

/* ---------- 11. footer ----------------------------------------------------- */

.footer { background: var(--teal); color: rgba(255,255,255,.88); padding-block: clamp(3rem, 6vw, 4.5rem) 0; }
/* THE HOURS COLUMN IS SIZED BY ITS CONTENT, NOT BY A GUESSED FRACTION (batch 7
   #2a, 2026-08-30). Measured on three deployed builds at 1440 BEFORE this change:
   kitchenerphysiotherapy +22px, hamiltonback-chiro +16px, fundy-optometry +43px
   of the hours column's own content hanging OUTSIDE its track and into the gutter
   in front of FIND US. Nothing saw it: the page never scrolls horizontally, so
   blueprint-gate's `overflow` (documentElement.scrollWidth - clientWidth) reads 0,
   and the text stays on its own tinted ground so text-contrast and page-void read
   clean too. A 0.8fr track resolves to ~202px at 1440 while "Wednesday" + gap +
   "8:30am to 7pm" needs ~224px — i.e. the DEFAULT week overran, not some rare
   value; kitchener's real "By appointment only" (5 characters longer than the
   "By appointment" it was shortened to) is what finally pushed the spill past the
   48px gutter into a visible collision.
   `auto` is the fix rather than a bigger fraction: the track takes exactly the
   width the week needs and gives the rest back, so no future set of day names or
   time strings can be the one that does not fit, and there is no magic number to
   re-tune per prospect. The three fr siblings keep minmax(0, …) so they yield the
   space instead of pushing the page wide. The wrap fallback in .hours-time below
   covers the case where even auto cannot be honoured (a very narrow parent). */
.footer-grid {
  display: grid; gap: clamp(1.75rem, 4vw, 3rem);
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.8fr) minmax(0, auto) minmax(0, 1.2fr);
  align-items: start;
}
.footer h3 {
  color: #fff; font-family: var(--sans);
  font-size: 0.75rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
  margin-bottom: 1rem;
}
.footer a { color: #fff; text-decoration: none; }
.footer a:hover { text-decoration: underline; }
/* .footer a would otherwise paint the white pill's label white-on-white */
.footer a.pill-white { color: var(--ink); }
.footer a.pill-white:hover { text-decoration: none; }
.footer ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.55rem; font-size: 0.9375rem; }
.footer-logo-plate {
  display: inline-block; background: #fff; border-radius: 10px;
  padding: 0.85rem 1.1rem; margin-bottom: 1.25rem;
}
.footer-logo-plate img { width: 180px; height: auto; }
.footer-logo-plate img.logo-badge { width: auto; height: 96px; }
.footer-wordmark { display: inline-block; font-family: var(--serif); font-size: 1.45rem; color: #fff; margin-bottom: 1.25rem; }
.footer address { font-style: normal; font-size: 0.9375rem; line-height: 1.7; }
/* Phone / email / fax: one icon line each, all sharing a left edge. The icon is
   a fixed-width flex item so the labels align with each other too. */
.footer-addr { margin: 0 0 0.75rem; }
.contact-line {
  display: flex; align-items: center; gap: 0.6rem;
  margin: 0 0 0.45rem;
  color: #fff; text-decoration: none;
}
.contact-line:last-child { margin-bottom: 0; }
.contact-line .ico { stroke: var(--teal-light); }
.footer a.contact-line:hover span { text-decoration: underline; text-underline-offset: 3px; }
/* HOURS (#59). ONE grid for the whole week with the rows as display:contents, so
   "day" and "time" are two REAL columns and every time starts at the same x. They
   were flex rows with space-between, which pushed each time to its own row's right
   edge and left the column ragged. */
/* max-width CAPS what the `auto` footer track can ask for. Without it a single
   very wordy hours line would be free to take the whole row and squeeze FIND US
   toward zero, which is the overrun's mirror image rather than its fix. Above
   the cap the value wraps inside its own column (see .hours-time). 20rem holds
   every real week measured on this fleet with room to spare: the widest, fundy
   at "8:30am to 12:30pm", asks 245px of 320px. */
.hours-grid {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  column-gap: 1rem; row-gap: 0.5rem;
  font-size: 0.9375rem;
  max-width: 20rem;
}
.hours-row { display: contents; }
.hours-day { position: relative; padding-left: 0.95rem; }
/* The time column takes the remaining width (minmax(0, 1fr) above) instead of
   max-content, and may WRAP rather than spill. `nowrap` was the second half of
   the overrun: with it, a value the track could not hold had nowhere to go but
   outside the column. A clock range still never breaks in practice — the auto
   track on .footer-grid gives the week the width it asks for — so this is the
   graceful floor, not the normal render. `overflow-wrap: anywhere` is the last
   resort for a single unbroken token (a long "8:30am–12:30pm" dash form on a
   narrow parent), matching .form-done-alt's idiom elsewhere in this file. */
.hours-time {
  text-align: left; color: rgba(255,255,255,.78);
  min-width: 0; overflow-wrap: anywhere;
}
/* Today's marker: a triangle POINTING RIGHT at the day name. Its gutter is
   reserved on EVERY row and the triangle is transparent until main.js marks one,
   so marking costs ZERO layout shift and a page with JS off renders the same list
   it always did, just uniformly indented.
   content-box explicitly: the global border-box rule makes `width: 0` resolve
   against the borders that FORM the triangle, so the box stops collapsing and the
   shape is no longer a clean triangle. */
.hours-day::before {
  content: ""; position: absolute; left: 0; top: calc(0.5em + 2px);
  box-sizing: content-box;
  width: 0; height: 0; border-radius: 0;
  border-top: 4.5px solid transparent;
  border-bottom: 4.5px solid transparent;
  border-left: 7.5px solid transparent;
  background: none;
}
.hours-row[data-today="true"] { font-weight: 700; color: #fff; }
.hours-row[data-today="true"] .hours-day,
.hours-row[data-today="true"] .hours-time { font-weight: 700; color: #fff; }
/* BOTH state tones are LIGHT tints, and that is measured rather than stylistic:
   the footer band sits at the brand colour, and on the source build's mid
   luminance (0.189) darkening cannot reach the 3:1 non-text bar while lightening
   can. #9bf0a8 and #ffcfc0 clear 3:1 there and are far apart in hue. A prospect
   whose brand is very light inverts that arithmetic, so the TAILOR must pixel-
   verify both markers on their own footer and, if either fails, pin a darker
   footer slot in theme.json rather than hard-coding a colour here. */
.hours-row[data-today="true"][data-state="open"] .hours-day::before { border-left-color: #9bf0a8; }
.hours-row[data-today="true"][data-state="closed"] .hours-day::before { border-left-color: #ffcfc0; }
.footer-map {
  width: 100%; height: 210px; border: 0; border-radius: 10px; display: block;
  background: var(--teal-deep);
}
/* Centred under the map (#59). The `.footer-cta` repeat booking pill that used to
   follow it is gone with the same verdict: the footer never duplicates the
   primary booking CTA. */
/* Get directions is a white BUTTON in the shared .pill geometry, not a text
   link, and carries the Google Maps pin in Google's own red as the one spot of
   colour down here — the same source-attribution role the G plays on reviews.
   No wrapper element: the button centres itself, so the only thing the contrast
   gate can measure here is the button, which carries its own white fill.
   `.footer a { color:#fff }` is (0,1,1) and would outrank `.pill-white`'s
   (0,1,0), painting the label white-on-white; `.footer a.pill-white` (0,2,1)
   above already states the ink, and this rule restates it on hover too. */
.footer .btn-directions {
  color: var(--ink);
  padding: 0.8rem 1.25rem;
  display: flex; width: max-content;
  margin: 0.9rem auto 0;
}
.footer .btn-directions:hover { color: var(--ink); background: var(--tint); }
.ico-pin { flex: 0 0 auto; stroke: none; }
.footer-bottom {
  margin-top: clamp(2.25rem, 5vw, 3.25rem);
  border-top: 1px solid rgba(255,255,255,.24);
  padding-block: 1.4rem;
  display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; align-items: center;
  font-size: 0.8125rem; color: rgba(255,255,255,.78);
}
.footer-bottom .spacer { margin-left: auto; }
/* Page-level photo colophon: one deduped attribution line for every credited
   photo on the page (Google Place photos carry their contributor's name; Street
   View keeps its "© Google" notice). `flex-basis: 100%` drops it onto its own
   row under the copyright line. Renders ONLY when an image carries a `credit` —
   an uncredited build emits nothing at all, not an empty label. */
.photo-colophon {
  flex: 1 0 100%;
  color: rgba(255,255,255,.78);
  line-height: 1.6;
  /* The fixed PXLNorth preview badge parks over the last ~48px of the viewport,
     and this line is the last thing on the page — at 390 it sat directly under
     the badge. An attribution the chrome covers is not a displayed attribution,
     so the colophon reserves its own clearance. Costs nothing on an uncredited
     build: the element does not exist unless a photo carries a credit. */
  padding-bottom: 3.5rem;
}
.photo-colophon a { color: #fff; text-decoration: underline; text-underline-offset: 3px; }

@media (max-width: 980px) { .footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .footer-grid { grid-template-columns: 1fr; } }

/* ---------- preview badge -------------------------------------------------- */

.preview-badge {
  position: fixed; left: 50%; transform: translateX(-50%); bottom: 14px; z-index: 120;
  display: flex; align-items: stretch; gap: 1px;
  background: rgba(31,37,35,.94);
  border-radius: 999px; overflow: hidden;
  box-shadow: 0 10px 30px rgba(0,0,0,.28);
  font-family: var(--sans); font-size: 0.75rem;
  max-width: calc(100vw - 24px);
}
.preview-badge a { text-decoration: none; padding: 0.6rem 1rem; white-space: nowrap; }
.preview-badge-info { color: rgba(255,255,255,.85); }
.preview-badge-cta { background: var(--teal-light); color: #14201d; font-weight: 700; }
.preview-badge-cta:hover { background: #fff; }

@media (max-width: 540px) {
  .preview-badge { font-size: 0.6875rem; }
  .preview-badge a { padding: 0.5rem 0.7rem; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}

/* HEADING INK (TASTE_LEDGER #44 "font is weak and cheap" / #53 "hard to read font" /
   #66 "headings THICKER") — display headings shipped at Newsreader 400 italic, which the
   operator read as weak three times and which blueprint-gate's heading-ink check (floor 500)
   fails on every clinical tailor. Upright 600 across h1 + the eight section h2s; -0.02em
   keeps the wider weight from loosening the line. Last in the file so it beats the per-section
   blocks above (same specificity, later wins). The 600 face is REQUESTED in the master's
   default fonts.googleHref — a used weight must be a fetched weight, or the browser fakes it. */
.hero h1,
.mission h2,
.split-intro h2,
.friction h2,
.about h2,
.reviews-head h2,
.firstvisit h2,
.book h2 {
  font-style: normal;
  /* PRESENTATION AXIS 1, added 2026-08-30. The weight is the matrix's displayRegister,
     CLAMPED by the resolver to $presentation.headingWeights [500, 600] - the serif upright
     cuts every tailored fonts.googleHref is required to fetch - so it can never land on a
     face the link does not request (blueprint-gate's faux-bold failure) and never under
     500 (heading-ink's own floor). 600 is both the fleet baseline and this master's
     approved value, so a build with no matrix row renders this line unchanged. Tracking is
     a DELTA from the fleet neutral for the same reason (see :root). */
  font-weight: var(--display-weight, 600);
  letter-spacing: calc(-0.02em + var(--display-track-delta, 0em));
}
