/* ==========================================================================
   Wellway External Services — foundation
   Day 1: design tokens, shared shell, theme toggle.
   Palette taken directly from assets/logo.svg.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Tokens
   -------------------------------------------------------------------------- */

:root {
  /* Brand — straight from the logo */
  --ink:        #0b0c0d;   /* logo background circle */
  --mint:       #8fc9ab;   /* the W, underline, "EXTERNAL SERVICES" */
  --bone:       #f5f3ee;   /* "WELLWAY" wordmark */

  /* Light-mode accent.
     --mint on --bone is ~1.9:1 and fails WCAG badly, so light mode
     needs a darkened companion. This one measures ~4.6:1 on bone. */
  --mint-deep:  #2f7a56;

  /* Semantic tokens — dark theme is the default, matching the logo.
     color-scheme keeps scrollbars and native controls (the booking page's
     date and time inputs, when they land) in step with the theme instead of
     always rendering light. */
  color-scheme: dark;
  --bg:          var(--ink);
  --bg-raised:   #14161a;
  --bg-sunken:   #08090a;
  --text:        var(--bone);
  --text-muted:  #9ba1a5;
  --accent:      var(--mint);
  --accent-text: var(--ink);      /* text sitting on an accent fill */
  --border:      #23272c;
  --shadow:      0 1px 2px rgb(0 0 0 / .4), 0 8px 24px rgb(0 0 0 / .3);

  /* Hero wash — see section 12. --grime is the film; a translucent layer over
     the headline drops its contrast whichever way round the theme runs, which
     is the whole of what haze does and why this needs no per-theme filter to
     go with it. Only the colour changes: cool and grey on the dark theme, warm
     and brown on bone. --sheen is the light passing over the clean pane. */
  --grime: rgb(126 138 130 / .46);
  --sheen: rgb(238 250 244 / .5);

  /* Failure has to be legible, not decorative — see the fail-loudly rule in
     CLAUDE.md. Both values clear 4.5:1 on their own background. */
  --danger: #ff8a8a;

  /* Type */
  --font-display: Georgia, 'Times New Roman', 'Iowan Old Style', serif;
  --font-body:    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
                  'Helvetica Neue', Arial, sans-serif;

  --step--1: clamp(0.83rem, 0.8rem + 0.15vw, 0.9rem);
  --step-0:  clamp(1rem, 0.96rem + 0.2vw, 1.09rem);
  --step-1:  clamp(1.2rem, 1.1rem + 0.5vw, 1.5rem);
  --step-2:  clamp(1.5rem, 1.3rem + 1vw, 2.1rem);
  --step-3:  clamp(1.9rem, 1.5rem + 1.9vw, 3rem);
  --step-4:  clamp(2.3rem, 1.6rem + 3.2vw, 4.2rem);

  /* Space */
  --sp-1: 0.25rem;
  --sp-2: 0.5rem;
  --sp-3: 0.75rem;
  --sp-4: 1rem;
  --sp-5: 1.5rem;
  --sp-6: 2rem;
  --sp-7: 3rem;
  --sp-8: 4.5rem;
  --sp-9: 7rem;

  /* Shape */
  --radius-sm: 6px;
  --radius:    12px;
  --radius-lg: 20px;
  --radius-full: 999px;

  --container: 1140px;
  --header-h: 72px;

  /* Split so the view-transition rules below can reuse the duration on its
     own — animation-duration can't take the combined shorthand. */
  --transition-dur:  180ms;
  --transition-ease: cubic-bezier(.4, 0, .2, 1);
  --transition:      var(--transition-dur) var(--transition-ease);
}

/* Light theme — two triggers, identical values. Keep the blocks in sync.

   The media query is the fallback for a visitor who hasn't chosen: with JS
   off nothing sets data-theme, so CSS alone has to honour the OS. (This is
   also why <html> carries no hardcoded data-theme.) The attribute block is
   an explicit choice and must win, which it does by coming second at equal
   specificity — :root:not([data-theme='dark']) and :root[data-theme='light']
   are both (0,2,0). Someone on a light OS who picks dark is excluded by the
   :not(), and falls back to the :root dark tokens above. */

@media (prefers-color-scheme: light) {
  :root:not([data-theme='dark']) {
    color-scheme: light;
    --bg:          var(--bone);
    --bg-raised:   #ffffff;
    --bg-sunken:   #ece9e2;
    --text:        var(--ink);
    --text-muted:  #5d6367;
    --accent:      var(--mint-deep);
    --accent-text: #ffffff;
    --border:      #dcd8cf;
    --shadow:      0 1px 2px rgb(11 12 13 / .06), 0 8px 24px rgb(11 12 13 / .07);
    --grime: rgb(104 88 64 / .40);
    --sheen: rgb(255 255 255 / .95);
    --danger: #b3261e;
  }
}

:root[data-theme='light'] {
  color-scheme: light;
  --bg:          var(--bone);
  --bg-raised:   #ffffff;
  --bg-sunken:   #ece9e2;
  --text:        var(--ink);
  --text-muted:  #5d6367;
  --accent:      var(--mint-deep);
  --accent-text: #ffffff;
  --border:      #dcd8cf;
  --shadow:      0 1px 2px rgb(11 12 13 / .06), 0 8px 24px rgb(11 12 13 / .07);
  --grime: rgb(104 88 64 / .40);
  --sheen: rgb(255 255 255 / .95);
  --danger: #b3261e;
}

