/* tokens.css */
/* =============================================================================
   London Massage Concierge — DESIGN TOKENS
   -----------------------------------------------------------------------------
   Source of truth: docs/art-direction/visual-direction.md §2 (colour), §3 (type),
   §4 (layout/space) · docs/a11y/accessibility-requirements.md §1–§2 · R-CD-04.

   The visual idea is "the quiet desk": ink and ivory, a single gold line, and
   space to think. About 90% ink-and-ivory, at most 10% accent. Gold is a LINE,
   never a fill. Burgundy is the moment of commitment and exists almost solely
   for Book Now.

   R-CD-09 (14 Sep 2026) partially supersedes the paragraph above: gold and
   burgundy gained a THIRD role, a decorative background wash/gradient, on
   client direction toward more atmosphere and colour presence across
   sections. Text, icons, borders and focus rings keep the original hard
   rules untouched — see the ATMOSPHERE block below for the measured detail.

   Load order: tokens.css -> base.css -> components.css
   ============================================================================= */

/* =============================================================================
   ###  TYPE TERRITORY  ###  THE SWAPPABLE BLOCK — START
   -----------------------------------------------------------------------------
   THIS IS THE ONLY PLACE IN THE DESIGN SYSTEM THAT NAMES A TYPEFACE.
   Every @font-face rule and every font-family declaration lives between this
   marker and the matching END marker. Nothing outside it may name a font;
   everything outside it goes through --font-display / --font-body / --font-price.

   Currently implemented: TERRITORY A "Hotel Classical" (visual-direction §3.1)
   — Marcellus for display, Hanken Grotesk for body/UI.

   STATUS: RULED. Territory A upheld by the art-director on rendered evidence,
   13 Sep 2026 — see docs/art-direction/type-ruling.md. Grounds: Bodoni Moda's
   hairlines fail on Soft Black at header and display size (with optical sizing
   already active, i.e. at its sturdiest available cut); Hanken Grotesk's
   figures are natively tabular at 560/1000 and cannot be broken, where
   Manrope's are proportional by default; and Bodoni breaks the 1440 H1 from
   one line onto two (155px -> 290px, measured at matched cap height).

   NOTE, because this is the thing most likely to be "improved" later: the
   ruling explicitly does NOT rest on Marcellus resembling the logo wordmark.
   It does not resemble it — Bodoni Moda is the closer relative, and
   visual-direction §3.1 was corrected on that point (type-ruling §6).
   Marcellus is a deliberate companion to the mark, not a relative of it.
   Do NOT move this pairing toward the logo's high-contrast Didone character;
   that is the exact direction that was tested and failed on dark surfaces.
   ============================================================================= */

/* --- Webfonts. Self-hosted WOFF2, subset to Latin (visual-direction §3.4).
       Marcellus 14 KB + Hanken Grotesk 35 KB = 49 KB, inside the ~100 KB
       critical budget, and exactly two families / two preloaded files.
       Paths are relative to this stylesheet so they resolve identically from
       src/styles/ and from the built dist/styles/. ------------------------- */

@font-face {
  font-family: "Marcellus";
  src: url("../assets/fonts/marcellus-latin.woff2") format("woff2");
  font-weight: 400;             /* Marcellus has ONE weight and no italic. */
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Hanken Grotesk";
  src: url("../assets/fonts/hanken-grotesk-latin-var.woff2") format("woff2");
  font-weight: 400 700;         /* variable weight axis; we use 400/500/600 */
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD;
}

/* --- Metric-override fallbacks (visual-direction §3.4 "Zero layout shift").
   -----------------------------------------------------------------------------
   MEASURED, NOT ESTIMATED. Generated by `node tools/font-metrics.mjs`, which
   brotli-decompresses each WOFF2, reads head/hhea/OS-2/hmtx/cmap out of it, and
   does the same for the named macOS/Windows fallback TTF.

   The formula, and why it is shaped this way:
     size-adjust       = avgCharWidth(web) / avgCharWidth(fallback)
     ascent-override   = ascent(web)  / unitsPerEm(web) / size-adjust
     descent-override  = descent(web) / unitsPerEm(web) / size-adjust
     line-gap-override = lineGap(web) / unitsPerEm(web) / size-adjust
   size-adjust rescales the fallback so a line of text is the same LENGTH and
   therefore wraps onto the same number of lines; the three vertical overrides
   are divided by size-adjust so the line BOX stays the same height despite that
   rescale. avgCharWidth is the advance of the lowercase alphabet plus the space
   weighted by English letter frequency — not OS/2.xAvgCharWidth, whose
   definition changed between OS/2 versions and is not comparable across fonts.

   HONEST SCOPE OF THE CLAIM: because base.css sets a numeric line-height on
   every text element, most of the vertical shift is already neutralised by CSS.
   What these overrides actually remove is (a) the horizontal reflow, which is
   the part that causes visible CLS in a paragraph, and (b) the residual vertical
   error wherever line-height resolves to `normal` — form controls, buttons and
   replaced elements. Measured error removed, per tools/font-metrics.mjs:
     Marcellus/Georgia        line height -9.38%  -> 0.0000%   length -3.62% -> 0%
     Hanken Grotesk/Arial     line height -11.75% -> 0.0000%   length -1.49% -> 0%
   On a platform carrying none of the named fallbacks (most Android), the
   local() src fails, the face is skipped, and the stack falls through to the
   generic — unshifted but unmatched. That is a platform limit, not a defect. */

@font-face {
  font-family: "Marcellus Fallback";
  src: local("Georgia");
  size-adjust: 103.76%;
  ascent-override: 93.88%;
  descent-override: 26.96%;
  line-gap-override: 0%;
}

@font-face {
  font-family: "Marcellus Fallback 2";
  src: local("Times New Roman");
  size-adjust: 113.91%;
  ascent-override: 85.52%;
  descent-override: 24.56%;
  line-gap-override: 0%;
}

@font-face {
  font-family: "Hanken Grotesk Fallback";
  src: local("Arial");
  size-adjust: 101.51%;
  ascent-override: 98.51%;
  descent-override: 29.85%;
  line-gap-override: 0%;
}

@font-face {
  font-family: "Hanken Grotesk Fallback 2";
  src: local("Helvetica Neue");
  size-adjust: 100.26%;
  ascent-override: 99.74%;
  descent-override: 30.22%;
  line-gap-override: 0%;
}

:root {
  /* The three semantic handles. Everything outside this block uses only these. */

  --font-display: "Marcellus", "Marcellus Fallback", "Marcellus Fallback 2",
    Georgia, "Times New Roman", serif;

  --font-body: "Hanken Grotesk", "Hanken Grotesk Fallback",
    "Hanken Grotesk Fallback 2", Arial, Helvetica, sans-serif;

  /* --font-price: the family allowed to set a price.
     visual-direction §3.1: "Prices always use tabular lining figures so columns
     align." MEASURED FINDING (node tools/font-features.mjs): Marcellus's digits
     have TEN DIFFERENT ADVANCE WIDTHS (0 = 1651 units, 1 = 604 units, on a 2048
     em) and its GSUB carries only `frac` and `liga` — there is no `tnum` and no
     `lnum`, so font-variant-numeric cannot fix it and browsers will not
     synthesise tabular figures. Marcellus therefore CANNOT satisfy the rule.
     Hanken Grotesk's digits are all 560/1000 — already lining and already
     tabular, with no feature needed. So prices are set in the body face.
     This is a real conflict inside §3 resolved on measurement; see README. */
  --font-price: var(--font-body);

  /* Display floor (§3.1): "Marcellus never goes below 24 px. Below that,
     headings switch to Hanken Grotesk in medium weight with wide tracking."
     Only type steps whose clamp MINIMUM is >= this value may use
     --font-display. With the scale below that means --step-3 and larger.
     If the territory is swapped to a face with no such floor, set it to 0. */
  --display-min: 1.5rem;        /* 24px */

  /* Optical tracking for the display face at large sizes. Marcellus is drawn
     from Roman inscriptional capitals and sets slightly loose already, so this
     is near zero; a Didone (Territory B) would want a negative value here. */
  --display-tracking: 0.005em;

  /* Weights available in the body face. Marcellus has none of these — it has
     one weight — which is exactly why hierarchy comes from size, space and
     case rather than weight (§3.1). */
  --weight-body: 400;
  --weight-medium: 500;
  --weight-strong: 600;
}

/* =============================================================================
   ###  TYPE TERRITORY  ###  THE SWAPPABLE BLOCK — END
   Nothing below this line names a typeface.
   ============================================================================= */


:root {
  /* ===========================================================================
     COLOUR — visual-direction §2.2, implemented exactly.
     Every ratio below is measured by `node tools/contrast.mjs`, which composites
     alpha the way a browser does (sRGB device space, rounded to 8 bits) and then
     applies the WCAG relative-luminance formula from a11y §1.6.
     =========================================================================== */

  /* -- Client swatches (B3 §3, fixed — never edit these four) -- */
  --ink: #0b0b0b;                 /* Soft Black */
  --surface: #f8f4ea;             /* Ivory — the room. Never stark white.      */
  --accent: #6b1f2a;              /* Burgundy — the moment of commitment.      */
  --gold: #d4af7c;                /* Warm Gold — a line, never a fill.         */

  /* -- Surfaces -- */
  --surface-raised: #fcfaf4;      /* cards/sheets on ivory: a lift, not a shadow */
  --surface-dark: #0b0b0b;        /* header, footer, dark sections               */
  --surface-dark-raised: #141312; /* warm charcoal, cards on dark (B2 §1)        */

  /* -- Text --
     --ink-70 / --on-dark-70 close visual-direction §10's open item. Measured,
     not estimated. The composited hex is recorded beside each so QA can
     eyedropper a screenshot and get the same number.                         */
  --ink-70: rgba(11, 11, 11, 0.7);       /* composites to #52514E — 7.23:1 on --surface      */
  --on-dark: #f8f4ea;                    /* 17.92:1 on --surface-dark                        */
  --on-dark-70: rgba(248, 244, 234, 0.7);/* composites to #B1AEA7 — 8.89:1 on --surface-dark */
  /* Both clear the 7:1 AAA body floor the spec asked for, at the 70% the spec
     named, so no alpha adjustment was needed. Cross-checked on the raised
     surfaces they may also sit on: 7.39:1 and 8.59:1 respectively. */

  /* -- Accent --
     HARD RULE (§2.3): NEVER burgundy on black (1.74:1). On dark surfaces the
     primary button inverts to ivory fill with ink text. Burgundy never fills a
     section background. */
  --accent-hover: #571822;        /* ivory on it: 12.26:1 */
  --accent-pressed: #431219;      /* distinct from hover, not a 2% shift (a11y §1.4) */
  --on-accent: #f8f4ea;           /* 10.31:1 on --accent */

  /* -- Gold --
     HARD RULE (§2.3): gold carries NO meaning on ivory (1.87:1). Never text,
     never a meaningful icon, never an input border, never a focus ring there.
     Use --gold-text on ivory. No gradients, foils, glows or shadows on gold. */
  --gold-text: #7a5c2e;           /* "Deep Bronze" — 5.63:1 on ivory. Small labels,
                                     eyebrows, inline links, meaningful icon strokes
                                     on ivory ONLY. Not body copy, not CTAs. (a11y §1.4) */

  /* -- Lines. The Hairline motif (§5) is where gold earns its keep. -- */
  --line: rgba(11, 11, 11, 0.12);          /* structural rules on ivory (§2.2)  */
  --line-strong: rgba(11, 11, 11, 0.34);   /* emphasis rules on ivory           */
  --line-on-dark: rgba(248, 244, 234, 0.22);
  --line-gold: #d4af7c;                    /* DECORATIVE ONLY. 1.87:1 on ivory. */

  /* Functional borders must clear 3:1 (a11y §1.3 names input borders).
     Solved, not guessed: 44% ink is the minimum on ivory, 36% ivory on ink.
     These sit above that for headroom. */
  --field-line: rgba(11, 11, 11, 0.52);       /* -> #7D7B76, 3.85:1 on --surface      */
  --field-line-dark: rgba(248, 244, 234, 0.62);/* -> #9E9B95, 7.10:1 on --surface-dark */

  /* -- Focus (a11y §1.5; §8 line 1: ties to the Ring, never `outline: none`) -- */
  --focus-light: #0b0b0b;         /* on ivory — 17.92:1 */
  --focus-dark: #d4af7c;          /* on Soft Black — 9.58:1, and reads "signature" */
  --focus-on-accent: #f8f4ea;     /* inside a burgundy fill — 10.31:1. Never ink here. */
  --focus-width: 2px;             /* a11y §1.5 floor */
  --focus-offset: 2px;            /* keeps the ring off the component's own colour */

  /* -- State --
     Errors reuse the accent family. Colour is never the only signal: every
     error also carries an icon-free text prefix, aria-invalid and a message
     (§8 line 6, a11y §1.4). */
  --error: #8c1d28;               /* on ivory: 7.6:1 — darker than --accent so error
                                     text never reads as a Book Now affordance */
  --error-surface: #fdf1f0;

  /* ===========================================================================
     ATMOSPHERE — R-CD-09. A third role for gold and burgundy: a decorative
     background wash/gradient, additive to "gold is a line" (§2.3/§5) and
     "burgundy is the moment of commitment" (§2.2). Backgrounds only — never
     text, never a meaningful icon or input border, never a focus ring, and
     never applied to the Ring/hairline/star motifs themselves (those keep
     "no gradients, foils, glows or shadows on gold" exactly as written above).
     A fill behind content is a different visual role than a stroke or a
     glyph, so this does not reopen the hard rules — it adds a role beside them.

     MEASURED (canvas-resolved color-mix, same relative-luminance/ratio method
     as tools/contrast.mjs — a color-mix result is composited via getImageData
     rather than computed, because getComputedStyle can serialise it back out
     as oklch() instead of rgb()) so the wash is chosen by what it does to real
     text, not by eye:
       --surface-warm       #F8F0E5   --ink-70 on it: 7.03:1 — clears the 7:1
                             AAA floor this system already holds secondary
                             text to (same razor as --surface-sunken's 7.02:1,
                             "no headroom at all" — this has slightly more).
                             Safe anywhere --surface is, including under muted
                             text.
       --surface-warm-deep  #F4E8D9   --ink-70 on it: 6.57:1 — clears 4.5:1 AA
                             but falls under the 7:1 AAA floor. DECORATIVE END
                             ONLY: use where the content is full --ink, a
                             heading, or imagery — never place .muted/.meta
                             (--ink-70) text directly on this stop.
       --surface-dark-warm  #160E0F   --on-dark 17.32:1, --on-dark-70 8.59:1,
                             --gold on it 9.26:1. Identical recipe to the
                             hero's existing gradient (14 Sep 2026); hoisted to
                             a token so every dark content section can share
                             it (components.css §Hero).
       --surface-dark-gold  #1B1815   --on-dark 16.09:1, --on-dark-70 7.98:1,
                             --gold on it 8.6:1. A second dark wash — gold-
                             leaning instead of burgundy-leaning — for rhythm
                             variety where a page wants a dark section that
                             doesn't read as "another hero".
     All four clear every floor with real headroom: --ink and --surface-dark
     sit so close to the gamut extremes that even a 20% mix toward gold or
     burgundy barely moves the measured ratio (checked up to 20% while
     choosing these percentages; higher would still pass, these were picked
     for the visible warmth wanted, not because more would fail). */
  --surface-warm: color-mix(in oklch, var(--surface), var(--gold) 5%);
  --surface-warm-deep: color-mix(in oklch, var(--surface), var(--gold) 16%);
  --surface-dark-warm: color-mix(in oklch, var(--surface-dark), var(--accent) 12%);
  --surface-dark-gold: color-mix(in oklch, var(--surface-dark), var(--gold) 10%);

  /* ===========================================================================
     TYPE SCALE — §3.4 fluid with clamp(), §4 mobile-first at 375px.
     Every step is min@375px -> max@1440px. Minimums and maximums are in rem so
     the whole scale still grows under browser zoom and OS text size (SC 1.4.4).
     Body is 16px at 375px, which is the a11y §2 floor, not a coincidence.

     DISPLAY-SAFE STEPS (clamp minimum >= --display-min, 24px): step-3, step-4,
     step-5. ONLY these may carry --font-display. step-2 and below are set in
     --font-body at --weight-medium (§3.1).
     =========================================================================== */
  --step--2: clamp(0.75rem, 0.728rem + 0.094vw, 0.8125rem);  /* 12 -> 13px  meta, legal */
  --step--1: clamp(0.875rem, 0.853rem + 0.094vw, 0.9375rem); /* 14 -> 15px  captions, hints */
  --step-0:  clamp(1rem, 0.978rem + 0.094vw, 1.0625rem);     /* 16 -> 17px  BODY */
  --step-1:  clamp(1.125rem, 1.081rem + 0.188vw, 1.25rem);   /* 18 -> 20px  lede */
  --step-2:  clamp(1.3125rem, 1.246rem + 0.282vw, 1.5rem);   /* 21 -> 24px  sans heading ceiling */
  --step-3:  clamp(1.5rem, 1.324rem + 0.751vw, 2rem);        /* 24 -> 32px  display floor */
  --step-4:  clamp(1.75rem, 1.398rem + 1.502vw, 2.75rem);    /* 28 -> 44px  display */
  --step-5:  clamp(2.125rem, 1.553rem + 2.441vw, 3.75rem);   /* 34 -> 60px  hero display */

  --leading-display: 1.08;        /* large Marcellus: tight, it is already airy */
  --leading-heading: 1.18;
  --leading-body: 1.6;
  --leading-tight: 1.35;          /* dense UI lists */

  /* Wide tracking is permitted ONLY on short uppercase labels of three words or
     fewer (§3.1). --tracking-label is the default for those; --tracking-label-wide
     is the top of the 0.08–0.14em band, for the shortest marks only.
     --tracking-heading is for small SANS headings, which are sentence case and
     therefore explicitly NOT in the wide-tracking band. */
  --tracking-label: 0.1em;
  --tracking-label-wide: 0.14em;
  --tracking-heading: 0.01em;
  --tracking-none: 0;

  --measure: 62ch;                /* long-form 60–72ch (§3.4). Was 68ch: `ch` sizes off the
    "0" glyph, which is wider than Hanken Grotesk's real average letter, so 68ch was
    rendering some prose paragraphs at 76-79 real characters/line (measured 14 Sep 2026,
    range test on /about/) against the skill's own 65-75ch cap. 62ch brings the worst
    cases back under 75 without starving the short paragraphs that were already fine. */
  --measure-tight: 60ch;
  --measure-ceremonial: 34ch;     /* centred BODY lines only; never a paragraph > 2 lines (§4) */

  /* CQ-01. The ceremonial measure above is correct for body text and WRONG for
     display text, because `ch` resolves against the element's OWN font-size. A
     `.ceremonial` wrapper is a div inheriting ~17px Hanken, so 34ch computed to
     323.68px — and then capped headings set at 44–60px inside it. On
     /payment-confirmed/ at 1440 that broke the H1 over eight lines with
     mid-word breaks (global `overflow-wrap: break-word`, base.css:43, severs a
     word once the cap is narrower than the word). Invisible at 375, where the
     column is 327px and a 324px cap changes nothing.
     So display type is capped on ITSELF, where the unit resolves at display
     size. In `em`, not `ch`: Marcellus's `0` is 1651/2048 em (R-CD-06c), an
     unusually wide digit, so `ch` overstates its average character width by
     roughly 40% and would silently reintroduce a too-wide measure. */
  --measure-ceremonial-display: 15em;   /* ~24 display characters per line */

  /* ===========================================================================
     SPACE — §4, 4px base: 4/8/12/16/24/32/48/64/96/128.
     Named by their px value so the scale is unambiguous and nothing gets
     invented between steps.
     =========================================================================== */
  --space-4: 0.25rem;
  --space-8: 0.5rem;
  --space-12: 0.75rem;
  --space-16: 1rem;
  --space-24: 1.5rem;
  --space-32: 2rem;
  --space-48: 3rem;
  --space-64: 4rem;
  --space-96: 6rem;
  --space-128: 8rem;

  /* Section rhythm: 64–80px mobile, 96–128px desktop (§4). */
  --section-y: clamp(4rem, 2.592rem + 6.009vw, 8rem);        /* 64 -> 128px */
  --section-y-tight: clamp(3rem, 1.944rem + 4.507vw, 6rem);  /* 48 -> 96px  */

  /* ===========================================================================
     LAYOUT — §4. 4-col mobile / 8-col tablet / 12-col desktop, ~1200px max,
     outer margins 24px mobile and 64px+ desktop.
     Breakpoints (custom properties cannot be used in @media, so they are fixed
     literals in the stylesheets and recorded here for reference):
       tablet  >= 48rem (768px)  -> 8 columns
       desktop >= 64rem (1024px) -> 12 columns
     =========================================================================== */
  --content-max: 75rem;           /* 1200px */
  --content-text: 44rem;          /* long-form column */
  --gutter: clamp(1.5rem, 0.62rem + 3.756vw, 4rem);  /* 24 -> 64px */
  --grid-cols: 4;
  --grid-gap: var(--space-16);

  --radius: 2px;                  /* near-square: restraint, not softness */
  --radius-sheet: 4px;
  --hairline: 1px;

  /* ===========================================================================
     CONTROLS
     =========================================================================== */
  --target: 44px;                 /* LMC internal floor (a11y §2), above SC 2.5.8's 24px */
  --ring-stroke: 1px;             /* the Ring motif: optically consistent 1–1.5px (§5) */
  --bar-height: 0px;              /* set by the persistent action bar; reserves page padding */

  /* ===========================================================================
     MOTION — docs/motion/motion-spec.md §1. "Felt, not noticed."
     a11y §6 / North Star §4.6: micro 150–250ms, UI 300–450ms, total <= 1000ms,
     transform and opacity only.

     Four curves, deliberately: three from v1.0 and one added by the v1.1
     relaxation pass (motion-spec.md, v1.1 addendum). Every extra curve is a
     future drift opportunity, and a reviewer can still hold four in their head.
       settle — arriving with travel. Both control points sit at y <= 1, so this
                curve is MATHEMATICALLY incapable of overshoot. That is what
                enforces §4.6's ban on bounce/elastic/wobble — not anyone's
                restraint. Slope ~5.25 at t=0 (instant response), 0 at t=1
                (silent stop). Any replacement must keep both properties.
       calm   — v1.1. The fade curve, for anything that arrives or leaves by
                opacity alone: section reveals, the mobile menu, an FAQ answer,
                a Card step, one page dissolving into the next. Slope 0.25 at
                t=0 so a fade BEGINS gently instead of snapping, then a long
                silent tail; both control points at y <= 1, so no overshoot.
       exit   — leaving with travel. Accelerates away. Exits run about half an
                entrance's length: the user has already decided; making them
                watch it is rude.
       draw   — the Ring's stroke. Slope 0.25 at t=0 (a pen touching down, not
                a whip), quick through the middle, 0 at t=1 so the two ends
                ARRIVE at each other rather than collide. That is the trick.
     =========================================================================== */
  --ease-settle: cubic-bezier(0.16, 0.84, 0.44, 1);
  --ease-calm: cubic-bezier(0.32, 0.08, 0.16, 1);
  --ease-exit: cubic-bezier(0.4, 0, 0.68, 0.06);
  --ease-draw: cubic-bezier(0.32, 0.08, 0.12, 1);

  --dur-press: 90ms;        /* press-down; past ~100ms it reads as lag */
  --dur-micro: 220ms;       /* colour / state change (v1.1: 180 → 220, unhurried) */
  --dur-exit: 280ms;        /* anything leaving with travel (v1.1: 200 → 280) */
  --dur-ui: 380ms;
  --dur-fade: 360ms;        /* v1.1: an interaction surface fading IN — the mobile
                               menu, an FAQ answer, a Card step */
  --dur-fade-out: 220ms;    /* v1.1: the same surface fading OUT. Exits stay
                               shorter than entrances, always */
  --dur-page: 420ms;        /* v1.1: one page dissolving into the next */
  --dur-sheet: 480ms;       /* + --delay-sheet = 520ms (v1.1: 360 → 480, presented slowly) */
  --dur-scrim: 360ms;       /* v1.1: 200 → 360 */
  --dur-reveal: 760ms;      /* section reveal — OPACITY ONLY since v1.1; nothing
                               translates. Long enough to feel like settling in,
                               and the words are readable well before it ends */
  --dur-reveal-out: 380ms;  /* a section leaving the viewport: half the arrival */
  --dur-hairline: 760ms;    /* matches the reveal it rides on */
  --dur-ring: 700ms;        /* ambient Ring: quicker than the ceremonial one, */
  --dur-ring-close: 880ms;  /* so the confirmation reads as the bigger moment */

  --delay-sheet: 40ms;         /* the panel waits for the scrim */
  --delay-ring-close: 100ms;   /* the beat of stillness before "consider it arranged" */
  --stagger: 80ms;             /* max 4 steps, ever: 3 × 80 + 760 = 1000ms, the cap (v1.1: 60 → 80) */

  /* Reduced motion is a FIRST-CLASS path, not a degraded one:
     same information, same timing, less movement. Capped at 150ms by a11y §6. */
  --dur-reduced: 120ms;
  --dur-reduced-beat: 150ms;
}

@media (min-width: 48rem) { :root { --grid-cols: 8;  --grid-gap: var(--space-24); } }
@media (min-width: 64rem) { :root { --grid-cols: 12; --grid-gap: var(--space-32); } }

/* =============================================================================
   COMPATIBILITY ALIASES — DEPRECATED, DO NOT USE IN NEW CSS
   -----------------------------------------------------------------------------
   The first draft of this system used t-shirt space names and a few differently
   named colours, and templates/partials were written against them. The canonical
   scale above is named by px value because visual-direction §4 states it as
   "4/8/12/16/24/32/48/64/96/128" and an unambiguous name stops values being
   invented between steps.

   These aliases exist so nothing breaks during the changeover. They are one-line
   indirections with no cost. New rules must use the canonical names; a future
   sweep can delete this block once `grep -rn "space-2xs\|--text-muted" src/`
   comes back empty.
   ============================================================================= */
:root {
  --space-3xs: var(--space-4);
  --space-2xs: var(--space-8);
  --space-xs:  var(--space-12);
  --space-s:   var(--space-16);
  --space-m:   var(--space-24);
  --space-l:   var(--space-32);
  --space-xl:  var(--space-48);
  --space-2xl: var(--space-64);
  --space-3xl: var(--space-96);

  --ivory: var(--surface);
  --burgundy: var(--accent);
  --text: var(--ink);
  --text-muted: var(--ink-70);
  --on-dark-muted: var(--on-dark-70);
  --accent-text: var(--on-accent);
  --gold-line: var(--line-gold);
  --focus: var(--focus-light);
  --focus-on-dark: var(--focus-dark);
  --focus-on-accent: var(--on-accent);

  --tracking-caps: var(--tracking-label);
  --tracking-caps-tight: 0.08em;      /* bottom of the 0.08–0.14em band (§3.1) */
  --measure-narrow: var(--measure-tight);
  --content-narrow: var(--content-text);
  --section-y-legacy: var(--section-y);

  /* A third ivory step, below --surface, for quiet inset bands.
     MEASURED (tools/contrast.mjs): --ink on it 16.85:1; --ink-70 composites to
     #504F4B for 7.02:1, which clears the 7:1 floor — but only just: 70% is
     exactly the minimum alpha that clears it here, with no headroom at all.
     So this surface may carry secondary text, and must not be darkened further
     without re-running the script. --gold-text on it is 5.29:1 (still >= 4.5:1).
     Tone step vs --surface is 1.06:1, so an inset band always needs a hairline
     as well — the tone alone is not a boundary. */
  --surface-sunken: #f2ede1;
}

/* Forced-colours mode: hand the palette back to the user agent and keep only the
   structure. a11y §10 tests this mode explicitly. */
@media (forced-colors: active) {
  :root {
    --line: CanvasText;
    --line-strong: CanvasText;
    --line-on-dark: CanvasText;
    --line-gold: CanvasText;
    --field-line: CanvasText;
    --field-line-dark: CanvasText;
    --focus-light: Highlight;
    --focus-dark: Highlight;
    --focus-on-accent: Highlight;
  }
}

/* base.css */
/* =============================================================================
   London Massage Concierge — BASE
   Reset, element defaults, the type hierarchy, focus, layout primitives.
   Requires tokens.css. Nothing here names a typeface (see the TYPE TERRITORY
   block in tokens.css); everything goes through --font-display / --font-body.
   ============================================================================= */

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

html {
  -webkit-text-size-adjust: 100%;
  /* SC 2.4.11 / a11y §1.5 and §3: a focused element must never end up under the
     sticky header or the persistent action bar. Both are reserved here at layout
     time rather than patched by JS. --bar-height is set by .action-bar. */
  scroll-padding-top: var(--space-96);
  scroll-padding-bottom: calc(var(--bar-height) + var(--space-24));
  scroll-behavior: smooth;
  /* The Concierge Card locks background scroll while it is open. Without a
     stable gutter, removing the desktop scrollbar shifts the whole page
     sideways — a layout shift caused entirely by a dialog opening.
     motion-spec §5.5. */
  scrollbar-gutter: stable;
}

body {
  margin: 0;
  background: var(--surface);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--step-0);
  font-weight: var(--weight-body);
  line-height: var(--leading-body);
  font-synthesis-weight: none;    /* Marcellus has one weight; never fake a bold */
  font-kerning: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* a11y §3: the action bar must never permanently hide the last line of content. */
  padding-bottom: calc(var(--bar-height) + env(safe-area-inset-bottom, 0px));
  /* SC 1.4.10: nothing may cause a horizontal scrollbar at 320px. */
  overflow-x: hidden;
  overflow-wrap: break-word;
}

/* ---------------------------------------------------------------------------
   TYPE HIERARCHY — visual-direction §3.1
   The display face has ONE weight and no italic, so hierarchy comes from size,
   space and case, never from weight.

   THE 24px RULE, implemented literally: --font-display appears only on h1–h3,
   whose steps have a clamp minimum of >= --display-min (24px). h4 and below sit
   under that floor and therefore switch to the body face at medium weight.
   --------------------------------------------------------------------------- */

h1, h2, h3, h4, h5, h6 {
  margin: 0 0 var(--space-16);
  text-wrap: balance;             /* §3.4: no widows in headings */
}

h1, h2, h3 {
  font-family: var(--font-display);
  font-weight: 400;               /* the only weight it has */
  letter-spacing: var(--display-tracking);
  line-height: var(--leading-heading);
}

h1 { font-size: var(--step-5); line-height: var(--leading-display); }
h2 { font-size: var(--step-4); }
h3 { font-size: var(--step-3); }

/* Below 24px the display face is not permitted. Sentence-case sans, medium
   weight. NOTE: §3.1 says these carry "wide tracking", but the very next bullet
   restricts wide tracking (0.08–0.14em) to short UPPERCASE labels of three words
   or fewer. A sentence-case subheading is not that, so it takes a restrained
   --tracking-heading. Use .label / .eyebrow when the thing genuinely is a short
   uppercase label. (Documented in README as a resolved conflict inside §3.) */
h4, h5, h6 {
  font-family: var(--font-body);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-heading);
  line-height: var(--leading-heading);
}

h4 { font-size: var(--step-2); }
h5 { font-size: var(--step-1); }
h6 {
  font-size: var(--step--1);
  font-weight: var(--weight-strong);
  text-transform: uppercase;
  letter-spacing: var(--tracking-label);
}

p, li, dd, dt, figcaption, blockquote {
  text-wrap: pretty;              /* §3.4 */
}

p {
  margin: 0 0 var(--space-16);
  max-width: var(--measure);      /* §3.4 long-form measure 60–72ch */
}
p:last-child { margin-bottom: 0; }

ul, ol {
  margin: 0 0 var(--space-16);
  padding-left: 1.15em;
}
li + li { margin-top: var(--space-4); }
li { max-width: var(--measure); }

a {
  color: inherit;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
  text-decoration-color: var(--line-strong);
  transition: text-decoration-color var(--dur-micro) var(--ease-settle);
}
a:hover { text-decoration-color: currentColor; }

strong, b { font-weight: var(--weight-strong); }
em, i { font-style: italic; }     /* body face only; the display face has no italic */

small { font-size: var(--step--1); }

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

hr {
  border: 0;
  border-top: var(--hairline) solid var(--line);
  margin: var(--space-48) 0;
}

::selection { background: var(--gold); color: var(--ink); }

/* Real typographic punctuation is a copy rule (§3.4), but the two space
   characters the subset does NOT contain are worth naming so nobody reaches for
   them: U+2007 FIGURE SPACE and U+200A HAIR SPACE are absent from both webfonts
   (verified by tools/font-features.mjs) and would silently fall back. Use a
   normal space or a non-breaking space instead. */

/* ---------------------------------------------------------------------------
   FOCUS — a11y §1.5, visual-direction §8 line 1.
   Visible on EVERY surface. Never `outline: none`. >= 2px at >= 3:1 with a ~2px
   offset. Tied to the Ring motif: on ivory a compliant ink core with a fine gold
   hairline drawn just outside it (the "Ring echo", explicitly sanctioned by
   a11y §1.5 as decoration on top of a compliant core); on Soft Black the core is
   itself gold, which is the Ring's own colour, so no echo is needed.

   Built from box-shadow rather than a bare outline so the 2px gap is PAINTED
   with the surface colour. That is what keeps the indicator compliant on the
   burgundy Book Now button: ink ring against ivory gap is 17.92:1, and ivory gap
   against burgundy is 10.31:1, so both boundaries clear 3:1. A bare ink outline
   sitting directly on burgundy would be 1.74:1 and fail.

   The transparent `outline` is deliberate: in forced-colours mode box-shadows
   are dropped and the UA forces the outline to a system colour, so the indicator
   survives. It is not a disabled outline.
   --------------------------------------------------------------------------- */
:root {
  --focus-color: var(--focus-light);
  --focus-gap: var(--surface);
  --focus-echo: var(--line-gold);
}

a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible,
dialog:focus-visible,
[tabindex]:focus-visible,
[contenteditable]:focus-visible {
  outline: var(--focus-width) solid transparent;
  outline-offset: var(--focus-offset);
  box-shadow:
    0 0 0 var(--focus-offset) var(--focus-gap),
    0 0 0 calc(var(--focus-offset) + var(--focus-width)) var(--focus-color),
    0 0 0 calc(var(--focus-offset) + var(--focus-width) + 1px) var(--focus-echo);
}

/* Dark surfaces re-point the three properties; no rule is duplicated. */
.section--dark,
.on-dark,
.site-header,
.site-footer,
.hero--dark,
.action-bar {
  --focus-color: var(--focus-dark);
  --focus-gap: var(--surface-dark);
  --focus-echo: transparent;
}
.surface-dark-raised { --focus-gap: var(--surface-dark-raised); }
.surface-raised { --focus-gap: var(--surface-raised); }

/* Safari still paints a default ring on some controls before :focus-visible
   resolves; normalise without ever removing a keyboard indicator. */
:focus:not(:focus-visible) { outline: none; }

/* ---------------------------------------------------------------------------
   SKIP LINK — a11y §8, first focusable element on every page.
   --------------------------------------------------------------------------- */
.skip-link {
  position: absolute;
  top: 0;
  left: var(--space-16);
  z-index: 200;
  transform: translateY(-120%);
  display: inline-flex;
  align-items: center;
  min-height: var(--target);
  padding: var(--space-12) var(--space-24);
  background: var(--surface-dark);
  color: var(--on-dark);
  border-bottom: var(--hairline) solid var(--line-gold);
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-decoration: none;
  transition: transform var(--dur-micro) var(--ease-settle);
}
.skip-link:focus-visible {
  transform: translateY(0);
  --focus-gap: var(--surface-dark);
  --focus-color: var(--focus-dark);
  --focus-echo: transparent;
}

/* ---------------------------------------------------------------------------
   LAYOUT PRIMITIVES — visual-direction §4
   --------------------------------------------------------------------------- */