/* Theme switching — the whole page changes in one paint.

   Several components below transition colour or border-colour for their hover
   states (.nav a, .theme-toggle, .btn). Left alone, those also animate when
   the theme flips, so they'd lag behind everything else and the page would
   change in two waves. theme.js hangs this class on <html> just long enough
   to commit the new colours with every transition switched off, so all of it
   lands on the same frame.

   Animating the switch by transitioning colours is a dead end worth not
   re-attempting: text that inherits its colour rather than declaring it
   doesn't get a transition of its own, so it holds the old colour for the
   whole duration and then snaps, and interpolating drags text through
   ~2.2:1 contrast at the midpoint going from light-on-dark to
   dark-on-light. The fade below avoids both by never interpolating a
   colour — see the view-transition rules. */

.theme-switching,
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after {
  transition: none !important;
}

/* The fade. The browser snapshots the page before and after the (already
   instant) switch and cross-fades the two images, so every element still
   holds a correct colour the entire time — there is no half-way ink.
   theme.js only reaches for this where it exists; everywhere else, and
   under reduced motion, the switch stays instant. */

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: var(--transition-dur);
    animation-timing-function: var(--transition-ease);
  }
}

/* --------------------------------------------------------------------------
   2. Base
   -------------------------------------------------------------------------- */

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

* { margin: 0; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-h) + var(--sp-4));
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--step-0);
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

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

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

h1 { font-size: var(--step-4); }
h2 { font-size: var(--step-3); }
h3 { font-size: var(--step-1); }

p { text-wrap: pretty; max-width: 68ch; }

a { color: inherit; text-decoration-color: color-mix(in srgb, var(--accent) 55%, transparent); text-underline-offset: 3px; }
a:hover { text-decoration-color: var(--accent); }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}

::selection { background: var(--accent); color: var(--accent-text); }

/* --------------------------------------------------------------------------
   3. Layout helpers
   -------------------------------------------------------------------------- */

.container {
  width: min(100% - 2.5rem, var(--container));
  margin-inline: auto;
}

.section { padding-block: var(--sp-8); }
.section--tight { padding-block: var(--sp-7); }

.eyebrow {
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: var(--sp-3);
}

.muted { color: var(--text-muted); }
.lead  { font-size: var(--step-1); color: var(--text-muted); }

.grid { display: grid; gap: var(--sp-5); }
@media (min-width: 720px) {
  .grid--2 { grid-template-columns: repeat(2, 1fr); }
  .grid--3 { grid-template-columns: repeat(3, 1fr); }
}

.card {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--sp-5);
  box-shadow: var(--shadow);
}

.skip-link {
  position: absolute;
  left: var(--sp-4);
  top: var(--sp-4);
  z-index: 100;
  padding: var(--sp-2) var(--sp-4);
  background: var(--accent);
  color: var(--accent-text);
  border-radius: var(--radius-sm);
  transform: translateY(-200%);
  transition: transform var(--transition);
}
.skip-link:focus { transform: translateY(0); }

/* --------------------------------------------------------------------------
   4. Buttons
   -------------------------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: 0.7rem 1.4rem;
  border: 1px solid transparent;
  border-radius: var(--radius-full);
  font-family: var(--font-body);
  font-size: var(--step-0);
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
  transition: transform var(--transition), background var(--transition),
              border-color var(--transition), opacity var(--transition);
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }

.btn--primary { background: var(--accent); color: var(--accent-text); }
.btn--primary:hover { opacity: .88; }

.btn--ghost { background: transparent; color: var(--text); border-color: var(--border); }
.btn--ghost:hover { border-color: var(--accent); color: var(--accent); }

.btn--sm { padding: 0.5rem 1rem; font-size: var(--step--1); }

.btn[disabled] { opacity: .55; cursor: default; transform: none; }
.btn[disabled]:hover { transform: none; opacity: .55; }

/* --------------------------------------------------------------------------
   4b. Form controls
   -------------------------------------------------------------------------- */

/* Shared, not contact-specific. The booking page is the most important page on
   the site and the admin page is all inputs, so these live up here with the
   buttons rather than in a page section further down.

   font: inherit on every control is load-bearing. Browsers default form
   elements to a 13px system face that ignores the page entirely, so without it
   the inputs would be the one place on the site not using the type scale. */

.form { display: grid; gap: var(--sp-5); max-width: 42rem; }

.form__row { display: grid; gap: var(--sp-5); }
@media (min-width: 620px) {
  .form__row--2 { grid-template-columns: 1fr 1fr; }
}

.field { display: grid; gap: var(--sp-2); }

.field label { font-size: var(--step--1); font-weight: 600; }

/* "Optional" belongs in the label, not in placeholder text — placeholders
   vanish the moment someone starts typing, which is exactly when they'd want
   to check whether they had to fill it in. */
.field label .optional { font-weight: 400; color: var(--text-muted); }

.input,
.textarea {
  font: inherit;
  width: 100%;
  min-height: 44px;
  padding: 0.65rem 0.85rem;
  color: var(--text);
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  transition: border-color var(--transition), box-shadow var(--transition);
}
.textarea { min-height: 9rem; line-height: 1.6; resize: vertical; }

.input::placeholder,
.textarea::placeholder { color: var(--text-muted); opacity: 1; }

.input:hover,
.textarea:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }

.input:focus-visible,
.textarea:focus-visible { border-color: var(--accent); }

/* aria-invalid rather than :invalid. :invalid matches an empty required field
   before it has been touched, so the form would load already covered in red. */
.input[aria-invalid='true'],
.textarea[aria-invalid='true'] { border-color: var(--danger); }

.field__error {
  font-size: var(--step--1);
  color: var(--danger);
}
.field__error:empty { display: none; }