/* .wrap: max ~1200px, outer margins 24px mobile -> 64px+ desktop. */
.wrap {
  width: 100%;
  max-width: var(--content-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.wrap--text { max-width: calc(var(--content-text) + var(--gutter) * 2); }
.wrap--flush { padding-inline: 0; }

/* .grid: 4 columns at mobile, 8 at tablet, 12 at desktop. Children span with
   .col-N (mobile) and .col-t-N / .col-d-N at the two breakpoints. */
.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), minmax(0, 1fr));
  gap: var(--grid-gap);
}
.col-1 { grid-column: span 1; } .col-2 { grid-column: span 2; }
.col-3 { grid-column: span 3; } .col-4 { grid-column: span 4; }
@media (min-width: 48rem) {
  .col-t-2 { grid-column: span 2; } .col-t-3 { grid-column: span 3; }
  .col-t-4 { grid-column: span 4; } .col-t-5 { grid-column: span 5; }
  .col-t-6 { grid-column: span 6; } .col-t-8 { grid-column: span 8; }
}
@media (min-width: 64rem) {
  .col-d-3 { grid-column: span 3; } .col-d-4 { grid-column: span 4; }
  .col-d-5 { grid-column: span 5; } .col-d-6 { grid-column: span 6; }
  .col-d-7 { grid-column: span 7; } .col-d-8 { grid-column: span 8; }
  .col-d-9 { grid-column: span 9; } .col-d-12 { grid-column: span 12; }
}

/* Stacks. "Whitespace is the material: when in doubt, add space rather than a
   divider" (§4). */
.stack > * + * { margin-top: var(--space-16); }
.stack-24 > * + * { margin-top: var(--space-24); }
.stack-32 > * + * { margin-top: var(--space-32); }
.stack-48 > * + * { margin-top: var(--space-48); }

/* ---------------------------------------------------------------------------
   TYPE UTILITIES
   --------------------------------------------------------------------------- */

/* Short uppercase label, three words or fewer — the ONLY place wide tracking is
   permitted (§3.1). On ivory it is --gold-text, never --gold (1.87:1). */
.eyebrow,
.label {
  display: block;
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--gold-text);
  margin: 0 0 var(--space-12);
}
.label--wide { letter-spacing: var(--tracking-label-wide); }
.section--dark .eyebrow,
.section--dark .label,
.on-dark .eyebrow,
.on-dark .label { color: var(--gold); }   /* 9.58:1 on Soft Black */

.lede {
  font-size: var(--step-1);
  line-height: 1.5;
  max-width: var(--measure-tight);
}

.muted { color: var(--ink-70); }
.section--dark .muted,
.on-dark .muted { color: var(--on-dark-70); }

.meta { font-size: var(--step--1); color: var(--ink-70); }
.section--dark .meta, .on-dark .meta { color: var(--on-dark-70); }

/* Centred type is for short ceremonial moments only — never a paragraph over two
   lines (§4). The measure cap enforces it rather than trusting discipline.

   CQ-01: the cap lives on the CHILDREN, not on this wrapper. A single cap here
   cannot serve both, because `ch`/`em` resolve against each element's own
   font-size: this div inherits body size, its headings are display size, and
   one value is therefore wrong for one of them. Capping per child lets each
   measure resolve against the type it is actually measuring.

   The wrapper keeps centring and no width of its own, which leaves the three
   button-only ceremonial blocks (about.mjs, treatments.mjs, faq.mjs) visually
   unchanged — a button is centred by `text-align: center` regardless of how
   wide its container is allowed to be. */
.ceremonial {
  text-align: center;
  margin-inline: auto;
}
.ceremonial > :is(h1, h2, h3) {
  max-width: var(--measure-ceremonial-display);
  margin-inline: auto;
}
.ceremonial > :is(p, ul, ol, dl) {
  max-width: var(--measure-ceremonial);
  margin-inline: auto;
}

/* §4 permits centred prose only up to two lines. Some copy is inherently
   longer than that — the direct-visit explanation on /payment-confirmed/ ran to
   three centred lines at 1440 and six at 375 — and the answer is to stop
   centring it, not to squeeze it. Set left, at a reading measure, still
   centred as a block.

   The heading above it stays ceremonial, and the split is deliberate rather
   than a concession: the H1 is the moment, this is the instruction, and they
   should not be typeset as the same kind of thing. */
.ceremonial .prose-left {
  text-align: left;
  max-width: var(--measure-tight);
  margin-inline: auto;
}

.nowrap { white-space: nowrap; }

/* Tabular lining figures for every price (§3.1, §8 line 3). The body face's
   digits are natively monospaced, so this declares intent and protects the rule
   if the type territory is ever swapped for a face with proportional figures. */
/* NB: hook on the price classes, never on a mock marker attribute. Those markers
   vanish in live mode, so styling on one silently drops the tabular figures at
   exactly the moment the real prices ship. The build fails on that now. */
.tabular,
.price,
.treatment__from {
  font-variant-numeric: tabular-nums lining-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
}

.visually-hidden:not(:focus):not(:active) {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ---------------------------------------------------------------------------
   SECTIONS — §4. Alternate ivory and dark sparingly: at most two dark sections
   per page, and the header and footer already count as dark (§2.3).
   --------------------------------------------------------------------------- */
.section { padding-block: var(--section-y); }
.section--tight { padding-block: var(--section-y-tight); }

/* R-CD-09: warmed toward --accent at the bottom edge, same recipe the hero
   already shipped (components.css §Hero) — hoisted to --surface-dark-warm so
   every dark content section shares one definition instead of repeating the
   color-mix(). Top stop is unchanged (--surface-dark) so this is invisible on
   a section that is dark for only a sliver of its height. Measured: on-dark
   17.32:1, on-dark-70 8.59:1, gold 9.26:1 against the warmed stop — see
   tokens.css ATMOSPHERE. Header and footer are separate rules and are
   deliberately NOT included (R-CD-06a: they are constant chrome). */
.section--dark {
  background: var(--surface-dark);
  background: linear-gradient(180deg, var(--surface-dark) 0%, var(--surface-dark-warm) 100%);
  color: var(--on-dark);
}
.section--raised { background: var(--surface-raised); }

/* R-CD-09: the gold-warmed counterpart to --raised, for a section that should
   carry real atmosphere rather than just a lift. Top stop matches flat
   --surface so it blends into whatever precedes it; the wash concentrates
   toward the bottom, which is where imagery/image-slot content typically
   sits in the sections this is meant for. Text-safe throughout for full
   --ink and headings; keep .muted/.meta (--ink-70) content in the upper half
   of the section, since only the top stop is measured to the 7:1 AAA floor
   (see tokens.css ATMOSPHERE — --surface-warm vs --surface-warm-deep). */
.section--warm {
  background: linear-gradient(180deg,
    var(--surface) 0%,
    var(--surface-warm) 55%,
    var(--surface-warm-deep) 100%);
}

/* A hairline may separate sections instead of a colour change (§4). */
.section--ruled { border-top: var(--hairline) solid var(--line); }
.section--dark.section--ruled { border-top-color: var(--line-gold); }

/* Image slots are designed spaces with a defined ratio, never grey boxes, and
   they collapse cleanly when empty (§6.3, retained as an engineering
   requirement by R-CD-01's amendment). :empty is what makes that automatic. */
.image-slot {
  aspect-ratio: 4 / 5;
  background: none;
  border: 0;
  margin: 0;         /* figure's UA default is 1em 40px — found 14 Sep 2026, images
                         sitting ~40px in from the text column instead of flush */
  overflow: hidden;  /* required by object-fit: cover below — without it a source
                         image narrower than the box is centred and clipped by
                         nothing, so it just sits inside the reserved box at its
                         own size instead of being cropped to fill it. */
}
.image-slot:empty { display: none; }
.image-slot--wide { aspect-ratio: 3 / 2; }
/* DESIGN ANOMALY, found 16 Sep 2026: removing the padding *after* an image
   (below) left the space *before* it exposed as what it always was: zero.
   .image-slot's own `margin: 0` plus .wrap's own `margin: 0` meant a
   paragraph or a Book Now button sat flush against the top edge of a
   full-bleed photograph with no gap at all — text touching a photograph
   reads as broken, not deliberate, on both the text-heavy opener sections
   (treatments, how-it-works, join-our-team, home) and the CTA-only closer
   sections (prices, faq, treatments, how-it-works). `.wrap + .image-slot`
   catches exactly those cases and only those: about-opener and the three
   home context photos are nested INSIDE their .wrap, not a sibling of it, so
   they are unaffected and keep the spacing their own stack/grid gap already
   gives them; contact's image-only closer has no preceding .wrap either. */
.wrap + .image-slot { margin-top: var(--space-48); }
/* DESIGN ANOMALY, found 16 Sep 2026: no .section has a max-width, so a
   full-bleed image is expected to cover the ENTIRE viewport width on any
   screen — but the generic `img { max-width: 100% }` rule (base.css) only
   ever shrinks an image to fit its box, never grows one to fill it. Every
   image here ships at a fixed native width (1024-1376px); on any viewport
   wider than that, the image renders at its own intrinsic size and sticks to
   the left edge, leaving the rest of the section's background exposed on the
   right. Not new to today's images — the site's very first full-bleed asset
   (1376px) already has this latent gap on any screen over ~1376px CSS
   pixels; today's narrower replacements just brought the threshold down to
   an ordinary laptop width, which is what made it visible. Fixed at the
   container rather than by chasing ever-larger source photography: the
   image now always fills the reserved aspect-ratio box exactly, cropping
   losslessly via object-fit rather than leaving empty space. */
.image-slot img { width: 100%; height: 100%; object-fit: cover; }

/* DESIGN ANOMALY, found 16 Sep 2026: a full-bleed image is always the LAST
   child of its .section (every current imageSlot() call sites this way), so
   .section's own padding-block adds a full --section-y of plain background
   AFTER the image before the section technically ends — and then the NEXT
   section adds its own --section-y on top of that before its own content
   starts. Two paddings stack into one dead band with nothing in it: measured
   278px on desktop, 154px on mobile, on every page that opens or closes on
   one of these images (home, about, treatments, how-it-works, join-our-team,
   prices, faq, contact). The image itself already reads as the section's
   hard edge, so it does not need its own section's padding as well — the
   following section's own top padding (or the footer's, on a closing image)
   is enough on its own. Scoped to :last-child so a section that puts more
   content after its image (none does today) is unaffected. */
.section:has(> .image-slot:last-child) { padding-bottom: 0; }

/* ---------------------------------------------------------------------------
   MOTION — a11y §6, docs/motion/motion-spec.md §9

   THE PRINCIPLE: same information, same timing, less movement.

   Reduced motion is a request to remove MOVEMENT, not to remove design. The
   evidence is specific: vestibular responses are provoked by translation,
   scale, rotation and parallax — by things that appear to move through space.
   Opacity change moves nothing and is comfortable. So the reduced path keeps
   every beat, every sequence and every piece of state feedback, and expresses
   them with a short cross-fade instead of travel. Nobody gets a dead site
   because they asked not to be made dizzy.

   This is deliberately NOT the usual blanket `*{animation-duration:1ms
   !important}`. That rule kills the reduced path's own cross-fades along with
   everything else, and the !important makes every intentional exception below
   unreachable. Each component declares its own reduced behaviour instead;
   the full table is motion-spec §9.
   --------------------------------------------------------------------------- */
/* Section reveals — motion-spec §4.4.
   DEFAULT IS VISIBLE. The pre-reveal state is scoped to [data-motion="on"], an
   attribute the motion script sets on <html>. No script, or a script that
   errors, means no attribute, means every section is simply present and
   readable. There is no state in which content is hidden and nothing is coming
   to show it.

   NO TEXT EVER STARTS AT opacity: 0 ABOVE THE FOLD. LCP does not register a
   transparent element, so fading a heading up adds the delay plus the duration
   to the metric wholesale. Two mechanisms keep this true: the motion script
   marks anything already on screen at load as [data-reveal="done"] (shown with
   no transition at all), and the build fails if data-reveal appears inside the
   hero partial. */
/* v1.1 (motion-spec addendum, 16 Sep 2026): reveals are OPACITY ONLY. Nothing
   translates — a section arrives by becoming present, not by moving into
   place — and the reveal now runs BOTH WAYS: a block that has left the
   viewport fades out (off screen, or in the outermost 6% of it) and re-arms,
   so it fades in again the next time the reader reaches it. The transition
   that runs is the one on the state being entered: 760ms in, 380ms out. */
[data-reveal] {
  transition: opacity var(--dur-reveal) var(--ease-calm);
}
/* KNOWN FRAGILITY — THE ONE-LINE FIX IS BELOW, AND IT IS NOT MINE TO LAND ALONE.

   This selector is the only thing on the site that hides content, and it is
   gated on [data-motion], which a synchronous inline <head> script sets. The
   code that CLEARS it is a separate <script type="module"> at the end of <body>.
   Two scripts, and only the second one can fail. Four routes to permanently
   invisible content:
     1. a syntax error in the module (a module that fails to parse never runs)
     2. a CSP added later without a hash or nonce for it
     3. any runtime throw before the observer is attached
     4. no IntersectionObserver — the module's own `'IntersectionObserver' in
        window` guard SKIPS observing, which leaves everything hidden. The guard
        was written to be defensive and produces the worst outcome available.

   Renaming the flag (html.js, [data-js], etc.) fixes none of this: the head
   script always runs, so any head-set flag has exactly this failure mode. The
   fix is ATOMICITY — the code that hides must be the code that shows, in one
   script, so a parse failure disables both halves together.

   WHEN motion/frontend move the arming into the same script as the observer and
   have it set `document.documentElement.dataset.reveal = 'armed'` synchronously
   in <head> (removing the attribute in a catch), change the two selectors here
   and the three in components.css §G8 from
       [data-motion="on"] [data-reveal=""]
   to
       [data-reveal="armed"] [data-reveal=""]
   and nothing else. Both changes must land in the same commit: flipping this
   alone silently disables every reveal, and flipping the script alone hides
   nothing. Until then, components.css §G8 keeps Book Now and every [data-legal]
   block out of reach of this rule. */
[data-motion="on"] [data-reveal=""] {
  opacity: 0;
  transition-duration: var(--dur-reveal-out);   /* leaving: half the arrival */
}
[data-reveal="in"],
[data-reveal="done"] { opacity: 1; }
[data-reveal="done"] { transition: none; }   /* was already on screen at load */

/* Staggered groups: up to FOUR direct children, never more.
   Never a price list or a treatment list — those exist to be compared, and
   delaying the fourth price by 180ms delays the one comparison the customer
   came to make (motion-spec §4.5, NN-07, R-19). */
[data-reveal-group] > * {
  transition: opacity var(--dur-reveal) var(--ease-calm);
  transition-delay: calc(var(--stagger) * var(--i, 0));
}
/* v1.1: the children carry their own pre-state, otherwise the delay above
   has nothing to delay (the group's opacity used to change only on the
   wrapper, so the stagger never showed). Scoped to the same G8 exclusions as
   the wrapper, so a block that must never hide still never hides. Leaving:
   short and unstaggered, since it runs off screen. 3 × 80 + 760 = 1000ms,
   exactly the North Star §4.6 cap. */
[data-motion="on"] [data-reveal=""]:not(:has(.btn--primary, .action-bar, [data-legal])) [data-reveal-group] > * {
  opacity: 0;
  transition-duration: var(--dur-reveal-out);
  transition-delay: 0s;
}
[data-reveal="done"] [data-reveal-group] > * { transition: none; }

/* Full-bleed image reveal — motion-spec.md §4.8 (R-CD-10). A full-bleed
   .image-slot sits as a SIBLING of .wrap, not nested inside it, so it is a
   second, independent [data-reveal] target in its section rather than a
   subdivision of the text reveal above — §4.5's "one per section" is written
   for text and does not reach it. Since v1.1 a photograph fades exactly as
   text does — the rules above already cover it and there is no separate
   amplitude because nothing translates. Never scale, never blur/filter —
   both are banned outright (motion-spec §8.2, §6.4). */

/* Cross-document navigation — v1.1. One page dissolves into the next over
   --dur-page instead of cutting. No JavaScript, no snapshot at any moment
   other than the navigation itself, and a browser without cross-document
   View Transitions ignores the whole block and navigates as it always has.
   The same-document handoff rejection in motion-spec §10.1 still stands:
   this is navigation only. */
@view-transition { navigation: auto; }
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--dur-page);
  animation-timing-function: var(--ease-calm);
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  @view-transition { navigation: none; }

  /* Global movement primitives. Colour and focus are untouched on purpose:
     a colour change is not motion, and a11y §1.4 requires the state to stay
     distinct. The focus ring was already instant and stays that way.

     No !important anywhere in here, deliberately: source order and specificity
     already do the work, and !important is the exact property that made the
     blanket rule above impossible to make exceptions to. The pre-reveal rule
     below matches the one it overrides at (0,2,0) and simply comes later. */
  [data-reveal] { transition: opacity var(--dur-reduced) var(--ease-settle); }
  [data-motion="on"] [data-reveal=""] { transition-duration: var(--dur-reduced); }
  [data-reveal-group] > *,
  [data-motion="on"] [data-reveal=""]:not(:has(.btn--primary, .action-bar, [data-legal])) [data-reveal-group] > * {
    transition-duration: var(--dur-reduced);
  }
  .skip-link { transition: none; }
}

/* ---------------------------------------------------------------------------
   LEGACY CLASS ALIASES — templates were written against these names.
   Kept so the changeover to the canonical vocabulary breaks nothing.
   --------------------------------------------------------------------------- */
.wrap--narrow { max-width: calc(var(--content-text) + var(--gutter) * 2); }
.section--sunken { background: var(--surface-sunken); }
/* An inset band's tone step is 1.06:1, so it always carries a hairline too —
   the tone alone is never the boundary (§8 line 6). */
.section--sunken {
  border-top: var(--hairline) solid var(--line);
  border-bottom: var(--hairline) solid var(--line);
}
.small { font-size: var(--step--1); }
/* .center is for short ceremonial moments only. It caps its own measure so a
   paragraph longer than two lines cannot be centred by accident (§4). */
.center { text-align: center; margin-inline: auto; max-width: var(--measure-ceremonial); }

/* SC 1.4.12 Text spacing: nothing may clip or overlap when a user forces
   line-height x1.5, paragraph spacing x2, letter-spacing 0.12em and
   word-spacing 0.16em. The rule is "never set a fixed height on a text box" —
   min-height only, everywhere, which the components file honours. */

/* components.css */
/* Components — see docs/art-direction/visual-direction.md and docs/a11y/accessibility-requirements.md */

/* ============ Buttons ============ */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2xs);
  min-height: var(--target);
  padding: 0.8rem 1.6rem;
  font-family: var(--font-body);
  font-size: var(--step-0);
  font-weight: 600;
  letter-spacing: 0.01em;
  text-decoration: none;
  /* A button label is a name, not prose. "Book Now" was breaking to two lines
     in the header at every desktop width — §8 wants it visibly the single most
     prominent action on the page, and a two-line label in a box is not that.
     Same cause as the nav labels: `min-width: 0` on the header's flex children
     lets a button shrink below its content, and the text wraps to fill it. */
  white-space: nowrap;
  border: var(--hairline) solid transparent;
  border-radius: var(--radius);
  cursor: pointer;
  /* Colour only. Transitioning a colour is paint work on a button-sized area at
     180ms — permitted by motion-spec §6.2. Never add box-shadow, filter or any
     layout property to this list. */
  transition: background-color var(--dur-micro) var(--ease-settle),
    color var(--dur-micro) var(--ease-settle),
    border-color var(--dur-micro) var(--ease-settle);
}

/* Press: 1px of travel, 90ms in, --dur-micro out. Fast in, slower out — press
   feedback slower than ~100ms reads as lag, and lag on Book Now is the worst
   perception failure available to us. 1px, not a scale: a 0.97 scale on a 44px
   button moves its edges 0.66px and blurs its text for the duration. This is
   the key-travel of a well-made keyboard, which is the right register.
   motion-spec §6.3. */
.btn:active { transform: translateY(1px); transition-duration: var(--dur-press); }

/* Primary = the moment of commitment. Burgundy on light, ivory on dark (never burgundy on black). */
.btn--primary {
  background: var(--accent);
  color: var(--accent-text);
}
.section--dark .btn--primary,
.site-header .btn--primary {
  background: var(--ivory);
  color: var(--ink);
}

.btn--secondary {
  background: transparent;
  color: var(--text);
  border-color: var(--line-strong);
}
.section--dark .btn--secondary { color: var(--on-dark); border-color: var(--line-on-dark); }

/* Hover is gated on a real pointer. On touch, :hover sticks after a tap and
   leaves the last-pressed button looking permanently active — and most of this
   site's traffic is touch. motion-spec §6.2. */
@media (hover: hover) and (pointer: fine) {
  .btn--primary:hover { background: var(--accent-hover); }
  .section--dark .btn--primary:hover,
  .site-header .btn--primary:hover { background: #fff; }
  .btn--secondary:hover { border-color: var(--ink); }
  .section--dark .btn--secondary:hover { border-color: var(--gold); }
}

.btn--quiet {
  background: none;
  border: 0;
  padding: 0.5rem 0;
  min-height: var(--target);
  font-weight: 600;
  color: var(--gold-text);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}
.section--dark .btn--quiet { color: var(--gold); }

.link-more {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  min-height: var(--target);
  font-weight: 600;
  font-size: var(--step--1);
  letter-spacing: var(--tracking-caps-tight);
  text-transform: uppercase;
  color: var(--gold-text);
  text-decoration: none;
  border-bottom: var(--hairline) solid var(--gold-line);
  padding-bottom: 2px;
}
.section--dark .link-more { color: var(--gold); }
@media (hover: hover) and (pointer: fine) {
  .link-more { transition: color var(--dur-micro) var(--ease-settle),
                           border-color var(--dur-micro) var(--ease-settle); }
  .link-more:hover { color: var(--ink); border-color: var(--ink); }
  .section--dark .link-more:hover { color: var(--on-dark); border-color: var(--on-dark); }
}

/* ============ Motifs ============ */
/* The Ring: the monogram's circle. One per section at most. */
.ring {
  width: 3.5rem; height: 3.5rem;
  display: grid; place-items: center;
  border: var(--hairline) solid var(--gold-line);
  border-radius: 50%;
  font-family: var(--font-display);
  font-size: var(--step-1);
  color: var(--gold-text);
  flex: none;
}
.section--dark .ring { color: var(--gold); }

/* The Star: divider/marker only. Never in rows of five, never near ratings. */
.rule-star {
  display: flex;
  align-items: center;
  gap: var(--space-s);
  margin-block: var(--space-l);
  color: var(--gold-line);
}
.rule-star::before,
.rule-star::after {
  content: "";
  flex: 1;
  height: var(--hairline);
  background: currentColor;
  opacity: 0.55;
}
.rule-star svg { width: 12px; height: 12px; flex: none; }

/* ============ Header ============ */
.site-header {
  background: var(--surface-dark);
  color: var(--on-dark);
  position: relative;
  z-index: 20;
}
.site-header__inner {
  display: flex;
  align-items: center;
  gap: var(--space-m);
  min-height: 4.5rem;
  padding-block: var(--space-2xs);
}
.brand-link { display: inline-flex; align-items: center; text-decoration: none; flex: none; }
.brand-lockup { display: block; height: 2.6rem; width: auto; }
@media (min-width: 75rem) { .brand-lockup { height: 3rem; } }

/* ---- THE HEADER MODE SWITCH: 75rem, and all SIX rules must move together ----
   `.brand-lockup` height · `.nav` · `.header__cta` · `.nav-toggle` ·
   `.mobile-nav` · `.action-bar`. They are one decision expressed six times
   (CSS cannot use a custom property in a media query). Change one alone and you
   get a width band with NO navigation at all — nav hidden and toggle hidden
   together — so grep `75rem` and change every hit or none.

   CQ-02, final cause. This was 60rem, and the desktop nav simply does not fit
   at 960px: seven items in the R-06 order, plus the brand lockup, plus a Book
   Now that §8 requires to be the most prominent action. Measured after the
   other fixes, "Contact" still overlapped the CTA by 74px at 960 and 63px at
   1024 — and because overflow is not clipped, those were the glyphs themselves
   painting under the button, with `elementFromPoint` returning `a.btn`. The
   links were not merely ugly, they were NOT CLICKABLE: clicking Contact opened
   the booking dialog. The row fits from ~1180px, so the switch moves to 75rem
   and the mobile menu — which already works — owns everything below it.
   That is the honest answer rather than shrinking the nav until it technically
   fits, which is what produced the wrap this fix replaced. */
.nav { margin-left: auto; display: none; }
@media (min-width: 75rem) { .nav { display: block; } }
/* CQ-02, second cause. `li + li { margin-top: var(--space-4) }` in base.css is
   a prose-list rule, and it applied here too: every nav item EXCEPT the first
   picked up 4px of top margin, which is why "Treatments" sat at top=92 and the
   other six at 96. The list resets list-style, margin and padding but never
   reset that, and a 4px step on a single item reads as a misalignment rather
   than as spacing. Gap owns the spacing in a flex row; margins do not. */
.nav__list { display: flex; gap: var(--space-s); list-style: none; margin: 0; padding: 0; }

/* Every HORIZONTAL list row, not just the nav. In a flex row a top margin does
   not add spacing, it displaces the item vertically, so the rule above put each
   of these rows' items on two baselines. `gap` already owns the spacing here.
   Vertical lists are deliberately left alone: there the 4px is real spacing and
   removing it would be a visual change, not a fix. */
.nav__list > li,
.hero__signals > li,
.treatment__durations > li { margin-top: 0; }
.nav__link {
  display: inline-flex; align-items: center;
  min-height: var(--target);
  font-size: var(--step--1);
  letter-spacing: var(--tracking-caps-tight);
  text-transform: uppercase;
  text-decoration: none;
  color: var(--on-dark-muted);
  /* CQ-02. A nav label is a name, not prose: "HOW IT WORKS" breaking to
     "HOW IT / WORKS" left three items on three different baselines at every
     desktop width, including 1440 where the nav occupied 704px of a 1200px
     container with 324px to spare. It was never a space problem — the
     `min-width: 0` overflow guard on `.site-header__inner > *` (G-guard, below)
     lets these flex children shrink below their content, and the text wrapped
     to fill the shrink rather than the row staying at its intrinsic width.
     Pinning the labels removes the shrink target; the guard still does its real
     job of preventing a document wider than the viewport. */
  white-space: nowrap;
}
@media (hover: hover) and (pointer: fine) {
  .nav__link { transition: color var(--dur-micro) var(--ease-settle); }
  .nav__link:hover { color: var(--on-dark); }
}
.nav__link[aria-current="page"] {
  color: var(--on-dark);
  border-bottom: var(--hairline) solid var(--gold-line);
}

.header__cta { display: none; }
@media (min-width: 75rem) { .header__cta { display: inline-flex; margin-left: var(--space-s); } }

.nav-toggle {
  margin-left: auto;
  display: inline-flex; align-items: center; gap: 0.5em;
  min-height: var(--target); min-width: var(--target);
  padding: 0 var(--space-2xs);
  background: none; border: 0; cursor: pointer;
  color: var(--on-dark);
  font-family: var(--font-body); font-size: var(--step--1);
  letter-spacing: var(--tracking-caps-tight); text-transform: uppercase;
}
@media (min-width: 75rem) { .nav-toggle { display: none; } }
.nav-toggle__bars { display: block; width: 20px; height: 10px; position: relative; }
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  content: ""; position: absolute; left: 0; right: 0; height: var(--hairline); background: currentColor;
  transition: transform var(--dur-fade) var(--ease-calm);
}
.nav-toggle__bars::before { top: 0; }
.nav-toggle__bars::after { bottom: 0; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::before { transform: translateY(4.5px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::after { transform: translateY(-4.5px) rotate(-45deg); }

/* v1.1: the menu fades in and out instead of snapping. `hidden` is still the
   switch app.js throws; `display` transitions discretely so the EXIT can run
   at all, and @starting-style gives the entrance its opacity-0 first frame. A
   browser without either simply snaps — the reduced-motion path, not a bug.
   Opacity only: the menu does not slide (motion-spec v1.1 addendum). */
.mobile-nav {
  background: var(--surface-dark); border-top: var(--hairline) solid var(--line-on-dark);
  opacity: 1;
  transition: opacity var(--dur-fade) var(--ease-calm),
    display var(--dur-fade) allow-discrete;
}
.mobile-nav[hidden] {
  display: none;
  opacity: 0;
  transition: opacity var(--dur-fade-out) var(--ease-calm),
    display var(--dur-fade-out) allow-discrete;
}
@starting-style { .mobile-nav:not([hidden]) { opacity: 0; } }
.mobile-nav__list { list-style: none; margin: 0; padding: var(--space-2xs) 0 var(--space-m); }
.mobile-nav__list a {
  display: flex; align-items: center; min-height: var(--target);
  padding: var(--space-2xs) 0;
  color: var(--on-dark); text-decoration: none;
  font-size: var(--step-1);
  border-bottom: var(--hairline) solid var(--line-on-dark);
}
@media (min-width: 75rem) { .mobile-nav { display: none; } }

/* ============ Hero ============ */
.hero {
  background: var(--surface-dark);
  color: var(--on-dark);
  padding-block: clamp(2.5rem, 1.5rem + 6vw, 6rem);
  position: relative;
  overflow: hidden;
}
.hero__inner { display: grid; gap: var(--space-m); }
.hero__title { margin: 0; }
.hero__contexts {
  font-family: var(--font-display);
  font-size: var(--step-2);
  color: var(--gold);
  margin: 0;
  letter-spacing: var(--tracking-caps-tight);
}
.hero__contexts .dot { color: var(--on-dark-muted); padding-inline: 0.35em; }
.hero__support { font-size: var(--step-1); max-width: var(--measure-narrow); margin: 0; color: var(--on-dark); }
.hero__actions { display: flex; flex-wrap: wrap; gap: var(--space-s); align-items: center; }
.hero__signals {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem var(--space-s);
  margin: 0; padding: 0; list-style: none;
  font-size: var(--step--1); color: var(--on-dark-muted);
}
.hero__signals li { display: flex; align-items: center; gap: 0.5em; }
.hero__signals li + li::before {
  content: ""; width: 3px; height: 3px; border-radius: 50%;
  background: var(--gold); display: inline-block; margin-right: 0.5em;
}

/* ============ Process (four steps) ============ */
.process { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-l); }
@media (min-width: 48rem) { .process { grid-template-columns: repeat(4, 1fr); gap: var(--space-m); } }
.process__step { display: grid; gap: var(--space-2xs); }
.process__ring { margin-bottom: var(--space-2xs); }
.process__label {
  font-size: var(--step--1); letter-spacing: var(--tracking-caps);
  text-transform: uppercase; color: var(--gold-text); margin: 0;
}
.section--dark .process__label { color: var(--gold); }
.process__line { font-family: var(--font-display); font-size: var(--step-2); margin: 0; }
.process__support { margin: 0; font-size: var(--step--1); color: var(--text-muted); }
.section--dark .process__support { color: var(--on-dark-muted); }

/* ============ Contexts (home / hotel / office) ============ */
.contexts { display: grid; gap: var(--space-l); }
@media (min-width: 48rem) { .contexts { grid-template-columns: repeat(3, 1fr); } }
.context { border-top: var(--hairline) solid var(--line); padding-top: var(--space-s); }
.section--dark .context { border-color: var(--line-on-dark); }
.context h3 { margin-bottom: var(--space-2xs); }

/* ============ Meet the Team ============ */
/* STATUS 16 Sep 2026: rebuilt from a 2–3 column card grid to a single-column
   list of rows, larger photo beside the name and bio, on direct client
   instruction ("larger images... beside it their name and description").
   Still a hairline list rather than a boxed card (visual-direction §4:
   "reads better on a phone and looks more expensive") — the hairline top
   edge is the only container, same discipline as .contexts/.treatment. */
/* FOUND 16 Sep 2026: with no grid-template-columns, a grid's single implicit
   column sizes to its content's max-content width rather than the
   container's full width — measured shrinking every row to ~580px inside an
   ~1100px wrap, which in turn squeezed .team-row__content to 228px and wrapped
   the bio down to short, cramped lines. Exact shape of the bug .treatment's
   own comment already documents ("an explicit fr track so a long name/price
   pair can never force this wider than its container") — same fix. */
.team-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-xl); }
.team-row {
  border-top: var(--hairline) solid var(--line);
  padding-top: var(--space-l);
  display: grid;
  gap: var(--space-m);
  /* base.css's bare `li { max-width: var(--measure) }` (a prose-list-width
     rule, 62ch) caps every <li> including this one — measured shrinking a
     1088px available row to 582px, which is what actually squeezed
     .team-row__content down to 228px, not the photo's own width. Exact bug
     .treatment's own "AESTHETIC FIX" comment already documents and fixes
     the same way. */
  max-width: none;
}
.section--dark .team-row { border-color: var(--line-on-dark); }
/* Photo above content until there is room for both side by side — a photo
   this size squeezed into a narrow phone column beside a paragraph would
   leave the text column too narrow to read comfortably. */
@media (min-width: 40rem) {
  .team-row { grid-template-columns: auto 1fr; align-items: start; }
}
.team-row__photo {
  display: block;
  width: 100%; max-width: 20rem;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: var(--radius);
}
@media (min-width: 40rem) { .team-row__photo { width: clamp(15rem, 27vw, 23rem); max-width: none; } }
/* Gold is a line, never a fill (§2.3) — a hairline ring around ink-on-ivory
   initials, not a solid gold disc, filling the SAME box a real photo would
   so a mixed list of some-photographed, some-not therapists stays one
   consistent shape rather than a small circle beside a full photograph.
   Decorative only: aria-hidden on the element itself (meet-the-team.mjs),
   the name right beside it is the real, accessible identifier. */
.team-row__photo--fallback {
  display: flex; align-items: center; justify-content: center;
  border: var(--hairline) solid var(--gold-line);
  font-family: var(--font-display);
  font-size: var(--step-4);
  color: var(--gold-text);
}
.section--dark .team-row__photo--fallback { color: var(--gold); }
.team-row__content { display: grid; gap: var(--space-2xs); align-content: start; }
.team-row__name { margin: 0; }
.team-row__tags { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--space-3xs); }
.team-row__bio { margin: 0; color: var(--text-muted); max-width: var(--measure); }
.team-row__bio + .team-row__bio { margin-top: var(--space-2xs); }
.section--dark .team-row__bio { color: var(--on-dark-muted); }
/* A pill outline, same "line, not fill" rule as the avatar ring. Reuses
   --step--1 and the label letter-spacing already established for
   .process__label, so a specialty tag and a process step read as the same
   typographic register rather than inventing a second one. */
.tag {
  display: inline-flex; align-items: center;
  padding: 0.2em 0.65em;
  border: var(--hairline) solid var(--line-strong);
  border-radius: var(--radius);
  font-size: var(--step--1);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--ink-70);
}
.section--dark .tag { border-color: var(--line-on-dark); color: var(--on-dark-70); }