/* Off-screen rather than display:none — a field a bot can't see at all is one
   it also can't be tempted to fill in. aria-hidden and tabindex keep it away
   from screen readers and the tab order. */
.form__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.form__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-4); }

/* Holds its height whether or not it has anything in it, so the submit button
   doesn't jump down the page the moment a message appears under it. */
.form__status {
  font-size: var(--step--1);
  min-height: 1.6em;
  color: var(--text-muted);
}
.form__status[data-state='ok']    { color: var(--accent); }
.form__status[data-state='error'] { color: var(--danger); }
.form__status a { color: inherit; font-weight: 600; }

/* --------------------------------------------------------------------------
   5. Header + nav
   -------------------------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  height: var(--header-h);
  display: flex;
  align-items: center;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--border);
}

.site-header__inner {
  display: flex;
  align-items: center;
  gap: var(--sp-5);
  width: min(100% - 2.5rem, var(--container));
  margin-inline: auto;
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  text-decoration: none;
  margin-right: auto;
}
/* The header mark, inlined into every page's markup.

   Sized by its ink, not by a padded box. The mark's viewBox is cropped to the
   artwork with the stroke included, so `height` here is exactly what renders.
   The old circular badge filled its 38px box edge to edge; this one carries no
   circle, so reusing 38px would draw it at half the size.

   1.42rem is the text block beside it — cap-top of WELLWAY down to the baseline
   of "External Services", which measures 22.68px against 16px Georgia. Matching
   a mark to the text block rather than to an arbitrary square is what makes the
   two read as one lockup. Re-derive it if the brand type sizes change.

   Colour comes from --accent, the same token as .brand__sub, so the mark and the
   subtitle can never drift into two different greens: real mint on dark, deeper
   mint on light where #8fc9ab would only reach ~1.9:1.

   Inline rather than <img src="logo-mark-mono.svg"> because currentColor can't
   cross an <img> boundary — it would resolve black and vanish in dark mode. */
.brand__mark {
  display: block;
  height: 1.42rem;
  width: auto;
  color: var(--accent);
}
.brand__text { display: flex; flex-direction: column; line-height: 1.05; }
/* The wordmark never wraps. Left to wrap it splits "EXTERNAL SERVICES" over
   two lines and squeezes the logo when the header row gets tight; the narrow
   breakpoint in section 5 drops the text outright instead. */
.brand__name {
  font-family: var(--font-display);
  font-size: 1rem;
  letter-spacing: 0.16em;
  white-space: nowrap;
}
.brand__sub {
  font-size: 0.6rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--accent);
  white-space: nowrap;
}

.nav { display: flex; align-items: center; gap: var(--sp-5); }
.nav a {
  font-size: var(--step--1);
  font-weight: 500;
  letter-spacing: 0.02em;
  text-decoration: none;
  color: var(--text-muted);
  transition: color var(--transition);
}
.nav a:hover { color: var(--text); }
.nav a[aria-current='page'] { color: var(--accent); }

.header-actions { display: flex; align-items: center; gap: var(--sp-3); }

.theme-toggle {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-full);
  background: transparent;
  color: var(--text);
  cursor: pointer;
  transition: border-color var(--transition), color var(--transition);
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.theme-toggle svg { width: 17px; height: 17px; }
.theme-toggle .icon-sun  { display: none; }
[data-theme='light'] .theme-toggle .icon-sun  { display: block; }
[data-theme='light'] .theme-toggle .icon-moon { display: none; }

.nav-toggle { display: none; }

/* Mobile nav */
@media (max-width: 860px) {
  .nav {
    position: fixed;
    inset: var(--header-h) 0 auto 0;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    padding: var(--sp-4) 1.25rem var(--sp-6);
    background: var(--bg);
    border-bottom: 1px solid var(--border);

    /* The closed panel has to travel its own height *plus* the header, because
       it is anchored at top: var(--header-h) rather than at 0. A plain -100%
       lands its bottom edge exactly on --header-h, still on screen.

       -120% used to paper over that, but only for panels taller than 360px
       (72 / 0.2). With three links the panel is ~251px, so 20% of it — a 22px
       strip — sat across the top of the viewport: a flat band over the header
       that also swallowed clicks aimed at the logo and the tops of the toggle
       buttons, since a fixed element still takes pointer events wherever it
       overlaps. Sizing the travel off the panel's own height was the mistake;
       this is measured off what it actually has to clear. The extra 1px is
       there so fractional panel heights can't leave a hairline behind. */
    transform: translateY(calc(-100% - var(--header-h) - 1px));

    /* Off-screen is not hidden: the links stay in the tab order and would take
       focus into empty space above the fold. Flipping visibility only after the
       slide-out finishes keeps the animation intact in both directions. */
    visibility: hidden;
    transition: transform var(--transition),
                visibility 0s linear var(--transition-dur);
  }
  .nav[data-open='true'] {
    transform: translateY(0);
    visibility: visible;
    transition: transform var(--transition), visibility 0s;
  }
  .nav a {
    padding: var(--sp-4) 0;
    font-size: var(--step-1);
    border-bottom: 1px solid var(--border);
  }
  .nav-toggle {
    display: grid;
    place-items: center;
    width: 38px; height: 38px;
    padding: 0;
    background: transparent;
    border: 1px solid var(--border);
    border-radius: var(--radius-full);
    color: var(--text);
    cursor: pointer;
  }
  /* Narrower gutter here only, so body copy keeps its normal margins. */
  .site-header__inner {
    gap: var(--sp-3);
    width: min(100% - 1.5rem, var(--container));
  }
  .header-actions { gap: var(--sp-2); }
}