/* ============ Treatments & prices ============ */
/* A hairline list, not a card grid: reads better on a phone and looks more expensive (visual-direction §4) */
.treatment-list { list-style: none; margin: 0; padding: 0; }
.treatment {
  border-top: var(--hairline) solid var(--line);
  padding-block: var(--space-m);
  display: grid;
  grid-template-columns: minmax(0, 1fr);   /* belt: an explicit fr track so a
    long name/price pair can never force this wider than its container. */
  gap: var(--space-2xs);
  scroll-margin-top: 6rem;
  /* AESTHETIC FIX, 15 Sep 2026: the real cause of the actual bug. base.css's
     bare `li { max-width: var(--measure) }` (a prose-list-width rule, 62ch)
     was capping every .treatment at ~590px regardless of viewport — on
     /prices, the site's main commercial page, that left roughly HALF the
     1200px content column sitting empty at desktop widths, and kept
     .treatment__leader's flex:1 dotted rule from ever stretching to meet the
     price. .price-rows > li already carries this exact override a few
     hundred lines down; .treatment never got the same fix. */
  max-width: none;
}
.treatment:last-child { border-bottom: var(--hairline) solid var(--line); }
/* CQ-04. This was `flex-wrap: wrap` with `margin-left: auto` on the price, and
   it did not produce a column: the six homepage prices landed on six different
   right edges, a 145px spread at 1440, under full-width hairlines that made the
   raggedness obvious. `margin-left: auto` only reaches the right edge when the
   row is full width, and a wrapping row is free to shrink to its content.

   The fix is the one the system already had. `/prices/` solves this exactly
   once, correctly, with a `flex: 1` leader between label and figure — so the
   answer is to use that component here rather than invent a second treatment.
   Getting this right at 320px took two wrong turns, both worth recording.
   Dropping `flex-wrap: wrap` (to match `.price-row`) caused a WCAG 1.4.10
   reflow failure: `.price` is `white-space: nowrap` with an ~86px intrinsic
   width because of its unit label ("from £95 / per person"), flex squeezed it
   to 59px, and it spilled 27px past its box and scrolled the document.
   Restoring the wrap fixed that and broke the column instead — at 375 the long
   names pushed the price onto its own line and the right edges scattered again.

   The real fault in both was WHICH element absorbs the shrink. It must be the
   name, which can wrap internally (`min-width: 0` + `overflow-wrap: break-word`),
   never the price, which cannot wrap at all. So the name is the only flexible
   item and the price is pinned at its intrinsic width. The row then holds one
   line at every width without overflowing. Verified at 320, 375 and 1440. */
.treatment__head { display: flex; align-items: baseline; gap: var(--space-2xs) var(--space-s); }
.treatment__head .treatment__name { flex: 1 1 auto; min-width: 0; }
.treatment__head .treatment__from,
.treatment__head .price--anchor { flex: 0 0 auto; }
/* FOUND 16 Sep 2026: "Corporate / Office Massage" pairs with the longest
   .price__unit on the site ("per therapist, two-hour minimum, seated"). Pinned
   at flex:0 0 auto, .price--anchor claims that unit line's full UNWRAPPED
   width (254px of a 341px row at 390px) even though .price__unit is already
   display:block + white-space:normal and wraps fine on its own — flex-basis:auto
   with shrink:0 hands the item its max-content width regardless of what that
   leaves the name. The name then absorbs the entire deficit and breaks
   mid-word, the exact defect the comment above was written to prevent, just
   triggered by the unit label rather than the name. Letting price shrink too
   — its own number line ("from £240") is still protected by nowrap and never
   breaks — means the long unit note wraps to two lines instead, which is the
   correct trade: explanatory text wrapping is normal; a treatment name split
   mid-word is not. Verified at 320, 375, 390 and 1440 for every current name +
   unit-label pairing, including this one. */
.treatment__head .price--anchor { flex-shrink: 1; min-width: min-content; text-align: end; }
.treatment__name { margin: 0; font-size: var(--step-2); }
/* MEASURED CORRECTION: this was set in --font-display, which cannot satisfy
   "prices always use tabular lining figures" (§3.1). Marcellus's ten digits have
   ten different advance widths and its GSUB has no `tnum`, so the column cannot
   be made to align. Prices are set in --font-price. See tools/font-features.mjs
   and the note above .price below. */
.treatment__from {
  margin-left: auto;
  font-family: var(--font-price);
  font-variant-numeric: tabular-nums lining-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
  font-weight: var(--weight-strong);
  font-size: var(--step-2);
  white-space: nowrap;
}
.treatment__durations {
  display: flex; flex-wrap: wrap; gap: var(--space-xs) var(--space-m);
  list-style: none; margin: var(--space-2xs) 0 0; padding: 0;
  font-size: var(--step--1);
}
.treatment__durations li { display: flex; gap: 0.5em; align-items: baseline; }
.treatment__durations .dur { color: var(--text-muted); }
.treatment__actions { margin-top: var(--space-xs); display: flex; flex-wrap: wrap; gap: var(--space-s); align-items: center; }

/* The travel-fee note: body size, full contrast, never fine print (R-19) */
.price-note {
  border-left: 2px solid var(--gold-line);
  padding: var(--space-xs) 0 var(--space-xs) var(--space-s);
  margin-block: var(--space-m);
  font-size: var(--step-0);
  color: var(--text);
  max-width: var(--measure);
  background: none;
}
.section--dark .price-note { color: var(--on-dark); }
.price-note p { margin: 0; }
.price-note p + p { margin-top: var(--space-2xs); }

/* ============ Trust / standards ============ */
.standards { display: grid; gap: var(--space-l); }
@media (min-width: 48rem) { .standards { grid-template-columns: repeat(2, 1fr); gap: var(--space-l) var(--space-xl); } }
.standard { display: grid; gap: var(--space-3xs); }
.standard h3 { font-size: var(--step-1); margin: 0; }
.standard p { margin: 0; color: var(--text-muted); }
.section--dark .standard p { color: var(--on-dark-muted); }

/* ============ FAQ (native details/summary) ============ */
.faq { border-top: var(--hairline) solid var(--line); }
.faq__item { border-bottom: var(--hairline) solid var(--line); }
.faq__item summary {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-s);
  min-height: var(--target);
  padding-block: var(--space-s);
  font-family: var(--font-display);
  font-size: var(--step-1);
  cursor: pointer;
  list-style: none;
}
.faq__item summary::-webkit-details-marker { display: none; }
.faq__item summary::after {
  content: ""; flex: none; width: 10px; height: 10px;
  border-right: var(--hairline) solid var(--gold-text);
  border-bottom: var(--hairline) solid var(--gold-text);
  transform: rotate(45deg) translateY(-2px);
  transition: transform var(--dur-fade) var(--ease-calm);
}
.faq__item[open] summary::after { transform: rotate(-135deg) translateY(-2px); }
/* v1.1: the answer fades in on open and out on close. ::details-content is the
   UA's own wrapper around everything after the summary; transitioning its
   content-visibility discretely is what lets the CLOSE fade at all — the
   words dim first, then the row collapses. Height is never animated (§8.2):
   the row resizes in one step and the words arrive softly inside it. A
   browser without ::details-content drops these rules and toggles instantly. */
.faq__item::details-content {
  opacity: 0;
  transition: opacity var(--dur-fade-out) var(--ease-calm),
    content-visibility var(--dur-fade-out) allow-discrete;
}
.faq__item[open]::details-content {
  opacity: 1;
  transition-duration: var(--dur-fade);
}
.faq__answer { padding-bottom: var(--space-m); max-width: var(--measure); }
.faq__answer p:last-child { margin-bottom: 0; }

/* AESTHETIC FIX, 15 Sep 2026: .faq-brief (home §7's static four-question
   preview, never collapsible — see home.mjs) had zero rules anywhere in this
   file. It was rendering as a bare browser-default <dl>: no spacing, no
   divider, question and answer in identical weight, <dd> at the UA's ~40px
   indent. Styled to match .faq's own hairline-and-display-heading language
   above, without the accordion mechanics this version doesn't have. */
.faq-brief { margin: 0; border-top: var(--hairline) solid var(--line); }
.faq-brief dt {
  margin: 0;
  padding-top: var(--space-m);
  font-family: var(--font-display);
  font-size: var(--step-1);
}
.faq-brief dd {
  margin: 0;
  padding: var(--space-2xs) 0 var(--space-m);
  max-width: var(--measure);
}
.faq-brief dd + dt { border-top: var(--hairline) solid var(--line); }
.section--dark .faq-brief, .on-dark .faq-brief { border-top-color: var(--line-on-dark); }
.section--dark .faq-brief dd + dt, .on-dark .faq-brief dd + dt { border-top-color: var(--line-on-dark); }

/* ============ Forms ============ */
.field { display: grid; gap: var(--space-3xs); margin-bottom: var(--space-m); }
.field label { font-weight: 600; }
.field .hint { font-size: var(--step--1); color: var(--text-muted); }
.field input,
.field select,
.field textarea {
  font-family: var(--font-body);
  font-size: var(--step-0);
  min-height: var(--target);
  padding: 0.7rem 0.85rem;
  background: var(--surface-raised);
  color: var(--text);
  border: var(--hairline) solid var(--line-strong);
  border-radius: var(--radius);
  width: 100%;
}
.field textarea { min-height: 7rem; resize: vertical; }
.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible { border-color: var(--ink); }
/* Thicken the edge with an inset shadow, not border-width: changing the border
   from 1px to 2px reflows the field's own contents by a pixel. Not a CLS
   violation (it happens within 500ms of an interaction, so it is excluded from
   the metric) but a visible twitch, and free to avoid. The error state itself
   never animates — an error the user has to read does not fade in.
   motion-spec §6.4. */
   CRITICAL: base.css builds the focus indicator out of box-shadow, and this
   selector is MORE SPECIFIC than `input:focus-visible` — so an unguarded
   box-shadow here would suppress the focus ring on exactly the fields a
   keyboard user is being sent back to fix. The :not(:focus-visible) guard is
   not optional. If you ever change the focus ring away from box-shadow, this
   guard can go; until then it must stay. */
.field input[aria-invalid="true"] { border-color: var(--accent); }
.field input[aria-invalid="true"]:not(:focus-visible) {
  box-shadow: inset 0 0 0 1px var(--accent);
}
.field .error { color: var(--accent); font-weight: 600; font-size: var(--step--1); }
.field .error:empty { display: none; }
.fieldset { border: var(--hairline) solid var(--line); padding: var(--space-s); margin: 0 0 var(--space-m); }
.fieldset legend { font-weight: 600; padding-inline: var(--space-2xs); }
.checks { display: grid; gap: var(--space-2xs); }
.check { display: flex; gap: var(--space-2xs); align-items: flex-start; min-height: var(--target); }
.check input { width: 1.15rem; height: 1.15rem; min-height: 0; margin-top: 0.35rem; flex: none; }
.error-summary {
  border: 2px solid var(--accent);
  padding: var(--space-s);
  margin-bottom: var(--space-m);
}
.error-summary:focus-visible { outline-offset: 2px; }
.error-summary h2 { font-size: var(--step-1); margin-top: 0; }
.error-summary ul { margin-bottom: 0; }

/* ============ Concierge Card ============ */
.concierge-card {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  align-items: end;
  background: rgba(11, 11, 11, 0.55);
  border: 0;
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
}
@media (min-width: 48rem) { .concierge-card { align-items: center; justify-items: center; } }

/* Open 400ms (200ms scrim + 360ms panel behind a 40ms delay); close 200ms.
   Exits run at half an entrance's length: the user has already decided.

   Built as a <dialog> opened with showModal(). That is not a style preference —
   it supplies the focus trap, Esc-to-close, background inertness and, above all,
   FOCUS RETURN TO THE INVOKING CONTROL, all of which a11y §4.1 requires and all
   of which are easy to get subtly wrong by hand.

   Two animated properties, total: opacity on the wrapper (which fades scrim and
   panel as one) and transform on the panel. They never compound, because the
   wrapper's opacity creates the stacking context the panel sits inside.

   `transition-behavior: allow-discrete` on display/overlay is what lets the
   CLOSE animate at all — a @keyframes animation can only ever animate the open,
   because the element is display:none before an exit could run. That is why the
   previous `sheet-in` keyframe was replaced rather than extended.

   No @starting-style support => the sheet opens and closes instantly. That is a
   correct, non-broken state (it is the reduced-motion path), so there is no
   polyfill and no JS fallback. motion-spec §5.2–5.5. */
.concierge-card {
  opacity: 1;
  transition: opacity var(--dur-scrim) var(--ease-settle),
    display var(--dur-exit) allow-discrete,
    overlay var(--dur-exit) allow-discrete;
}
/* P0 FIX (creative-director, 13 Sep 2026). `.concierge-card` sets `display: grid`
   unconditionally above, which overrides the UA sheet's `dialog:not([open]) { display: none }`.
   With `position: fixed; inset: 0; z-index: 60`, the CLOSED dialog stayed a full-viewport
   hit-testing surface — opacity does NOT remove hit-testing — so it silently swallowed every
   tap on all 15 routes: nav, Book Now, form fields, footer links. The site was completely
   unusable and looked perfectly normal. `display` and `overlay` are already in the transition
   above with `allow-discrete`, so the exit animation is unaffected (motion-spec §5.5).
   DO NOT remove `display: none` or `pointer-events: none` from this rule. */
.concierge-card:not([open]) { opacity: 0; display: none; pointer-events: none; }
@starting-style { .concierge-card[open] { opacity: 0; } }

.concierge-card__panel {
  background: var(--surface);
  color: var(--text);
  width: 100%;
  max-height: 92dvh;
  overflow-y: auto;
  padding: var(--space-l) var(--gutter) var(--space-xl);
  border-top: 2px solid var(--gold-line);
  transform: none;
  transition: transform var(--dur-sheet) var(--ease-settle) var(--delay-sheet);
}
/* 24px, not a full-height slide. A 92dvh sheet travelling ~700px from the bottom
   edge is an app gesture, a vestibular-risk distance, and a composited layer
   crossing the whole screen on a mid-range Android. The sheet is PRESENTED, not
   thrown. No scale: scale on a sheet reads as a zoom, which is §4.6's wobble by
   another name. */
@starting-style { .concierge-card[open] .concierge-card__panel { transform: translateY(24px); } }
.concierge-card:not([open]) .concierge-card__panel {
  transform: translateY(8px);
  transition: transform var(--dur-exit) var(--ease-exit);
}
@media (min-width: 48rem) {
  .concierge-card__panel {
    max-width: 34rem;
    border: var(--hairline) solid var(--line);
    border-top: 2px solid var(--gold-line);
    border-radius: var(--radius-sheet);
    padding: var(--space-xl);
  }
  @starting-style { .concierge-card[open] .concierge-card__panel { transform: translateY(12px); } }
}

/* Reduced motion: scrim and panel cross-fade together, no translate. Focus
   timing is byte-for-byte identical — that is the entire point of §5.4. */
@media (prefers-reduced-motion: reduce) {
  .concierge-card {
    transition: opacity var(--dur-reduced) var(--ease-settle),
      display var(--dur-reduced) allow-discrete,
      overlay var(--dur-reduced) allow-discrete;
  }
  .concierge-card__panel,
  .concierge-card:not([open]) .concierge-card__panel { transform: none; transition: none; }
}
.concierge-card__head { display: flex; align-items: flex-start; gap: var(--space-s); margin-bottom: var(--space-m); }
.concierge-card__title { font-size: var(--step-3); margin: 0; }
.concierge-card__close {
  margin-left: auto; flex: none;
  min-width: var(--target); min-height: var(--target);
  background: none; border: var(--hairline) solid var(--line); border-radius: var(--radius);
  cursor: pointer; color: var(--text); font-size: var(--step-0);
}
.concierge-card__step[hidden] { display: none; }
.concierge-card__foot { margin-top: var(--space-m); display: grid; gap: var(--space-s); }
/* The author `display: grid` above outranks the UA's `[hidden] { display: none }`,
   so the handoff step's actions block (data-card-handoff-actions, hidden until
   the chat fails to open) needs its own rule or it renders on top of the status
   line. Found while adding the v1.1 fades below. */
.concierge-card__foot[hidden] { display: none; }
/* v1.1: a step, and the handoff status/actions, fade IN when they become
   current. They do not fade OUT: steps are swapped in the same task, and an
   outgoing step kept in layout for its exit would stack under the incoming one
   and jolt the panel's height. The old one leaves in a frame; the new one
   arrives over --dur-fade. */
.concierge-card__step,
.concierge-card__foot,
.chat-facade__status { transition: opacity var(--dur-fade) var(--ease-calm); }
@starting-style {
  .concierge-card__step:not([hidden]),
  .concierge-card__foot:not([hidden]),
  .chat-facade__status:not([hidden]) { opacity: 0; }
}
.concierge-card__alt { font-size: var(--step--1); }

/* ============ Chat facade ============ */
.chat-facade {
  display: inline-flex; align-items: center; gap: 0.6em;
  min-height: var(--target); padding: 0.7rem 1.2rem;
  background: var(--surface-dark); color: var(--on-dark);
  border: var(--hairline) solid var(--gold-line); border-radius: var(--radius);
  font-family: var(--font-body); font-weight: 600; cursor: pointer; text-decoration: none;
}
@media (hover: hover) and (pointer: fine) {
  .chat-facade:hover { background: var(--surface-dark-raised); }
}
.chat-facade:active { transform: translateY(1px); transition-duration: var(--dur-press); }

/* ============ Persistent mobile action bar ============ */
.action-bar {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 50;
  display: flex; align-items: center; gap: var(--space-2xs);
  padding: var(--space-2xs) var(--space-s) calc(var(--space-2xs) + env(safe-area-inset-bottom, 0px));
  background: var(--surface-dark);
  border-top: var(--hairline) solid var(--gold-line);
}
.action-bar .btn--primary { flex: 1; }
.action-bar__ask {
  color: var(--on-dark); background: none; border: 0;
  min-height: var(--target); padding-inline: var(--space-s);
  font-family: var(--font-body); font-size: var(--step--1); font-weight: 600;
  letter-spacing: var(--tracking-caps-tight); text-transform: uppercase; cursor: pointer;
}
@media (min-width: 75rem) { .action-bar { display: none; } }

/* ============ Footer ============ */
.site-footer {
  background: var(--surface-dark);
  color: var(--on-dark-muted);
  padding-block: var(--space-2xl) var(--space-xl);
  font-size: var(--step--1);
}
.site-footer a { color: var(--on-dark); text-decoration: none; }
@media (hover: hover) and (pointer: fine) {
  .site-footer a:hover { text-decoration: underline; }
}
/* D-03 · WCAG 1.4.1 Use of Colour. The blanket `text-decoration: none` above is
   right for the footer's link *lists*, which are navigation and read as links
   from their position. It is wrong for a link sitting inside a run of prose:
   link --on-dark (#F8F4EA) against surrounding --on-dark-70 (#B1AEA7) is only
   2.01:1, far under the 3:1 that would let colour alone carry the distinction.
   So prose links get the underline back — the same treatment base.css gives
   every prose link on an ivory background, in an on-dark line colour.
   Scoped to `p` on purpose: it must not reach the lists or the brand lockup.
   0.55 rather than --line-on-dark (0.22), because at 22% the underline is a
   decorative hairline rather than a signal, and a distinction you have to hunt
   for does not satisfy 1.4.1 in substance even where it passes a checker. */