/* --------------------------------------------------------------------------
   5b. Mobile quote bar
   -------------------------------------------------------------------------- */

/* On a phone the header can't hold the wordmark, two icon buttons and a
   143px "Book a free quote" — the row needs 376px inside 351px, and the
   brand gets squeezed until "EXTERNAL SERVICES" wraps under the toggle. So
   below 480px the CTA moves out of the header into its own sticky bar,
   full width and full wording, and the header keeps its in-line button
   everywhere it actually fits. Never both at once. */

.cta-bar { display: none; }

@media (max-width: 479px) {
  .cta-bar {
    display: block;
    position: sticky;
    top: var(--header-h);
    z-index: 49;                /* under the header, so the nav panel covers it */
    padding-block: var(--sp-3);
    background: color-mix(in srgb, var(--bg) 88%, transparent);
    backdrop-filter: blur(12px);
    border-bottom: 1px solid var(--border);
  }
  .cta-bar__btn {
    display: flex;
    width: 100%;
  }
  /* The header's own copy would be a second, competing CTA. */
  .header-actions .btn { display: none; }
  /* Anchor targets have to clear both bars now. */
  html { scroll-padding-top: calc(var(--header-h) + 4.5rem); }
}

/* --------------------------------------------------------------------------
   6. Footer
   -------------------------------------------------------------------------- */

.site-footer {
  margin-top: auto;
  padding-block: var(--sp-7) var(--sp-5);
  background: var(--bg-sunken);
  border-top: 1px solid var(--border);
  font-size: var(--step--1);
}
.site-footer__grid {
  display: grid;
  gap: var(--sp-6);
  grid-template-columns: 1fr;
}
@media (min-width: 720px) {
  .site-footer__grid { grid-template-columns: 2fr 1fr 1fr; }
}
.site-footer h2 {
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--sp-3);
}
.site-footer ul { list-style: none; padding: 0; display: grid; gap: var(--sp-2); }
.site-footer a { text-decoration: none; color: var(--text-muted); }
.site-footer a:hover { color: var(--accent); }
/* The horizontal lockup, inlined into the markup rather than loaded through
   <img src="logo-horizontal-mono.svg">. That variant is drawn entirely in
   currentColor, and an SVG behind <img> is a separate document that can't see
   this page — currentColor would resolve against the image's own root and come
   out black, invisible in dark mode. Inline, it inherits the colour below and
   follows the theme toggle, not just the OS.

   Held a step back from --text so the mark reads as a sign-off rather than
   competing with the tagline under it. */
.footer-logo {
  display: block;
  width: 190px;
  height: auto;
  color: var(--text-muted);
  margin-bottom: var(--sp-4);
}

.site-footer__tagline {
  font-family: var(--font-display);
  font-size: var(--step-1);
  color: var(--text);
  margin-bottom: var(--sp-3);
}
.site-footer__legal {
  margin-top: var(--sp-6);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--border);
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-4);
  justify-content: space-between;
  color: var(--text-muted);
}

/* --------------------------------------------------------------------------
   7. Day-1 scaffold marker — delete as pages get built
   -------------------------------------------------------------------------- */

.scaffold {
  border: 1px dashed var(--border);
  border-radius: var(--radius);
  padding: var(--sp-6);
  color: var(--text-muted);
  background: var(--bg-raised);
}
.scaffold code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85em;
  color: var(--accent);
}

/* --------------------------------------------------------------------------
   8. Motion preferences
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* --------------------------------------------------------------------------
   9. Home — hero
   -------------------------------------------------------------------------- */

.hero {
  position: relative;
  overflow: hidden;
  padding-block: var(--sp-8) var(--sp-7);
}

.hero__inner {
  display: grid;
  gap: var(--sp-7);
  align-items: center;
}
@media (min-width: 900px) {
  .hero__inner { grid-template-columns: 1.05fr 0.95fr; gap: var(--sp-8); }
}

.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 0.4rem 0.9rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-full);
  background: var(--bg-raised);
  font-size: var(--step--1);
  font-weight: 500;
  color: var(--text-muted);
  margin-bottom: var(--sp-5);
}
.badge::before {
  content: '';
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--accent);
}

.hero h1 { margin-bottom: var(--sp-5); }
.accent { color: var(--accent); }

.hero__lead { font-size: var(--step-1); color: var(--text-muted); margin-bottom: var(--sp-6); }

.hero__actions { display: flex; flex-wrap: wrap; gap: var(--sp-3); }

.hero__media { position: relative; }
.hero__media img {
  width: 100%;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
}

.proof-card {
  position: absolute;
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  font-size: var(--step--1);
  line-height: 1.3;
  max-width: 62%;
}
.proof-card strong { display: block; font-weight: 600; }
.proof-card span { color: var(--text-muted); }
.proof-card__icon {
  display: grid; place-items: center;
  width: 30px; height: 30px;
  flex-shrink: 0;
  border-radius: 50%;
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  color: var(--accent);
}
.proof-card__icon svg { width: 16px; height: 16px; }

.proof-card--tl { top: var(--sp-5); left: calc(-1 * var(--sp-4)); }
.proof-card--br { bottom: var(--sp-5); right: calc(-1 * var(--sp-4)); }

@media (max-width: 560px) {
  .proof-card--tl { left: var(--sp-3); top: var(--sp-3); }
  .proof-card--br { right: var(--sp-3); bottom: var(--sp-3); }
}