.site-footer p a {
  text-decoration: underline;
  text-decoration-color: rgba(248, 244, 234, 0.55);
}
@media (hover: hover) and (pointer: fine) {
  .site-footer p a:hover { text-decoration-color: currentColor; }
}
.footer__brand { display: grid; gap: var(--space-2xs); justify-items: start; margin-bottom: var(--space-xl); }
/* The stacked lockup is tall-and-narrow, so a footer height that suited a single
   line of text renders it ~73px wide with "CONCIERGE" under 4px. 11.5rem puts it
   at ~152px wide, clearing the legibility floor in src/assets/logo/README.md §3.
   Scoped to the image element on purpose: layout.mjs still renders an interim
   <p class="footer__lockup"> placeholder, and an unscoped height turns that into
   150px of dead space on all 15 pages. Drop the scoping once the <p> is gone. */
img.footer__lockup,
svg.footer__lockup { display: block; height: 11.5rem; width: auto; }
.footer__tagline {
  font-size: var(--step--1); letter-spacing: var(--tracking-caps);
  text-transform: uppercase; color: var(--gold); margin: 0;
}
.footer__cols { display: grid; gap: var(--space-l); }
@media (min-width: 48rem) { .footer__cols { grid-template-columns: repeat(3, 1fr); } }
.footer__cols h2 {
  font-family: var(--font-body); font-size: var(--step--1);
  letter-spacing: var(--tracking-caps); text-transform: uppercase;
  color: var(--gold); margin: 0 0 var(--space-2xs);
}
.footer__cols ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3xs); }
.footer__cols li a { display: inline-flex; align-items: center; min-height: 2rem; }
.footer__legal {
  margin-top: var(--space-xl); padding-top: var(--space-s);
  border-top: var(--hairline) solid var(--line-on-dark);
  display: flex; flex-wrap: wrap; gap: var(--space-s); justify-content: space-between;
}

/* Mock-mode styling lives in mock.css, which the build concatenates only while
   build.mode is "mock". A live build ships none of those bytes. */

/* ============ Footer contact list ============ */
.footer__contact {
  list-style: none; margin: var(--space-s) 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--space-2xs) var(--space-m);
}

/* ============ Utility page ============ */
.utility { min-height: 50vh; display: grid; align-content: center; }

/* ===========================================================================
   MOTION — docs/motion/motion-spec.md

   "Felt, not noticed." A concierge who performs for you is not a good
   concierge. Three rules govern everything here:
     1. Nothing animates on the way in. No preloader, no hero choreography,
        no above-the-fold reveal on load.
     2. Nothing animates that a user has to read. Prices, the travel-fee
        sentence, the postcode field and the confirmation copy paint at full
        opacity, immediately, always.
     3. Motion is CSS. JavaScript decides WHEN, never WHAT.
   =========================================================================== */

/* Section reveals ([data-reveal], [data-reveal-group]) live in base.css, not
   here — they are a global primitive applied to sections, not a component, and
   base.css already owns the reduced-motion principle they follow. One home
   only: two copies of a transition is exactly how the opacity and transform
   legs drift out of step. motion-spec §4.4.

   What follows is component-specific motion only. */

/* v1.1, touch: the UA's grey tap flash is the one piece of feedback on the
   site that is neither designed nor timed. Removed only where a control has
   its own press or colour feedback; links in body copy keep the UA behaviour. */
.btn,
.nav-toggle,
.nav__link,
.mobile-nav__list a,
.action-bar__ask,
.chat-facade,
.link-more,
.concierge-card__close,
.faq__item summary { -webkit-tap-highlight-color: transparent; }

/* --- The Ring (§2) --------------------------------------------------------
   The brand signature. Draws once per page load, on exactly one element.

   Technique: stroke-dasharray/stroke-dashoffset on a circle with
   pathLength="1", so the maths is resolution-independent — no 2*pi*r magic
   number to get wrong when the Ring is resized.

   stroke-dashoffset is the ONE documented exception to "transform and opacity
   only". It is paint work, permitted here alone, under hard limits: <=64px
   painted area, exactly one instance animating site-wide, never repeats, never
   runs before LCP. A 64x64 repaint is ~4,000 pixels a frame on a thread that is
   idle by then.

   It cannot affect LCP or CLS, structurally rather than behaviourally:
   the Ring is an INLINE <svg>, and inline SVG shapes are not an LCP candidate
   element type at all (<img> is — which is exactly why the Ring must never be
   one). And stroke-dashoffset is not a layout property, so it cannot move a
   neighbouring pixel: CLS contribution 0.000. */
.ring-mark__ring {
  stroke-dasharray: 1;
  stroke-dashoffset: 0;              /* default: DRAWN. No JS = a finished ring. */
  transform: rotate(-90deg);         /* <circle> starts at 3 o'clock; draw from 12 */
  transform-box: fill-box;
  transform-origin: center;
}
/* Only a running motion script may un-draw a ring, and only one MARKED for
   drawing. Scoping this to [data-ring-draw] is load-bearing: an unmarked Ring
   has nothing coming to draw it, so if it started undrawn it would simply stay
   invisible forever. Any Ring the script is not going to animate renders
   complete, always. */
[data-motion="on"] [data-ring-draw] .ring-mark__ring { stroke-dashoffset: 1; }
[data-motion="on"] [data-ring-draw="in"] .ring-mark__ring {
  animation: ring-draw var(--dur-ring) var(--ease-draw) both;
}
@keyframes ring-draw { to { stroke-dashoffset: 0; } }

/* The Concierge Card's Ring is triggered by the dialog OPENING, not by scroll
   (North Star §7, booking spec §4.7: "the Ring signature plays once on open").
   It must not carry data-ring-draw: the Card is a closed <dialog>, so it is
   display:none and an IntersectionObserver would never report it — the Ring
   would sit undrawn and invisible for the life of the page, on the one surface
   that matters most. Driving it from [open] costs no JavaScript and cannot
   desynchronise from the sheet. */
[data-motion="on"] .concierge-card__ring .ring-mark__ring { stroke-dashoffset: 1; }
[data-motion="on"] .concierge-card[open] .concierge-card__ring .ring-mark__ring {
  animation: ring-draw var(--dur-ring) var(--ease-draw) var(--delay-sheet) both;
}
@media (prefers-reduced-motion: reduce) {
  [data-motion="on"] .concierge-card__ring .ring-mark__ring {
    stroke-dashoffset: 0;
    animation: none;
  }
}

/* Reduced motion: no draw. The Ring is present, fully drawn, static, from the
   first frame — which is already the default above, so this costs nothing and
   the script simply never arms it. It is NOT faded in. It was always there. */
@media (prefers-reduced-motion: reduce) {
  [data-motion="on"] .ring-mark__ring { stroke-dashoffset: 0; animation: none; }
}

/* --- The Ring close on confirmation (§3) ----------------------------------
   The single emotional beat of the site. Paid path only — the Ring is a
   confirmation claim in visual form, and it must never claim more than is true
   (booking spec §7.4: direct access with no valid payment redirect gets neutral
   content and no beat).

   Sequence, 980ms total, inside §4.6's 1000ms cap:
     0ms    page paints; H1, body copy and every booking detail at FULL opacity
     100ms  the Ring begins closing, from 12 o'clock, clockwise
     780ms  the monogram fades up, arriving as the Ring seals
     980ms  rest. Nothing on this page moves again.

   Three beats: a pause, a line, a seal. The 100ms of stillness is the most
   important 100ms on the site — it is the pause a concierge leaves before
   saying "consider it arranged". Without it the Ring starts in the same instant
   as the page and reads as a page-load effect rather than a reply. The monogram
   arriving 100ms BEFORE the seal matters too: mark-then-seal reads as
   enclosure, seal-then-mark reads as decoration.

   Costs zero JavaScript. It needs no post-LCP gate because this page has no
   hero image, its LCP element is a text block that paints in the first frame,
   and the Ring's stroke is invisible for the first 100ms.

   THE HARD RULE: no text on this page may start at opacity 0. Fading the H1 up
   is the obvious "nicer" version and it would delay LCP outright, because LCP
   does not register an element while its opacity is 0 — the delay plus the fade
   would be added to the metric wholesale. It is also worse creatively: the
   words are already true when the page loads. The Ring confirms them; it does
   not reveal them. */
.confirm-ring { width: clamp(96px, 26vw, 152px); height: auto; }
[data-motion="on"] .confirm-ring .ring-mark__ring,
.confirm-ring .ring-mark__ring {
  stroke-dashoffset: 1;
  animation: ring-draw var(--dur-ring-close) var(--ease-draw) var(--delay-ring-close) both;
}
.confirm-ring__monogram { animation: fade-in 200ms var(--ease-settle) 780ms both; }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }

/* Reduced motion: the beat SURVIVES — deleting it would leave the site's one
   emotional moment as a blank. The Ring is rendered fully drawn and the whole
   group fades in over 150ms after the same 100ms pause. No drawing, no
   rotation, no movement. Opacity change provokes no vestibular response, so
   the pause and the arrival both survive. 150ms is a11y §6's ceiling. */
@media (prefers-reduced-motion: reduce) {
  /* G-01. The `[data-motion="on"]` prefix is REQUIRED here, not decorative.
     The rule this overrides is written twice above — once bare at (0,2,0) and
     once as `[data-motion="on"] .confirm-ring .ring-mark__ring` at (0,3,0) —
     and **a media query adds no specificity**. So a bare `.confirm-ring
     .ring-mark__ring` here lost to the motion rule and the Ring kept drawing
     for 880ms under `prefers-reduced-motion: reduce`, on /payment-confirmed/,
     which is the one page whose whole job is the emotional beat.
     The intent below was documented and correct; the selector silently never
     reached it, because `data-motion="on"` is always set by the head script.
     Caught by executing acceptance test G-01 rather than by reading the CSS.
     Any future reduced-motion override must match or exceed the specificity of
     the rule it is disabling. */
  [data-motion="on"] .confirm-ring .ring-mark__ring,
  .confirm-ring .ring-mark__ring { stroke-dashoffset: 0; animation: none; }
  [data-motion="on"] .confirm-ring__monogram,
  .confirm-ring__monogram { animation: none; }
  .confirm-ring {
    animation: fade-in var(--dur-reduced-beat) var(--ease-settle) var(--delay-ring-close) both;
  }
}

/* --- The hairline as a motion device (§7) ---------------------------------
   Gold is a line, not a fill. A line that draws is the most brand-true motion
   available to us, which is exactly why it is rationed.

   scaleX on a 1px-tall element is PURE COMPOSITOR WORK — no paint, no layout,
   unlike the Ring. The cheapest animation on the site. Use a span or a
   pseudo-element with a background, never a border-top: you cannot scale a
   border independently of its box.

   ALLOWED: the opening rule of a section (one per section, sharing that
   section's reveal trigger — it does not get its own observer); the rule that
   closes the confirmation page, after the Ring has sealed.
   NOISE: price rows and treatment rules (a dozen of them, carrying structure
   the eye needs immediately); the footer, header, table and fieldset rules;
   any hairline the user needs in order to parse the layout. If removing the
   line would make the content ambiguous, the line is structure — and structure
   does not perform. If unsure, the answer is no.

   A scaled 1px line can render with sub-pixel softness mid-animation. It lands
   crisp at scaleX(1) and the softness is imperceptible over 420ms. Do not
   "fix" it with will-change (§8.3). */
.hairline-draw {
  height: var(--hairline);
  background: currentColor;
  transform-origin: 0 50%;
}
[data-motion="on"] [data-reveal=""] .hairline-draw { transform: scaleX(0); }
[data-reveal="in"] .hairline-draw {
  transform: scaleX(1);
  transition: transform var(--dur-hairline) var(--ease-settle);
}
.confirm-rule { transition-delay: 980ms; }   /* a full stop, after the seal */

@media (prefers-reduced-motion: reduce) {
  .hairline-draw,
  [data-motion="on"] [data-reveal=""] .hairline-draw,
  [data-reveal="in"] .hairline-draw {
    transform: none;
    transition: opacity var(--dur-reduced) var(--ease-settle);
  }
}

/* --- Reduced motion, remaining components (§9) ----------------------------
   Colour and focus are deliberately untouched: a colour change is not motion,
   a11y §1.4 requires the state to stay distinct, and the focus ring was
   already instant. Only movement is removed. */
@media (prefers-reduced-motion: reduce) {
  .btn:active,
  .chat-facade:active { transform: none; }
  .nav-toggle__bars::before,
  .nav-toggle__bars::after { transition: none; }
  .faq__item summary::after { transition: none; }
  /* v1.1 fades are opacity only, so they survive — at the 120ms cap. */
  .mobile-nav,
  .mobile-nav[hidden],
  .faq__item::details-content,
  .faq__item[open]::details-content,
  .concierge-card__step,
  .concierge-card__foot,
  .chat-facade__status { transition-duration: var(--dur-reduced); }
}


/* #############################################################################
   ADDED BY ui-visual-designer — the components the first pass did not cover.
   Purely ADDITIVE: no existing class is renamed or redefined, so templates
   already written against this file keep working.
   ############################################################################# */

/* =============================================================================
   A. BOOK NOW PROMINENCE — visual-direction §8 line 5
   -----------------------------------------------------------------------------
   "Book Now is visibly the single most prominent action on every screen."
   Four things make that true, and ONLY ONE OF THEM IS COLOUR, because §8 line 6
   forbids relying on colour alone:
     1. It is the only burgundy fill in the entire system.
     2. It is geometrically taller than every other control — 52px against the
        44px floor — with more horizontal padding.
     3. It is the only control at strong weight with positive tracking.
     4. It is the only FILLED button. Everything else is a rule or a bare link.
   If a second filled button ever appears on a screen, this rule has been broken.

   The light/dark inversion is driven by custom properties rather than repeated
   selectors, so a new dark container only has to opt in once.
   NEVER burgundy on black — 1.74:1 (§2.3, a11y §1.1).
   ============================================================================= */
:root {
  --btn-primary-bg: var(--accent);
  --btn-primary-fg: var(--on-accent);
  --btn-primary-bg-hover: var(--accent-hover);
  --btn-primary-bg-active: var(--accent-pressed, #431219);
}
.section--dark,
.on-dark,
.site-header,
.site-footer,
.hero--dark,
.action-bar {
  /* The demonstration ribbon is deliberately NOT in this list: mock-only
     selectors live in mock.css, which is concatenated only in mock mode.
     The build fails if one appears in this file. */
  --btn-primary-bg: var(--on-dark);
  --btn-primary-fg: var(--ink);
  --btn-primary-bg-hover: var(--surface-raised);
  --btn-primary-bg-active: #ede7d9;
}

.btn--primary {
  min-height: 52px;
  padding-inline: var(--space-32);
  background: var(--btn-primary-bg);
  color: var(--btn-primary-fg);
  border-color: var(--btn-primary-bg);
  font-weight: var(--weight-strong);
  letter-spacing: 0.02em;
}
.btn--primary:active {
  background: var(--btn-primary-bg-active);
  border-color: var(--btn-primary-bg-active);
}
@media (hover: hover) and (pointer: fine) {
  .btn--primary:hover {
    background: var(--btn-primary-bg-hover);
    border-color: var(--btn-primary-bg-hover);
  }
}
/* The focus indicator is bounded by a compliant colour on BOTH sides: the gap
   painted outside (base.css) and this hairline inset inside — ivory on burgundy
   (10.31:1) on light, ink on ivory (17.92:1) on dark. An ink ring laid directly
   on burgundy would be 1.74:1 and fail. */
.btn--primary:focus-visible {
  box-shadow:
    0 0 0 var(--focus-offset) var(--focus-gap),
    0 0 0 calc(var(--focus-offset) + var(--focus-width)) var(--focus-color),
    0 0 0 calc(var(--focus-offset) + var(--focus-width) + 1px) var(--focus-echo),
    inset 0 0 0 var(--focus-width) var(--btn-primary-fg);
}

/* --- Ghost: text with a hairline underline. Lower weight than secondary. --- */
.btn--ghost {
  padding-inline: var(--space-8);
  color: var(--gold-text);
  border-color: transparent;
  border-bottom-color: var(--line-strong);
  border-radius: 0;
  font-weight: var(--weight-medium);
}
.section--dark .btn--ghost,
.on-dark .btn--ghost { color: var(--gold); border-bottom-color: var(--line-on-dark); }
@media (hover: hover) and (pointer: fine) {
  .btn--ghost:hover { color: var(--ink); border-bottom-color: var(--ink); }
  .section--dark .btn--ghost:hover,
  .on-dark .btn--ghost:hover { color: var(--on-dark); border-bottom-color: var(--on-dark); }
}

.btn--block { display: flex; width: 100%; }

/* Prefer NOT to disable submit buttons (a11y §1.4). Reserve this for things
   genuinely unavailable, e.g. a treatment the client has paused — and always
   pair it with a real disabled/aria-disabled attribute plus visible text
   explaining why, never colour alone. */
.btn[disabled],
.btn[aria-disabled="true"] { opacity: 0.55; cursor: not-allowed; }

/* Short uppercase label link — three words or fewer, so wide tracking applies. */
.link-label {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  min-height: var(--target);
  font-size: var(--step--1);
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--gold-text);
  text-decoration: none;
  border-bottom: var(--hairline) solid var(--line-gold);
}
.section--dark .link-label, .on-dark .link-label { color: var(--gold); }
@media (hover: hover) and (pointer: fine) {
  .link-label { transition: color var(--dur-micro) var(--ease-settle),
                            border-color var(--dur-micro) var(--ease-settle); }
  .link-label:hover { color: var(--ink); border-bottom-color: var(--ink); }
  .section--dark .link-label:hover,
  .on-dark .link-label:hover { color: var(--on-dark); border-bottom-color: var(--on-dark); }
}

/* =============================================================================
   B. MOTIFS — the pieces the first pass left out (visual-direction §5)
   ============================================================================= */