/* Stats strip */
.stats {
  display: grid;
  gap: var(--sp-5);
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  margin-top: var(--sp-8);
  padding-top: var(--sp-6);
  border-top: 1px solid var(--border);
}
.stat__figure {
  font-family: var(--font-display);
  font-size: var(--step-2);
  line-height: 1;
  color: var(--accent);
  margin-bottom: var(--sp-2);
}
.stat__label { font-size: var(--step--1); color: var(--text-muted); }

/* --------------------------------------------------------------------------
   10. Home — services, trust, work, quote
   -------------------------------------------------------------------------- */

.section__head { max-width: 60ch; margin-bottom: var(--sp-6); }
.section__head h2 { margin-bottom: var(--sp-4); }

/* The space belongs to the lead, not to the heading above it. about.html uses
   .section__head with nothing after the heading, so putting a bottom margin on
   the h1 instead would open a gap there for no reason. */
.section__head .lead { margin-top: var(--sp-4); }

.svc-card { display: flex; flex-direction: column; gap: var(--sp-4); }
.svc-card__price {
  font-family: var(--font-display);
  font-size: var(--step-2);
  color: var(--accent);
  line-height: 1;
}
.svc-card__price small {
  display: block;
  font-family: var(--font-body);
  font-size: var(--step--1);
  color: var(--text-muted);
  margin-top: var(--sp-2);
}
.svc-card ul { list-style: none; padding: 0; display: grid; gap: var(--sp-2); }
.svc-card li { display: flex; gap: var(--sp-3); font-size: var(--step--1); color: var(--text-muted); }
.svc-card li::before { content: '\2713'; color: var(--accent); flex-shrink: 0; }

.why-grid {
  display: grid;
  gap: var(--sp-5);
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.why-item h3 { font-family: var(--font-body); font-size: var(--step-0); font-weight: 600; margin-bottom: var(--sp-2); }
.why-item p { font-size: var(--step--1); color: var(--text-muted); }
.why-item--flag {
  grid-column: 1 / -1;
  padding: var(--sp-5);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.why-item--flag h3 { font-size: var(--step-1); font-family: var(--font-display); font-weight: 400; }

.gallery {
  display: grid;
  gap: var(--sp-4);
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.gallery img {
  width: 100%;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  border-radius: var(--radius);
  border: 1px solid var(--border);
}

.quote-block {
  max-width: 46ch;
  margin-inline: auto;
  text-align: center;
}
.quote-block blockquote {
  font-family: var(--font-display);
  font-size: var(--step-2);
  line-height: 1.3;
  margin-bottom: var(--sp-4);
}
.quote-block cite { font-style: normal; font-size: var(--step--1); color: var(--text-muted); }

.cta-band {
  text-align: center;
  padding: var(--sp-8) var(--sp-5);
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
  background: var(--bg-raised);
}
.cta-band h2 { margin-bottom: var(--sp-4); }
.cta-band p { margin-inline: auto; margin-bottom: var(--sp-6); color: var(--text-muted); }

/* --------------------------------------------------------------------------
   11. About — story
   -------------------------------------------------------------------------- */

/* Text first, photo second. The photo is narrower than the hero's because this
   column is running prose, and prose has a measure to respect: the body sits at
   a 62ch cap so the lines stay readable even when the viewport is wide.

   align-items is start, not center. Will's copy runs four paragraphs and the
   photo is a fixed 4:5 crop, so the two columns are never the same height;
   centering would float the picture in the middle of the text with ragged gaps
   above and below it. Starting both at the top keeps the photo level with the
   opening paragraph, which is where the eye lands first. */
.about-story {
  display: grid;
  gap: var(--sp-7);
  align-items: start;
}
@media (min-width: 900px) {
  .about-story { grid-template-columns: 1.35fr 0.65fr; gap: var(--sp-8); }
}

.about-story__body { max-width: 62ch; }
.about-story__body p + p { margin-top: var(--sp-5); }

/* His last line is the one that lands — "This is home." Given the display face
   and a step up in size it reads as a sign-off rather than a fourth paragraph,
   without needing a pull-quote that would repeat the copy. */
.about-story__close {
  font-family: var(--font-display);
  font-size: var(--step-1);
  color: var(--text);
}

/* Sticky so the photo stays with the reader down the story on tall screens.
   It's a plain block below 900px, where the grid is one column and sticky would
   pin the image over the text it belongs beside. */
@media (min-width: 900px) {
  .about-story__media { position: sticky; top: calc(var(--header-h) + var(--sp-5)); }
}
.about-story__media img {
  width: 100%;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border);
}

/* --------------------------------------------------------------------------
   11b. Contact
   -------------------------------------------------------------------------- */

/* Three ways to reach a person, and deliberately none of them dressed as the
   primary button. The page's call to action is still the booking band at the
   bottom — these are for the visitor who wants a human rather than a form, and
   ranking one of them would just be guessing which. */

.contact-methods {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--sp-4);
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}

.contact-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-3);
}

.contact-card__icon {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--accent) 18%, transparent);
  color: var(--accent);
}
.contact-card__icon svg { width: 20px; height: 20px; }

.contact-card h2 {
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The value carries the display face and the tap target, rather than sitting
   underneath a "click here". overflow-wrap because the email address is long
   enough to overflow a 240px card and a phone has nowhere else to put it. */
.contact-card__value {
  font-family: var(--font-display);
  font-size: var(--step-1);
  line-height: 1.2;
  text-decoration: none;
  overflow-wrap: anywhere;
}
.contact-card__value:hover {
  text-decoration: underline;
  text-decoration-color: var(--accent);
}

.contact-card p { font-size: var(--step--1); color: var(--text-muted); }

/* The where/when/season cards. Same heading treatment as .why-item on the about
   page — body face at 600 rather than the display serif, because these are
   labels on a fact, not headings anyone reads as a title. */
.contact-fact h3 {
  font-family: var(--font-body);
  font-size: var(--step-0);
  font-weight: 600;
  margin-bottom: var(--sp-2);
}
.contact-fact p { font-size: var(--step--1); }

/* --------------------------------------------------------------------------
   11c. Legal — privacy
   -------------------------------------------------------------------------- */

/* One column of running prose at a readable measure, and no .reveal anywhere
   inside it. A legal page is read rather than scrolled past, and text that
   fades in as you travel down it is text you have to wait for. */

.legal { max-width: 68ch; }

.legal h2 {
  font-size: var(--step-1);
  margin-top: var(--sp-7);
  margin-bottom: var(--sp-3);
}
.legal > :first-child { margin-top: 0; }

/* Any two blocks in a row get the same gap, rather than a rule per pairing.
   p + p alone leaves the paragraph after a list welded to its last bullet,
   which is exactly where these sections say "that is the whole list". */
.legal :is(p, ul) + :is(p, ul) { margin-top: var(--sp-4); }

.legal ul {
  padding-left: 1.15rem;
  display: grid;
  gap: var(--sp-3);
}
.legal li::marker { color: var(--accent); }

/* The date is the first thing anyone checks on a policy, so it gets its own
   line above the text rather than a footnote underneath it. */
.legal__updated {
  font-size: var(--step--1);
  color: var(--text-muted);
  padding-bottom: var(--sp-5);
  border-bottom: 1px solid var(--border);
}

/* --------------------------------------------------------------------------
   12. Motion — ambient drift, scroll reveals
   -------------------------------------------------------------------------- */

/* Ambient drift.

   A single soft wash of accent colour moving slowly behind the hero. It lives
   in the `overflow: hidden` that .hero has carried since day one with nothing
   positioned inside it — the clip was already there, waiting.

   --accent, not --mint. The raw mint reads at roughly 1.9:1 on bone, so on the
   light theme the wash simply vanished; --accent resolves to mint on dark and
   mint-deep on light, which stays visible in both. This is the same trap the
   light-mode logo fell into.

   Only transform is animated, so this composites on the GPU and never triggers
   layout or paint. Animating the gradient's position or colour instead would
   repaint a hero-sized area every frame for the life of the page. */
.hero::before {
  content: '';
  position: absolute;
  inset: -45%;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(closest-side, var(--accent), transparent 72%);
  opacity: .13;
  will-change: transform;
  animation: hero-drift 34s var(--transition-ease) infinite alternate;
}

/* The hero's own children have no positioning of their own, so without this
   they would sit under the wash rather than over it. */
.hero > * { position: relative; z-index: 1; }

@keyframes hero-drift {
  from { transform: translate3d(-9%, -7%, 0) scale(1); }
  to   { transform: translate3d(11%, 9%, 0) scale(1.18); }
}

/* Scroll reveals.

   Gated on .js, which the inline <head> snippet sets before first paint. That
   ordering is the whole point: theme.js loads at the end of <body>, so if the
   hidden state lived there, every element would paint visible and then blink
   out. Gating also means a visitor with JS off — and any crawler — gets fully
   visible content instead of a page of invisible boxes.

   Elements are revealed by theme.js, which unobserves each one after its first
   pass so nothing re-animates when you scroll back up. */
.js .reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 560ms var(--transition-ease),
              transform 560ms var(--transition-ease);
}
.js .reveal.is-in { opacity: 1; transform: none; }

/* Gallery tiles cascade rather than landing as one block. Delay is on the tile,
   not the observer, so the stagger holds regardless of which order the
   IntersectionObserver happens to report them in. */
.js .gallery .reveal:nth-child(2) { transition-delay: 60ms; }
.js .gallery .reveal:nth-child(3) { transition-delay: 120ms; }
.js .gallery .reveal:nth-child(4) { transition-delay: 180ms; }
.js .gallery .reveal:nth-child(5) { transition-delay: 240ms; }
.js .gallery .reveal:nth-child(6) { transition-delay: 300ms; }

/* The hero wash.

   The headline does the job the business does: it starts behind a film of grime
   and soft focus, a squeegee takes it off left to right, and the clean glass
   catches the light. Once per session — theme.js marks it seen and the inline
   <head> snippet withholds .hero-wash on the way back, so nobody bouncing
   between here and the booking page sits through it twice.

   Nothing below touches the h1 or the .accent span themselves. The whole effect
   lives in pseudo-elements layered over the text, which is what makes the clean
   state free: it is the design as already drawn, with nothing applied. Reduced
   motion, JS off, a browser with no backdrop-filter, and every repeat visit all
   arrive there by doing less rather than by undoing something.

   The blur lives in the film rather than on the text for one reason. A
   backdrop-filter is clipped along with its own element, so as the clip-path
   edge travels, the text sharpens *behind the squeegee* instead of everywhere
   at once. Blurring the text directly would need a wrapper span — a filter on
   the h1 would blur these overlays too — and could still only fade uniformly,
   which loses the one thing the animation is about: someone wiping in a
   direction.

   All three animations run 2600ms on a 250ms delay, so their percentages are
   measuring the same clock:

     0    – 11.5%     0 – 300ms     dirty, holding
     11.5 – 57.7%   300 – 1500ms    squeegee crosses, film clips away behind it
     57.7 – 67.3%  1500 – 1750ms    clean, holding — the beat that sells it
     67.3 – 88%    1750 – 2288ms    the shine, a second and faster pass
     88   – 100%   2288 – 2600ms    glint on "clearest"

   Easing is per-act, declared inside the keyframes. One curve stretched over
   five acts smears them into a single slow gesture.

   The z-order is explicit, and it has to be. Positioning .accent so the glint
   has something to hang off also promotes it out of the inline content and into
   the same paint step as the film, where tree order puts it second — so
   "clearest" alone came through the grime sharp and bright, most of the way to
   invisible on a wide screen and unmissable on a phone. Numbering the layers
   fixes it, and `isolation` keeps that numbering inside the headline rather
   than letting it compete with .hero__media, whose proof cards are positioned
   too. .accent itself stays unnumbered on purpose: a z-index there would make
   it a stacking context and trap its own glint underneath the film. */