/* --- THE RING, at three sizes. Frames AT MOST ONE element per section: a
   process numeral, the Concierge Card crest, a confirmation. The stroke is a
   fixed px value, not an em, so it stays optically consistent (1–1.5px) as the
   ring scales rather than thickening with it. --- */
.ring {
  --ring-size: 3.5rem;
  inline-size: var(--ring-size);
  block-size: var(--ring-size);
  border-width: var(--ring-stroke);
  font-size: var(--step-2);
  line-height: 1;
}
.ring--sm { --ring-size: 2.5rem; font-size: var(--step-0); }
.ring--lg { --ring-size: 5rem; font-size: var(--step-3); }

/* --- THE STAR — a divider or marker ONLY. AT MOST ONE PER VIEWPORT.
   Never in a row, never near ratings or reviews, never a list bullet. It must
   never be mistakable for a rating (§5, §8 line 9).
   Drawn from a mask so it takes currentColor and ships no asset file. Always
   aria-hidden in markup. There is deliberately NO modifier that repeats it: if
   you find yourself writing a loop that outputs .star, stop. --- */
.star {
  --star-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 0c0 6.63 5.37 12 12 12-6.63 0-12 5.37-12 12 0-6.63-5.37-12-12-12 6.63 0 12-5.37 12-12z'/%3E%3C/svg%3E");
  display: inline-block;
  inline-size: 0.75rem;
  block-size: 0.75rem;
  flex: none;
  background-color: currentColor;
  -webkit-mask: var(--star-mask) center / contain no-repeat;
  mask: var(--star-mask) center / contain no-repeat;
  color: var(--gold);
}
.star--sm { inline-size: 0.5rem; block-size: 0.5rem; }
.star--lg { inline-size: 1.25rem; block-size: 1.25rem; }

/* --- THE HAIRLINE as an element. (var(--hairline) is the 1px width token; this
   is the rule itself.) Structural rhythm: section separations, price rows, the
   footer. This is where gold earns its keep. --- */
hr.hairline,
.hairline {
  border: 0;
  border-top: var(--hairline) solid var(--line);
  margin: 0;
}
.section--dark hr.hairline, .on-dark hr.hairline { border-top-color: var(--line-gold); }
hr.hairline--gold { border-top-color: var(--line-gold); }

/* A rule with a single centred star. One per viewport, like any other Star. */
.hairline-star {
  display: flex;
  align-items: center;
  gap: var(--space-16);
  margin-block: var(--space-48);
  color: var(--gold);
}
.hairline-star::before,
.hairline-star::after {
  content: "";
  flex: 1 1 0;
  block-size: var(--hairline);
  background: var(--line);
}
.section--dark .hairline-star::before,
.section--dark .hairline-star::after,
.on-dark .hairline-star::before,
.on-dark .hairline-star::after { background: var(--line-gold); }

/* =============================================================================
   C. PRICES — visual-direction §3.1, §8 line 3
   -----------------------------------------------------------------------------
   Prices ALWAYS use tabular lining figures, aligned. They are set in
   --font-price, which resolves to the BODY face, and that is a MEASURED
   decision, not a stylistic one:

     Marcellus digits 0–9 advance 1651 604 1090 1032 1174 954 1143 1001 1102
     1135 units on a 2048 em — ten different widths — and its GSUB carries only
     `frac` and `liga`. There is no `tnum` and no `lnum`, so
     font-variant-numeric has nothing to switch on, and browsers do not
     synthesise tabular figures. The column physically cannot align.

     Hanken Grotesk's digits are all 560/1000 — natively lining and tabular,
     no feature required.

   Verified by `node tools/font-features.mjs`. The font-variant-numeric
   declarations below are kept anyway: they declare intent and they protect the
   rule if the type territory is ever swapped for a face with proportional
   figures.

   Data shapes this must survive (mock-data-register §1):
     - "from £95" anchor prices, per person
     - Couples: priced PER COUPLE, staffed by TWO THERAPISTS
     - Corporate: per therapist, two-hour minimum, seated, NO 60-minute option
     - Sports and Couples have NO 120-minute option
   "The price table must degrade cleanly with empty cells, never show '—' as if
   it were a price, and never collapse a column." Hence .price-cell--none.
   ============================================================================= */

.price {
  font-family: var(--font-price);
  font-variant-numeric: tabular-nums lining-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
  font-weight: var(--weight-medium);
  white-space: nowrap;
  margin: 0;
}
.price--anchor { font-size: var(--step-2); font-weight: var(--weight-strong); }


.price__from {
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: var(--weight-body);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--ink-70);
  margin-inline-end: 0.5em;
}
.section--dark .price__from, .on-dark .price__from { color: var(--on-dark-70); }

/* The unit qualifier — "per person", "per couple, two therapists",
   "per therapist, two-hour minimum, seated". Never omitted: Couples is the case
   that breaks a naive "from £X per person" layout, so it is part of the
   component rather than an afterthought. */
.price__unit {
  display: block;
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: var(--weight-body);
  font-variant-numeric: normal;
  color: var(--ink-70);
  white-space: normal;
  margin-top: var(--space-4);
}
.section--dark .price__unit, .on-dark .price__unit { color: var(--on-dark-70); }

/* An absent length keeps its row. The dash is decorative and aria-hidden; the
   real information is the adjacent visually-hidden text. A dash is never
   presented as if it were a price. */
.price-cell--none { color: var(--ink-70); font-variant-numeric: normal; }
.section--dark .price-cell--none, .on-dark .price-cell--none { color: var(--on-dark-70); }

/* --- The price row: a hairline list. Works at 320px and reflows nothing. --- */
.price-rows { list-style: none; margin: 0; padding: 0; }
.price-rows > li { margin: 0; max-width: none; }
.price-row {
  display: flex;
  align-items: baseline;
  gap: var(--space-16);
  min-height: var(--target);
  padding-block: var(--space-12);
  border-top: var(--hairline) solid var(--line);
}
.price-rows > li:first-child .price-row { border-top: 0; }
/* The late-night figure takes a full line of its own under the standard price,
   so it never widens the price and every leader ends at the same point. */
.price-row:has(.price-row__late) { flex-wrap: wrap; row-gap: 0; }
.price-row__late {
  flex: 1 0 100%;
  font-size: var(--step--1);
  color: var(--ink-70);
  text-align: end;
}
.section--dark .price-row__late, .on-dark .price-row__late { color: var(--on-dark-70); }
.section--dark .price-row, .on-dark .price-row { border-color: var(--line-on-dark); }
.price-row__label { color: var(--ink-70); flex: none; }
.section--dark .price-row__label, .on-dark .price-row__label { color: var(--on-dark-70); }
/* A leader rule between label and price — the Hairline motif doing quiet work,
   and what makes a ragged list read as a column. Decorative: it carries no
   information, so its low contrast is correct. */
.price-row__leader,
.treatment__leader {
  flex: 1 1 auto;
  min-inline-size: var(--space-24);
  border-bottom: var(--hairline) dotted var(--line);
  translate: 0 -0.3em;
}
.section--dark .price-row__leader,
.on-dark .price-row__leader,
.section--dark .treatment__leader,
.on-dark .treatment__leader { border-bottom-color: var(--line-on-dark); }

/* --- The comparison table for /prices. SC 1.4.10: it reflows to labelled rows
   below 48rem rather than scrolling horizontally. --- */
.price-table { inline-size: 100%; border-collapse: collapse; }
.price-table th, .price-table td { text-align: start; padding: var(--space-12) var(--space-16); }
.price-table thead th {
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--ink-70);
  border-bottom: var(--hairline) solid var(--line-strong);
}
.price-table tbody th { font-weight: var(--weight-medium); }
.price-table tbody th .meta { display: block; font-weight: var(--weight-body); }
.price-table tbody tr { border-bottom: var(--hairline) solid var(--line); }
.price-table td {
  font-family: var(--font-price);
  font-variant-numeric: tabular-nums lining-nums;
  font-feature-settings: "tnum" 1, "lnum" 1;
}
.section--dark .price-table tbody tr, .on-dark .price-table tbody tr { border-color: var(--line-on-dark); }
.section--dark .price-table thead th, .on-dark .price-table thead th { color: var(--on-dark-70); }

@media (max-width: 47.999rem) {
  .price-table,
  .price-table tbody,
  .price-table tr,
  .price-table td,
  .price-table th { display: block; }
  .price-table thead {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip-path: inset(50%);
  }
  .price-table tr { padding-block: var(--space-16); }
  .price-table tbody th { padding-inline: 0; font-size: var(--step-1); }
  .price-table td {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-16);
    min-height: var(--target);
    padding-inline: 0;
    border-top: var(--hairline) solid var(--line);
  }
  /* Each cell carries its column name, so a column never "collapses" — it
     becomes a labelled row instead. */
  .price-table td::before {
    content: attr(data-label);
    font-family: var(--font-body);
    font-variant-numeric: normal;
    color: var(--ink-70);
  }
}

/* =============================================================================
   D. THE TRAVEL-FEE BLOCK (NN-08) — R-19, visual-direction §8 line 4
   -----------------------------------------------------------------------------
   THIS IS A LEGAL REQUIREMENT, NOT A STYLE CHOICE.

   "The travel-fee sentence sits at body size and full contrast beside prices —
   never as fine print, an asterisk or a tooltip."

   So, explicitly and permanently:
     - font-size is --step-0, the SAME size as body copy. Not --step--1.
     - colour is --ink / --on-dark at FULL opacity. Not --ink-70. Not muted.
     - never italic, never inside <small>, never abbreviated.
     - no asterisk, no superscript, no dagger, no "*" pseudo-element.
     - no tooltip, no <details>, no accordion, no "read more".
     - never visually-hidden, never collapsed, never truncated.
   The string itself is verbatim and byte-for-byte. DO NOT PARAPHRASE IT TO MAKE
   IT FIT — MAKE THE SPACE FIT IT.

   .price-note is the original class name and is kept working; .travel-fee is
   the clearer one. The rules also key off [data-legal], which every instance
   carries for QA grepping, so stripping the class does not strip the
   protection. The gold rule on the inline-start edge is the Hairline motif: it
   is decorative and carries no meaning, so nothing is lost if it is not seen.
   ============================================================================= */
.travel-fee,
.price-note,
[data-legal="nn-08"],
[data-legal="nn-09"] {
  font-family: var(--font-body);
  font-size: var(--step-0);
  font-weight: var(--weight-body);
  font-style: normal;
  line-height: var(--leading-body);
  color: var(--ink);
  max-width: var(--measure);
}
.travel-fee {
  border-inline-start: 2px solid var(--line-gold);
  padding: var(--space-8) 0 var(--space-8) var(--space-24);
  margin-block: var(--space-24);
  background: none;
}
.section--dark .travel-fee,
.on-dark .travel-fee,
.section--dark [data-legal],
.on-dark [data-legal] { color: var(--on-dark); }
.travel-fee p { margin: 0; max-width: none; }
.travel-fee p + p { margin-top: var(--space-12); }
/* Guard: if a future edit ever nests this inside <small>, the size is restored
   here rather than silently shrinking. */
.travel-fee small, small .travel-fee { font-size: var(--step-0); }

/* On the Concierge Card's final screen NN-08 is immediately followed by NN-09,
   unconditionally, directly above Continue (CC-02). This is the one place in
   the whole journey the two sentences appear together as a pair. */
.travel-fee--pair { margin-block: var(--space-24) var(--space-16); }

/* =============================================================================
   E. FORM PARTS — a11y §4
   ============================================================================= */
.field__label { font-weight: var(--weight-medium); color: var(--ink); }
.section--dark .field__label, .on-dark .field__label { color: var(--on-dark); }

/* Most fields are required, so mark the EXCEPTIONS rather than every required
   field (a11y §4, R-07). */
.field__optional {
  font-weight: var(--weight-body);
  color: var(--ink-70);
  margin-inline-start: 0.4em;
}
.section--dark .field__optional, .on-dark .field__optional { color: var(--on-dark-70); }

/* Hints live in real text tied by aria-describedby — never only in a
   placeholder, which disappears on input. */
.field__hint { font-size: var(--step--1); color: var(--ink-70); margin: 0; }
.section--dark .field__hint, .on-dark .field__hint { color: var(--on-dark-70); }

/* Input borders must clear 3:1 — a11y §1.3 names them explicitly.
   --field-line is ink at 52%, measured 3.85:1 on --surface. (The minimum that
   clears 3:1 is 44%; 52% is chosen for headroom.) */
.field input,
.field select,
.field textarea { border-color: var(--field-line); }
.section--dark .field input,
.section--dark .field select,
.section--dark .field textarea,
.on-dark .field input,
.on-dark .field select,
.on-dark .field textarea {
  color: var(--on-dark);
  background: var(--surface-dark-raised);
  border-color: var(--field-line-dark);
}
.field input::placeholder, .field textarea::placeholder { color: var(--ink-70); opacity: 1; }

/* Error state. NEVER colour alone: the edge thickens (a shape change), the
   message is real text carrying a literal "Error:" prefix, and the markup
   carries aria-invalid + aria-describedby.
   The edge is an inset box-shadow rather than border-width so it does not
   reflow the field's contents by 1px when the error appears (motion-spec §6.4).
   An error the user must read never fades in — there is no transition here. */
.field input[aria-invalid="true"],
.field select[aria-invalid="true"],
.field textarea[aria-invalid="true"] {
  border-color: var(--error);
  border-width: var(--hairline);
  box-shadow: inset 0 0 0 1px var(--error);
  background: var(--error-surface);
}
/* When an invalid field is focused, the focus ring must still win outright. */
.field input[aria-invalid="true"]:focus-visible,
.field select[aria-invalid="true"]:focus-visible,
.field textarea[aria-invalid="true"]:focus-visible {
  box-shadow:
    inset 0 0 0 1px var(--error),
    0 0 0 var(--focus-offset) var(--focus-gap),
    0 0 0 calc(var(--focus-offset) + var(--focus-width)) var(--focus-color),
    0 0 0 calc(var(--focus-offset) + var(--focus-width) + 1px) var(--focus-echo);
}

.field__error {
  display: flex;
  align-items: baseline;
  gap: 0.4em;
  margin: 0;
  font-size: var(--step-0);
  font-weight: var(--weight-medium);
  color: var(--error);
}
.field__error:empty { display: none; }
/* A non-colour marker, so the error still reads as an error in greyscale and in
   forced-colours mode. Real text, not an icon font, not an image. */