.hero-wash .hero h1 { position: relative; isolation: isolate; }

/* The film. The insets are negative so it covers the glyphs' overhang instead
   of stopping at the line box, and so the pane reads as a pane rather than as
   text with a tint on it.

   There is no flat base layer under the blobs and no mask over them, both for
   the same reason: anything that carries tint all the way to the element's edge
   ends on a hard rectangle, and a hard rectangle reads as a highlighted box
   rather than as dirt. The giveaway is the headline's ragged right edge, where
   the box carries on over bare background with nothing to be dirty on.

   Masking that boundary is the obvious fix and it is the wrong one. A radial
   mask fits an ellipse to a rectangle, so the falloff is spread thin along the
   long axis and crammed into the short one, and the top and bottom still land
   as visible seams. Building the film only out of blobs that have already gone
   transparent before they reach the edge feathers on every side by
   construction, and where they overlap they stack — which is what uneven dirt
   looks like. The stops are placed so each one runs out inside the box: alpha
   reaches zero at 74% of the radius, so a blob centred 30% from an edge can be
   40% wide and still not touch it.

   The one hard edge left is the squeegee's, which is the only one that should
   be hard. */
.hero-wash .hero h1::before {
  content: '';
  position: absolute;
  inset: -0.3em -0.7em;
  z-index: 1;
  pointer-events: none;
  background:
    radial-gradient(40% 42% at 30% 34%, var(--grime), transparent 74%),
    radial-gradient(36% 40% at 66% 66%, var(--grime), transparent 72%),
    radial-gradient(32% 38% at 78% 30%, var(--grime), transparent 74%),
    radial-gradient(34% 40% at 46% 52%, var(--grime), transparent 72%),
    radial-gradient(28% 34% at 20% 70%, var(--grime), transparent 74%);
  /* Invisible at rest, and the keyframes are what make it appear. This is the
     same rule the rest of the effect follows — every base state here is the
     clean one — but it is load-bearing rather than tidy. The fill below is
     `backwards`, which reverts to these declarations once the animation
     finishes; with no opacity of its own the film came back at full strength
     and stayed, leaving the headline permanently dirty. */
  opacity: 0;
  /* Blur and nothing else, which is not a matter of taste. Chromium clips a
     backdrop-filter to the border box and nothing softens that edge — not a
     mask, not the gradients above it. So anything tonal in here, and this
     carried a brightness and a contrast at first, lands as a hard-edged
     rectangle of shifted colour over the flat background. Blur has nothing to
     show on flat colour, which makes its boundary genuinely invisible; the
     dimming is the film's own alpha instead, and that feathers.

     In em, not px. --step-4 runs from 2.3rem to 4.2rem, so a fixed radius that
     reads as haze on a desktop headline is nearly double the strength against
     the smaller type on a phone, where it stopped being readable at all. */
  -webkit-backdrop-filter: blur(0.09em);
  backdrop-filter: blur(0.09em);
  animation: hero-wash-film 2600ms 250ms backwards;
}

/* backwards, not both, on all three: at the end each one should revert to its
   own rules — which are the invisible ones — rather than hold a finished
   frame, a composited layer and, in the film's case, a live backdrop-filter
   for the rest of the page's life. */
@keyframes hero-wash-film {
  /* The leading edge is slanted 6% — the top of a squeegee stroke arrives
     first. Only that edge moves; the right-hand pair sits off the box at 113%
     throughout so the shape never inverts on its way out. */
  0% {
    clip-path: polygon(-1% 0, 113% 0, 113% 100%, -7% 100%);
    opacity: 1;
  }
  11.5% {
    clip-path: polygon(-1% 0, 113% 0, 113% 100%, -7% 100%);
    animation-timing-function: cubic-bezier(.46, .03, .35, 1);
  }
  54% { opacity: 1; }
  57.7%, 100% {
    clip-path: polygon(107% 0, 113% 0, 113% 100%, 101% 100%);
    opacity: 0;
  }
}

/* The light. One band, two passes — the squeegee, then the shine. */
.hero-wash .hero h1::after {
  content: '';
  position: absolute;
  top: -0.18em;
  bottom: -0.18em;
  left: 0;
  width: 12%;
  z-index: 2;
  pointer-events: none;
  /* No plateau in the gradient — a band with a flat middle reads as a bar
     someone drew, and light doesn't have edges. */
  background: linear-gradient(90deg, transparent, var(--sheen) 50%, transparent);
  filter: blur(0.07em);
  opacity: 0;
  /* skewX sits to the left of scaleX deliberately. Transforms apply right to
     left, so this way the narrow second pass is skewed after it's scaled and
     keeps the full 9 degrees; the other order would compress the slant to
     about four and the shine would fall out of step with the stroke that
     preceded it. 9deg is the film's 6% edge slant measured over the
     headline's height. */
  transform: translateX(-58%) skewX(-9deg) scaleX(1);
  animation: hero-wash-sheen 2600ms 250ms backwards;
}

@keyframes hero-wash-sheen {
  /* Pass one, the squeegee: wide, soft, riding the film's leading edge. The
     -58% and 842% are percentages of the band's own width, not the headline's,
     and they put its centre exactly on that edge at both ends — which is why it
     borrows the film's easing rather than choosing its own. */
  0%, 8%    { transform: translateX(-58%) skewX(-9deg) scaleX(1); opacity: 0; }
  11.5%     { transform: translateX(-58%) skewX(-9deg) scaleX(1); opacity: .55;
              animation-timing-function: cubic-bezier(.46, .03, .35, 1); }
  52%       { opacity: .55; }
  57.7%     { transform: translateX(842%) skewX(-9deg) scaleX(1); opacity: 0; }

  /* Back to the start in 2.6ms at zero opacity. The reset has to happen
     somewhere and this is the only place nobody can see it. */
  57.8%     { transform: translateX(-58%) skewX(-9deg) scaleX(.42); opacity: 0; }

  /* Pass two, the shine: narrower, brighter, quicker, and eased on its own
     terms rather than tracking anything. The glass is already clean. */
  67.3%     { transform: translateX(-58%) skewX(-9deg) scaleX(.42); opacity: 0;
              animation-timing-function: cubic-bezier(.33, 0, .3, 1); }
  76%       { opacity: .95; }
  84%       { opacity: .95; }
  88%, 100% { transform: translateX(842%) skewX(-9deg) scaleX(.42); opacity: 0; }
}

/* The glint. --accent rather than a colour of its own: mint on dark,
   mint-deep on light, so it can't fall into the trap the drift comment above
   records. */
.hero-wash .hero h1 .accent { position: relative; }
.hero-wash .hero h1 .accent::after {
  content: '';
  position: absolute;
  top: -0.2em;
  right: -0.32em;
  width: 0.4em;
  height: 0.4em;
  z-index: 3;
  pointer-events: none;
  background: var(--accent);
  clip-path: polygon(50% 0, 57% 43%, 100% 50%, 57% 57%,
                     50% 100%, 43% 57%, 0 50%, 43% 43%);
  opacity: 0;
  transform: scale(0) rotate(-30deg);
  animation: hero-wash-glint 2600ms 250ms backwards;
}

@keyframes hero-wash-glint {
  0%, 88% { opacity: 0; transform: scale(0) rotate(-30deg);
            animation-timing-function: cubic-bezier(.2, .9, .3, 1); }
  94%     { opacity: 1; transform: scale(1) rotate(0deg); }
  100%    { opacity: 0; transform: scale(.55) rotate(14deg); }
}

/* Section 8 globally clamps transition-duration, but it does not stop keyframe
   loops or clear transition-delay — the drift would keep running and the
   staggered tiles would keep waiting. Both need switching off by hand. */
@media (prefers-reduced-motion: reduce) {
  .hero::before {
    animation: none;
    transform: translate3d(0, 0, 0) scale(1.08);
  }
  .js .reveal {
    opacity: 1;
    transform: none;
    transition: none;
    transition-delay: 0s !important;
  }
  /* The wash needs no unwinding, only deleting. Every part of it is a
     pseudo-element, and the headline underneath was never modified, so removing
     them outright leaves exactly the design as drawn. */
  .hero-wash .hero h1::before,
  .hero-wash .hero h1::after,
  .hero-wash .hero h1 .accent::after { content: none; }
}

/* --------------------------------------------------------------------------
   13. Touch targets
   -------------------------------------------------------------------------- */

/* 44x44 is the Apple HIG minimum and WCAG 2.5.8 Target Size (Minimum). Measured
   at 375px, every interactive element on the page sat under it. The footer list
   was worst at 16px tall — and that list holds the tel: link, which on a phone
   is the most important tap on the site.

   Desktop keeps its proportions: anything that would visibly change the layout
   is scoped to a coarse pointer or a narrow viewport. Only the button floor is
   unconditional, and that one is a 3px change nobody will see.

   min-height rather than re-tuned padding throughout — padding is measured
   against clamp()ed font sizes, so it drifts with the viewport. A floor does
   not. */

.btn { min-height: 44px; }

/* .btn--sm is the compact header pill; it stays compact where there's a mouse
   and only takes the floor on touch. */
.btn--sm { min-height: auto; }

/* 27px of content inside a 72px header, so the floor costs nothing visually —
   .brand is already a centred flex row. */
.brand { min-height: 44px; }

/* Keyboard-only, so touch size never really applies — but it sits off-screen
   until focused, so the floor is free and it keeps the audit at zero. */
.skip-link { min-height: 44px; display: inline-flex; align-items: center; }

@media (pointer: coarse), (max-width: 860px) {
  .btn--sm { min-height: 44px; }

  /* The 38px circle is right for the header's rhythm, so grow the control, not
     the ring. 44 still clears --header-h at 72px. */
  .theme-toggle,
  .nav-toggle { width: 44px; height: 44px; }

  /* Restores 8px+ of clear space between the two icon buttons now they are
     44 wide rather than 38. */
  .header-actions { gap: var(--sp-3); }

  /* Footer links: 16px -> 44px. Centring inside the floor is deterministic;
     padding alone would depend on the inherited line-height. The existing
     --sp-2 gap stays so adjacent targets keep the 8px separation the size
     increase would otherwise eat. */
  .site-footer ul a {
    display: flex;
    align-items: center;
    min-height: 44px;
  }

  .site-footer__legal a {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
}