.field__error::before { content: "Error:"; font-weight: var(--weight-strong); }
.section--dark .field__error, .on-dark .field__error { color: #f2b8bd; }

.error-summary { background: var(--error-surface); border-color: var(--error); }
.error-summary__title { margin: 0 0 var(--space-12); font-size: var(--step-2); }
.error-summary a { color: var(--error); font-weight: var(--weight-medium); }

/* Status region for "message sent" / "file selected" (SC 4.1.3). Announced
   politely via aria-live; never steals focus. */
.status {
  display: flex;
  align-items: baseline;
  gap: var(--space-12);
  padding: var(--space-16) var(--space-24);
  border-inline-start: 2px solid var(--line-gold);
  background: var(--surface-raised);
  color: var(--ink);
}
.section--dark .status, .on-dark .status {
  background: var(--surface-dark-raised);
  color: var(--on-dark);
}
.status:empty { display: none; }

/* The privacy-notice statement above Send Application — static text, NOT a
   control. No checkbox, no ticked state, no validation, nothing that can block
   submission (a11y §4.2; legitimate interest is the lawful basis, not consent).
   Body size and full contrast: it is genuinely present, not collapsed away. */
.privacy-note {
  margin-block: var(--space-24);
  font-size: var(--step-0);
  color: var(--ink);
  max-width: var(--measure);
}
.section--dark .privacy-note, .on-dark .privacy-note { color: var(--on-dark); }

/* =============================================================================
   F. SURFACES AND REMAINING PARTS
   ============================================================================= */

/* A raised panel on either surface. ALWAYS a tone step AND a hairline: the tone
   step alone is 1.05:1 on ivory and 1.06:1 on dark, so it is a lift, never a
   boundary. The line is what draws the edge (§8 line 6). */
.panel {
  background: var(--surface-raised);
  border: var(--hairline) solid var(--line);
  border-radius: var(--radius-sheet);
  padding: var(--space-24);
}
.section--dark .panel, .on-dark .panel {
  background: var(--surface-dark-raised);
  border-color: var(--line-on-dark);
}

/* The Ring frames exactly one element in the Concierge Card: the crest. */
.concierge-card__crest { margin-bottom: var(--space-24); }

/* The persistent bar reserves its own space so base.css can pad the page and
   set scroll-padding-bottom. A focused element can then never end up under it
   (SC 2.4.11) and the last line of content is never permanently hidden.
   76px is the rendered height at the default scale; measure and adjust if the
   bar's contents change. */
.has-action-bar { --bar-height: 76px; }
@media (min-width: 64rem) { .has-action-bar { --bar-height: 0px; } }
/* The bar yields to the Concierge Card: never two live "Book Now" targets. */
.action-bar[inert], .action-bar[hidden] { display: none; }

/* Secondary control on the bar — a real button with a visible text label, never
   icon-only, and never a second primary (a11y §3, §5.3). */
.action-bar__secondary { flex: none; }

/* =============================================================================
   G. MEASURED CORRECTIONS
   -----------------------------------------------------------------------------
   Every fix below was found by rendering the system in a real browser at 320px,
   375px and 1440px (tools/make-diag.mjs), not by reading the CSS. Each one is a
   defect the stylesheet could not have revealed on inspection.
   ============================================================================= */

/* --- G1. Book Now was not actually the tallest control. ---------------------
   MEASURED: at 375px the primary button and the ghost button both rendered
   53px tall, so the geometric half of the prominence argument was not true.
   Cause: .btn never set a line-height, so it inherited --leading-body (1.6),
   making the natural height 53px — above the 52px min-height the primary
   relies on. Setting a control line-height restores the hierarchy:
   primary 52px, everything else 44px. §8 line 5 depends on this. */
.btn { line-height: 1.2; }
.btn--secondary,
.btn--ghost { min-height: var(--target); }
.btn--primary { min-height: 52px; }

/* --- G2. Horizontal overflow at 320px. SC 1.4.10 permits none. --------------
   MEASURED: .nav-toggle rendered from 332px to 376px in a 320px viewport —
   56px outside. Cause: the brand lockup held a fixed height, so its intrinsic
   width (~284px) plus the gap plus the 44px toggle exceeded the 272px of
   content width available inside the gutters.
   The lockup now scales with the viewport while never dropping below the
   160px minimum width the logo spec sets for the horizontal lockup, and the
   brand link is allowed to shrink. Below 160px the markup should switch to the
   monogram-only lockup — that is a template decision, not a CSS one. */
.site-header__inner { gap: var(--space-12); }
.brand-link {
  min-width: 0;
  flex: 0 1 auto;
  min-height: var(--target);   /* G3: was 42px tall — under the 44px floor */
}
.brand-lockup {
  block-size: clamp(1.75rem, 1.1rem + 3vw, 2.25rem);
  inline-size: auto;
  max-inline-size: 100%;
  min-inline-size: 160px;      /* logo spec §7.4 */
}
@media (min-width: 64rem) { .brand-lockup { block-size: 2.75rem; } }

/* --- G4. Footer link targets were 32px. ------------------------------------
   32px clears SC 2.5.8's 24px AA floor, so this was not a failure — but the
   internal floor is 44px and there is no reason a footer link should be the
   one place we drop below it. */
.footer__cols li a,
.footer__col li a { min-height: var(--target); }

/* --- G5. The hero ignored the section rhythm and cropped focus rings. -------
   MEASURED: hero padding-block resolved to 43.2px at 320px and 46.5px at
   375px. visual-direction §4 sets the mobile section rhythm at 64–80px, so the
   hero was roughly a third short of the floor at every mobile width.

   The hero also carried `overflow: hidden`, which a11y §1.5 forbids outright:
   "no overflow: hidden container may crop a focus ring". The Book Now button
   lives inside the hero, so this was the single worst place on the site to
   clip an indicator. Removed.

   Dark is now opt-in via .hero--dark rather than forced on every hero. §2.3
   caps a page at two dark sections with the header and footer always dark, so
   a hero that is unconditionally dark spends that budget before the page has
   said anything. */
.hero {
  padding-block: var(--section-y);
  overflow: visible;
}

/* --- G5a. REGRESSION I INTRODUCED, AND THE FIX. ----------------------------
   G5 originally made the hero light unless it opted into .hero--dark. The
   templates' hero markup carries no such class, so the hero turned ivory while
   .hero__support and .hero__signals still hard-coded `color: var(--on-dark)`.
   Result: ivory text on an ivory background — the supporting line and the
   reassurance microline rendered completely invisible. MEASURED on the built
   homepage: p.hero__support colour rgb(248,244,234) against background
   rgb(248,244,234).

   Two things were wrong, and both are fixed rather than just the visible one:

   1. The DEFAULT is dark again, because that is what the templates expect and
      what the design intends. Light is now the explicit opt-out, .hero--light.
      .hero--dark is kept as an explicit, harmless synonym of the default.
   2. More importantly, the descendants no longer hard-code a colour. They
      inherit or pair with the variant, so NEITHER variant can ever be wrong
      again. A component whose text colour is stated independently of its own
      background is a latent invisible-text bug; this removes the class of
      defect, not just this instance. */
/* Warm gradient wash, 14 Sep 2026 (client direction: "background gradient...
   bringing this good feeling"; formalised as R-CD-09). A hue shift within the
   dark register, not a lightening one — top stays --surface-dark, bottom
   warms toward --accent at low mix — so the measured text contrast below is
   preserved, not guessed at. --surface-dark-warm is the same 12% recipe,
   hoisted to tokens.css so .section--dark (base.css) can share it rather than
   repeating the color-mix(). */
.hero,
.hero--dark {
  background: var(--surface-dark);
  background: linear-gradient(180deg, var(--surface-dark) 0%, var(--surface-dark-warm) 100%);
  color: var(--on-dark);
}
.hero--light { background: var(--surface); color: var(--ink); }

.hero__support { color: inherit; }
.hero__signals { color: var(--on-dark-70); }
.hero--light .hero__signals { color: var(--ink-70); }

/* Gold is legible on Soft Black (9.58:1) but carries no meaning on ivory
   (1.87:1), so the light variant uses --gold-text (5.63:1) for this line. */
.hero__contexts { color: var(--gold); }
.hero--light .hero__contexts { color: var(--gold-text); }
.hero--light .hero__contexts .sep { color: var(--ink-70); }

/* DESIGN ANOMALY, found 16 Sep 2026: the hero's fixed content order (R-12)
   is a single left-aligned text column, so at any width past the text
   measure the hero is mostly bare Soft Black — the "looks weak" complaint,
   most visible at ordinary desktop widths where the text fills under half
   the viewport. The signature the rest of the site already has — the Ring —
   was never brought into the one place creative-north-star.md names for it.
   Purely decorative (aria-hidden, set on the SVG itself by ring()), so it
   carries no information and needs no place in the mobile content order.
   `color` here, not a fill/stroke override, because ring-mark.svg's circle
   and monogram both paint via currentColor (partials.mjs). Desktop only —
   see the template comment for why phone gets nothing here rather than a
   shrunk version.

   POSITIONED AGAINST .hero__inner, NOT .hero. `top: 50%` against .hero
   itself was measured resolving to ~990px on a 1920px-wide screen — half of
   the section's FULL rendered height, which includes home-threshold sitting
   below the text as .hero__inner's own sibling (the image is full-bleed and
   often ~1300px tall). That put the ring inside the photograph, entirely
   below the initial viewport, which is also why it never drew: the Ring's
   own IntersectionObserver needs 50% of it visible before it arms (motion
   spec §2.2), and a ring sitting in the photograph on load is never in the
   viewport before a scroll to see it. .hero__inner is only the text stack,
   so 50% of its height centres the ring against the words it sits beside. */
.hero__ring { display: none; }
@media (min-width: 64rem) {
  .hero__inner { position: relative; }
  .hero__ring {
    display: block;
    position: absolute;
    top: 50%;
    right: 0;
    transform: translateY(-50%);
    width: clamp(11rem, 6rem + 14vw, 17rem);
    height: auto;
    color: var(--gold);
    opacity: 0.85;
  }
}
.hero--light .hero__signals li + li::before { background: var(--gold-text); }

/* --- G6. Checkbox and radio boxes were 18px. -------------------------------
   The 44px target is satisfied by the whole .check label row, which is the
   correct pattern, but an 18px box is small to aim at and small to see. 22px
   keeps the row as the target while making the control itself legible.
   The checked-state boundary must clear 3:1 (a11y §1.3); accent-color uses the
   burgundy, which is 10.31:1 against ivory. */
.check { align-items: center; gap: var(--space-12); }
.check input {
  inline-size: 1.375rem;
  block-size: 1.375rem;
  min-height: 0;
  margin: 0;
  flex: none;
  accent-color: var(--accent);
}
.section--dark .check input, .on-dark .check input { accent-color: var(--on-dark); }

/* --- G7. REAL horizontal overflow on /prices/. SC 1.4.10. ------------------
   MEASURED on the built page at a true 320px viewport: scrollWidth 651 against
   clientWidth 320. Offenders were .treatment__name and .treatment__from,
   rendering 414–627px wide inside a 320px column.

   CAUSE: .treatment__from carried `white-space: nowrap`, and the template fills
   it with a whole price SENTENCE — "from £95 (60 min) · £130 (90 min) · £165
   (120 min)". As a flex item with the default `min-width: auto`, a nowrap
   sentence cannot shrink below its min-content width, so it set the flex line's
   width and pushed the line past the viewport.

   `white-space: nowrap` belongs on an individual price TOKEN ("£95"), so a
   figure never breaks across lines — never on a sentence containing several of
   them. .price keeps it; the container does not.

   `min-width: 0` on the flex children is the companion fix: without it a flex
   item refuses to shrink below min-content no matter what wraps inside it, and
   one long unbreakable word anywhere in a row inflates the whole document. */
.treatment__head > * { min-width: 0; }
.treatment__name { min-width: 0; overflow-wrap: break-word; }
.treatment__from {
  white-space: normal;          /* the sentence wraps... */
  min-width: 0;
  margin-left: 0;               /* auto margin + wrapping produces odd lines */
}
.treatment__from .price,
.treatment__from .nowrap { white-space: nowrap; }   /* ...each figure does not */

/* The same class of defect, pre-empted on every other flex row in the system.
   A flex or grid child at `min-width: auto` is the single most common cause of
   a document wider than its viewport, and it is invisible in CSS review. */
.site-header__inner > *,
.hero__actions > *,
.treatment__actions > *,
.price-row > *,
.footer__legal > *,
.action-bar > * { min-width: 0; }
/* The demonstration ribbon's inner container carries the same guard, in mock.css. */

/* --- G8. Book Now must never be hidden by a section reveal. ----------------
   MEASURED on /treatments/: a `.wrap.ceremonial` block containing the Book Now
   CTA was sitting at `opacity: 0`, below the fold, waiting for the reveal
   observer.

   That is the reveal's designed pre-state and it is correct while the script is
   healthy. But the pre-state is gated on [data-motion="on"], which a tiny
   inline <head> script sets, while the code that clears it lives in a separate
   module at the end of <body>. If that module fails to parse or is blocked, the
   attribute is set and never cleared, and the content stays invisible
   permanently. For an ordinary paragraph that is a degraded page. For the
   primary CTA it is the booking journey disappearing.

   R-CD-04 is explicit that a flourish costing a single tap on the way to Book
   Now loses. So the primary action opts out of the reveal entirely: a block
   containing it is never hidden, whatever the script does.

   (The underlying fragility — pre-state set by one script, cleared by another —
   is the motion/frontend owners' to resolve. This is the design system
   refusing to let it reach Book Now.) */
[data-motion="on"] [data-reveal=""]:has(.btn--primary),
[data-motion="on"] [data-reveal=""]:has(.action-bar),
[data-motion="on"] [data-reveal=""]:has([data-legal]) {
  opacity: 1;
  transform: none;
}
/* [data-legal] is on that list for the same reason as Book Now, one step
   stronger: NN-08 and NN-09 are not design elements, they are statements we are
   required to show with every price (R-19). A disclosure that is present in the
   DOM but painted at opacity 0 because a script failed is, to a reader and to a
   regulator, a disclosure that was not made. It must not depend on JavaScript.

   Prices themselves are deliberately NOT on this list. Protecting everything is
   the same as deleting the reveal, and the line has to sit somewhere defensible:
   the primary action, and text we are legally obliged to display. */

/* =============================================================================
   H. SURFACE CONTEXT — killing the invisible-text class of defect
   -----------------------------------------------------------------------------
   Found by tools/render-contrast.mjs, which measures what the browser actually
   PAINTED rather than what the tokens say. Two live failures on every page:

     p.footer__legal.meta   rgba(11,11,11,.7) on rgb(11,11,11)   1.00:1
     a.link-more            rgb(122,92,46)    on rgb(11,11,11)   3.18:1

   Both are the same mistake as the hero's invisible supporting line, and it is
   worth naming precisely because it will happen again otherwise:

     A utility hard-codes a light-surface colour. Its dark-surface override is
     written as `.section--dark .muted`. A container that is dark but is NOT
     called .section--dark — the footer, the header, the hero, the action bar,
     the ribbon — never matches the override, so the light colour survives onto
     a dark background. Every token involved is individually correct. The
     combination is invisible.

   Listing more selectors in front of every override is the fix that fails next
   time, because the next dark container has to be remembered in a dozen places.
   Instead the surface declares its own context ONCE, and the utilities read it.
   Adding a dark container now means adding it to the one list below.

   Same pattern already used for --focus-color and --btn-primary-bg. This
   extends it to text, which is where it actually hurt. */

:root {
  --text-secondary: var(--ink-70);   /* measured 7.23:1 on --surface      */
  --gold-ctx: var(--gold-text);      /* 5.63:1 on ivory; gold is 1.87:1   */
}

/* THE canonical list of dark surfaces. Add new dark containers HERE, once. */
.section--dark,
.on-dark,
.site-header,
.site-footer,
.hero,
.hero--dark,
.action-bar,
.surface-dark-raised {
  /* The demonstration ribbon carries these two in mock.css — mock-only selectors
     cannot live in this file, and the build fails if one appears here. */
  --text-secondary: var(--on-dark-70);  /* measured 8.89:1 on --surface-dark */
  --gold-ctx: var(--gold);              /* 9.58:1 on Soft Black              */
}

/* The light hero opts back out — it is the one container in that list whose
   surface can flip. */
.hero--light {
  --text-secondary: var(--ink-70);
  --gold-ctx: var(--gold-text);
}

.muted,
.meta { color: var(--text-secondary); }

/* --gold-ctx, not --gold-text: on Soft Black the Deep Bronze measures 3.18:1
   and fails the 4.5:1 text floor. Gold is the correct member of the family
   there (9.58:1) and Deep Bronze is correct on ivory. The context picks. */
.link-more,
.btn--ghost,
.btn--quiet,
.link-label { color: var(--gold-ctx); }
.link-label { border-bottom-color: var(--gold-ctx); }
.eyebrow,
.label,
.process__label,
.footer__col h2,
.footer__cols h2 { color: var(--gold-ctx); }

/* =============================================================================
   I. THE HERO MICROLINE — a separator must never begin a line
   -----------------------------------------------------------------------------
   OBSERVED at 375px: the reassurance microline wrapped as
     "Qualified therapists"
     "• Final price confirmed before you book"
   The separator was a ::before on the SECOND item, so when the list wrapped the
   gold dot led the new line. A separator at the start of a line stops reading
   as a divider and starts reading as a bullet — and a gold bullet in front of a
   short benefit phrase is very close to the "list of benefits" and "imitates a
   rating" readings that §5 and §8 rule out.

   Two changes, belt and braces:
     1. On a phone the items simply stack, with no separator at all. Two short
        reassurances on their own lines read calmer than a bulleted pair, and a
        separator that does not exist cannot be misread.
     2. From 48rem the separator returns, but attached to the PRECEDING item as
        an ::after inside that item's own flex box. It now travels with the
        phrase in front of it, so even an unexpected wrap cannot leave it
        stranded at the start of a line.
   ============================================================================= */
.hero__signals {
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-4);
}
/* Neutralise the original leading separator in both directions. */
.hero__signals li + li::before { content: none; }

@media (min-width: 48rem) {
  .hero__signals {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    column-gap: var(--space-16);
    row-gap: var(--space-8);
  }
  .hero__signals li:not(:last-child)::after {
    content: "";
    inline-size: 3px;
    block-size: 3px;
    border-radius: 50%;
    background: var(--gold-ctx);
    margin-inline-start: var(--space-16);
    flex: none;
  }
}

/* mock.css */
/* Mock-mode styling only.
   scripts/build.mjs appends this file to dist/styles/site.css ONLY while
   site.json -> build.mode is "mock". Flip the flag to "live" and not one byte of
   this file ships — which is why nothing here may style anything the real site
   uses. Keep it to .mock-ribbon and .mock-notice. */

/* ============ Demonstration ribbon ============ */
/* In normal flow at the very top of the document, never position:fixed — it must
   not cover Book Now or steal height from the 375px hero (register §8). */
.mock-ribbon {
  background: var(--ink);
  color: var(--on-dark);
  border-bottom: var(--hairline) solid var(--gold-line);
  font-size: var(--step--1);
  line-height: var(--leading-body);

  /* The ribbon is a dark surface, so it re-points the three focus-ring
     properties exactly as base.css does for .section--dark and friends. These
     three lines live here rather than in that selector list on purpose: a mock
     selector in base.css would ship to every live build forever as a dead rule,
     and the build fails on exactly that. Three duplicated lines in a file that
     only exists in mock mode is the cheaper side of the trade. */
  --focus-color: var(--focus-dark);
  --focus-gap: var(--surface-dark);
  --focus-echo: transparent;

  /* Dark-surface button tokens, mirroring the .on-dark family in components.css.
     Moved here for the same reason as the focus properties above.
     NOTE: the ribbon contains no .btn--primary today, so this block may well be
     dead — preserved rather than silently pruned, because it was authored
     deliberately. Drop it if you own that rule and agree it is unused. */
  --btn-primary-bg: var(--on-dark);
  --btn-primary-fg: var(--ink);
  --btn-primary-bg-hover: var(--surface-raised);
  --btn-primary-bg-active: #ede7d9;

  /* Dark-surface text/gold context tokens, mirroring the same .on-dark family. */
  --text-secondary: var(--on-dark-70);
  --gold-ctx: var(--gold);

  /* v1.1: dismissing fades the ribbon out before it leaves the flow. */
  opacity: 1;
  transition: opacity var(--dur-fade-out) var(--ease-calm),
    display var(--dur-fade-out) allow-discrete;
}
.mock-ribbon[hidden] { display: none; opacity: 0; }
.mock-ribbon__inner {
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: var(--space-2xs) var(--space-s);
  padding-block: var(--space-2xs);
}
/* Flex/grid children default to min-width:auto and refuse to shrink below their
   content, which is what pushes a document wider than its viewport. Mirrors the
   overflow guard in components.css for the real layout containers. */
.mock-ribbon__inner > * { min-width: 0; }
.mock-ribbon__text { margin: 0; flex: 1 1 18rem; max-width: none; }
.mock-ribbon__dismiss {
  flex: 0 0 auto;
  min-height: var(--target);
  background: none;
  border: var(--hairline) solid var(--line-on-dark);
  border-radius: var(--radius);
  color: var(--on-dark);
  font: inherit;
  padding: var(--space-3xs) var(--space-xs);
  cursor: pointer;
}
.mock-ribbon__dismiss:hover { border-color: var(--gold-line); }
.mock-ribbon__dismiss:focus-visible { outline: var(--focus-width) solid var(--focus-on-dark); outline-offset: var(--focus-offset); }

/* ============ Draft notice at the top of each legal page ============ */
.mock-notice {
  background: var(--surface-sunken);
  border-top: var(--hairline) solid var(--line);
  border-bottom: var(--hairline) solid var(--line);
  padding: var(--space-xs) 0;
  font-size: var(--step--1);
  color: var(--text-muted);
}
.mock-notice p { margin: 0; max-width: none; }
