/* ---- tokens.css ---- */
/* =============================================================================
   TOKENS — brief 3.1, 3.2, 3.5.
   The single definition of every colour, space, radius and duration on the
   site. Nothing downstream invents a value; if a number is needed and is not
   here, it is added here first.
   ========================================================================== */

/* --- Typefaces. Self-hosted, brief 3.2 ------------------------------------
   Latin subset only. Both faces are SIL Open Font Licence — OFL-Gloock.txt and
   OFL-AlegreyaSans.txt ship alongside, which the licence requires.
   No third-party request, so the CSP stays strict and the footer's
   no-third-party claim holds. */

@font-face {
  font-family: 'Gloock';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/gloock-400-latin.woff2') format('woff2');
}

@font-face {
  font-family: 'Alegreya Sans';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/alegreya-sans-400-latin.woff2') format('woff2');
}

@font-face {
  font-family: 'Alegreya Sans';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url('/assets/fonts/alegreya-sans-500-latin.woff2') format('woff2');
}

:root {

  /* --- Palette. brief 3.1 ------------------------------------------------
     Measured contrast, not to be re-derived:
       olive on paper     5.50   AA pass. The button colour.
       white on olive     5.95   AA pass.
       charcoal on paper 12.38   carries all body text.
       charcoal on pale  10.57
       taupe on paper     3.05   FAILS. Never text, never a button label,
       white on taupe     3.30   FAILS. never an eyebrow. Fills and rules only.

     --- forest, added 14 Aug ----------------------------------------------
     A sixth colour, ruled by Sam. The palette's darkest tone was charcoal,
     which is grey: every page ended on grey and the greens never anchored.
     Forest is the dark anchor, and the old design already used this exact
     value, so it is a return rather than an invention.

       paper on forest   14.14   the default pairing on a forest band
       pale on forest    12.10
       pale on olive      4.79   AA pass, so eyebrows on olive use pale
       OLIVE ON FOREST    2.54   FAILS — and this is the one that catches you.

     Olive is the button colour AND the eyebrow colour, so both default to
     unreadable on either dark green. That is why the band classes in base.css
     set their own foregrounds instead of letting anything inherit. */

  --olive:    #556B2F;
  --taupe:    #A7886A;
  --pale:     #DEE7DD;
  --paper:    #F7F6F2;
  --charcoal: #2F2F2F;
  --forest:   #0F2A23;

  --olive-hover:  #465926;
  --olive-deep:   #3D4D21;
  --forest-soft:  #1B3B32;

  /* Form errors. Carried over from the superseded stylesheet, which used them
     without ever measuring them. Measured now:
       --error on paper       6.64   AA pass. Field messages, the * marker,
                                     and the invalid-field border.
       --error-deep on the
       alert's own #F7E9E7    9.70   AA pass. */
  --error:      #A32A1E;
  --error-deep: #6E1C14;

  /* Thermostat glow only. Nowhere else on the site. brief 4.3 */
  --glow-summer:      #8FB7C9;
  --glow-summer-deep: #5C8AA0;
  --glow-winter:      #DCA45E;
  --glow-winter-deep: #B4762F;

  /* --- Type ------------------------------------------------------------- */
  --font-display: 'Gloock', Georgia, 'Times New Roman', serif;
  --font-body:    'Alegreya Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;

  --step-hero:    clamp(2.75rem, 6vw, 5rem);
  --step-section: clamp(1.75rem, 3vw, 2.5rem);
  --step-sub:     clamp(1.5rem, 2.6vw, 2.125rem);
  --step-body:    1.0625rem;
  --step-eyebrow: 0.875rem;

  --leading-body: 1.65;
  --leading-hero: 1.05;
  --tracking-eyebrow: 0.08em;

  /* --- Space. 4px base. Nothing off-scale. brief 3.5 --------------------- */
  --s1:  4px;   --s2:  8px;   --s3:  12px;  --s4:  16px;  --s5:  24px;
  --s6:  32px;  --s7:  48px;  --s8:  64px;  --s9:  96px;  --s10: 128px;

  --section-y: clamp(64px, 9vw, 120px);
  --section-x: clamp(20px, 4vw, 56px);
  --measure:   1200px;

  /* --- Radius. Two values, no third. brief 3.5 --------------------------- */
  --r-control: 4px;   /* buttons, inputs, toggles */
  --r-media:   6px;   /* photographs, cards, panels */

  /* --- Elevation. ONE shadow exists, for the scrolled header. Cards are
         separated by space and tone, never by shadow. brief 3.5 ----------- */
  --shadow-header: 0 1px 0 rgba(47, 47, 47, 0.12);

  /* --- Text over photography. The SECOND shadow on the site, and a stated
         exception to the rule above rather than a drift from it.

         This is not elevation. It is a scrim that follows the letterforms
         instead of covering the picture, so the hero photograph can stay at
         its flat 0.10 tone and the type can still be read.

         Repeated identical blurs compound — a single layer only reaches about
         half its stated alpha at the glyph edge, because the blur has already
         halved it there. So density comes from repetition, not from one heavy
         layer, and that is what keeps these from reading as a glow.

         Two separate jobs live in a stack like this, and only one of them is
         visible. TIGHT layers sit against the letterform and do the legibility
         work. WIDE layers darken a large soft area and are what actually reads
         as "a drop shadow". Measured on the headline: dropping the wide layers
         lifted the surround 2–3px out from 65 to 81 brightness while
         legibility moved only 5.9% -> 6.7% under 4.5:1. Nearly free.

         Size decides the balance, which is why there are three of these.
         Display type shows its halo, so it gets the softest treatment. 15px
         text does not show a 3px halo at all, but needs the density far more,
         because a thin glyph's immediate surround sits closer to bright
         pixels — every softer candidate tried on the trust bar cost it two to
         three times the legibility it cost the headline.

         NOTE: WCAG 1.4.3 compares text colour to background colour and gives
         no credit for shadows. This improves real legibility; it does not
         move the measured ratio. brief 4.2 DoD 3 stands or falls separately. */

  /* Display type. Softened 14 Aug: was four 5px layers plus two 16px ones.
     13.0% of the headline's immediate surround under 4.5:1, from 70.9% bare. */
  --text-on-photo:
    0 0 4px rgba(24, 26, 18, 0.70), 0 0 4px rgba(24, 26, 18, 0.70),
    0 0 4px rgba(24, 26, 18, 0.70);

  /* Sub and masthead nav, 17–19px. 9.9% under 4.5:1, from 22.3% bare. */
  --text-on-photo-sm:
    0 0 3px rgba(24, 26, 18, 0.70), 0 0 3px rgba(24, 26, 18, 0.70),
    0 0 3px rgba(24, 26, 18, 0.70);

  /* Trust bar, 14–15px over the brightest, busiest part of the photograph.
     Keeps a fourth tight layer and a weak wide one — at this size the halo is
     physically too small to read as a shadow, and without them legibility went
     from 8.0% to 32.1% under 4.5:1. 18.2% as set, from 61.3% bare. */
  --text-on-photo-xs:
    0 0 3px rgba(24, 26, 18, 0.75), 0 0 3px rgba(24, 26, 18, 0.75),
    0 0 3px rgba(24, 26, 18, 0.75), 0 0 3px rgba(24, 26, 18, 0.75),
    0 0 9px rgba(24, 26, 18, 0.30);

  /* The filter equivalent, for the things text-shadow cannot reach: the trust
     bar's SVG icons and the reversed logo. Kept as a token so it cannot drift
     away from the type it sits beside. */
  --icon-on-photo:
    drop-shadow(0 0 2px rgba(24, 26, 18, 0.70))
    drop-shadow(0 0 2px rgba(24, 26, 18, 0.70))
    drop-shadow(0 0 8px rgba(24, 26, 18, 0.30));

  /* --- Motion. Nothing outside this vocabulary animates. brief 3.5 ------- */
  --t-fast: 300ms;   /* header state change */
  --t-mid:  400ms;   /* section reveal */
  --t-slow: 800ms;   /* thermostat, and only the thermostat */
  --ease:   cubic-bezier(0.33, 1, 0.68, 1);

  /* --- Legacy aliases -----------------------------------------------------
     site.css (the superseded draft stylesheet) refers to --serif and --sans
     throughout. Pointing them here means the old pages pick up the real
     typography immediately. Both aliases die with site.css. */
  --serif: var(--font-display);
  --sans:  var(--font-body);
}

/* ---- base.css ---- */
/* =============================================================================
   BASE — element defaults built from tokens.css.
   Layout lives in page/component styles, not here. This file only sets what
   every page needs regardless of design: the box model, typography, focus,
   and the quality floor from brief 3.4.
   ========================================================================== */

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

html {
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--charcoal);
  font-family: var(--font-body);
  font-size: var(--step-body);
  line-height: var(--leading-body);

  /* A long word breaks rather than pushing the page sideways. Measured at 200%
     text on a 390px screen, 20 August: the heading "Conservatories" in the
     services picker was 385px inside a 335px column and scrolled the whole
     page 30px. WCAG 1.4.4 asks for 200% without loss of content, and a
     sideways scroll is loss.

     break-word, not anywhere: it only breaks a word that cannot fit on a line
     of its own, so ordinary prose wraps exactly as it did. This trade is what
     makes it safe to set on body rather than hunting the long words one at a
     time — and the vocabulary here guarantees more of them, since
     "conservatories" and "air conditioning" are the whole subject. */
  overflow-wrap: break-word;

  /* House rule, brief 1.3 and 3.4: clip, never hidden. `clip` respects
     border-radius identically but does not create a programmatically
     scrollable container, so nothing can be scrolled to off-screen. */
  overflow-x: clip;
}

/* --- Headings ------------------------------------------------------------ */

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 400;
  text-wrap: balance;
  margin: 0 0 var(--s4);
}

h1 { font-size: var(--step-hero);    line-height: var(--leading-hero); }
h2 { font-size: var(--step-section); line-height: 1.15; }
h3 { font-size: var(--step-sub);     line-height: 1.2; }

p { margin: 0 0 var(--s4); }

/* Eyebrow. Olive, never taupe — taupe measures 3.05:1 on paper and fails AA
   at this size. brief 3.1, and a fault corrected from the approved design. */
.eyebrow {
  font-family: var(--font-body);
  font-size: var(--step-eyebrow);
  font-weight: 500;
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  color: var(--olive);
  margin: 0 0 var(--s3);
}

/* --- Links and focus ----------------------------------------------------- */

/* Underlined at rest, not on hover.

   This used to be `text-decoration: none` with the underline appearing only on
   :hover. Two problems with that, and the second is the one that matters.

   Olive #556B2F against charcoal body text #2F2F2F measures 2.25:1. WCAG
   technique G183 wants 3:1 when colour is the only thing distinguishing a link
   from its surrounding text, AND a non-colour cue on hover and focus. The
   ratio fails, and the non-colour cue existed on hover alone.

   THERE IS NO HOVER ON A PHONE. So on the device most people will read this
   site on, a link in a paragraph was signalled by a 2.25:1 colour shift and
   nothing else. The underline now carries it on every device and the ratio
   stops being load-bearing.

   Buttons opt out — they are already unmistakably interactive, and an
   underlined button label looks broken. */
a { color: var(--olive); text-decoration: underline; text-underline-offset: 3px; }
a:hover { color: var(--olive-deep); }

/* Opt-outs. An underline at rest is right for a link sitting inside a
   sentence, where there is nothing else to mark it. It is wrong everywhere the
   element is already obviously a control:

   - buttons: an underlined button label looks like a rendering fault
   - the logo: it is an image
   - both navs: position and styling already say "navigation", and the header
     nav uses an underline for aria-current="page" — underlining every item
     would delete the only indication of which page you are on
   - .skip-link: a full-bleed olive bar, unmistakable
   - .picker__card: already opts out in its own rule, listed here for the
     record rather than repeated */
a.button,
.logo,
.masthead__nav a,
.mobilemenu__nav a,
.skip-link { text-decoration: none; }

/* Visible keyboard focus on every interactive element. brief 3.4.
   :focus-visible so a mouse click does not leave a ring behind. */
:focus-visible {
  outline: 3px solid var(--olive);
  outline-offset: 3px;
  border-radius: 2px;
}

/* Against a dark or photographic background, olive has too little contrast to
   read as a focus ring. Anything on a dark ground opts into this. */
.on-dark :focus-visible { outline-color: var(--paper); }

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  background: var(--olive);
  color: #fff;
  padding: var(--s3) var(--s5);
  border-radius: 0 0 var(--r-control) 0;
}
.skip-link:focus {
  left: 0;
  text-decoration: none;
}

/* --- Media --------------------------------------------------------------- */

/* Every image carries explicit width and height (brief 3.4) so the box is
   reserved before it loads. These two rules keep that from distorting it. */
img, picture, svg, video {
  max-width: 100%;
  height: auto;
  display: block;
}

/* --- Layout primitives --------------------------------------------------- */

.wrap {
  max-width: var(--measure);
  margin-inline: auto;
  padding-inline: var(--section-x);
}

.section { padding-block: var(--section-y); }

/* Lede. The paragraph directly under a page or section heading. */
.lede {
  font-size: var(--step-lede, 1.1875rem);
  line-height: 1.55;
  max-width: 62ch;
}

/* --- Bands ----------------------------------------------------------------
   Section tone. Added 14 August because the rebuilt system had no way to
   change one, and it showed: the services page ran 2,035px of unbroken paper
   into 577px of pure white, 70% of the page in one near-white block.

   Every band sets its FOREGROUNDS as well as its background, and that is the
   whole point of them. Olive is both the link colour and the eyebrow colour,
   and olive on forest measures 2.54:1 — so a band that only set a background
   would leave eyebrows and links rendering perfectly and unreadable. Nothing
   here inherits a colour across a tone change.

   Rhythm, not decoration: no more than roughly a screen and a half of
   near-white before a break. Cards flip the other way from their band so they
   always read as objects — see --band-card. */

.band--paper  { background: var(--paper);  color: var(--charcoal); --band-card: var(--pale); }
.band--pale   { background: var(--pale);   color: var(--charcoal); --band-card: var(--paper); }
.band--olive  { background: var(--olive);  color: var(--paper);    --band-card: var(--olive-deep); }
.band--forest { background: var(--forest); color: var(--pale);     --band-card: var(--forest-soft); }

/* Light bands keep the defaults from further up this file. */
.band--pale .eyebrow { color: var(--olive); }

/* Dark bands. Headings go to the lightest tone, eyebrows and links to pale —
   4.79:1 on olive, 12.10:1 on forest, both AA. */
.band--olive h1,  .band--olive h2,  .band--olive h3,
.band--forest h1, .band--forest h2, .band--forest h3 { color: var(--paper); }

.band--olive .eyebrow,
.band--forest .eyebrow { color: var(--pale); }

/* :not(.button) matters. `.band--olive a` is specificity 0,1,1 and
   `.button--light` is 0,1,0, so without it the band repaints the primary
   button's label paper — on a paper button. Measured 1.00:1, which is
   invisible, and it renders perfectly so nothing looks wrong. */
.band--olive a:not(.button),
.band--forest a:not(.button) { color: var(--paper); text-decoration: underline; text-underline-offset: 4px; }
.band--olive a:not(.button):hover,
.band--forest a:not(.button):hover { color: #fff; }

/* Fold in the dark-ground focus ring rather than relying on .on-dark also
   being remembered in the markup. */
.band--olive :focus-visible,
.band--forest :focus-visible { outline-color: var(--paper); }

/* Hairlines, which otherwise vanish on whichever ground they were not
   designed for. */
.band--paper  { --band-rule: rgba(47, 47, 47, 0.14); }
.band--pale   { --band-rule: rgba(47, 47, 47, 0.20); }
.band--olive  { --band-rule: rgba(247, 246, 242, 0.28); }
.band--forest { --band-rule: rgba(222, 231, 221, 0.24); }

/* Available to a screen reader, invisible on screen. The superseded stylesheet
   had one of these and the new system did not, which meant any page that had
   already been rebuilt could not label an icon-only control. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: clip;          /* house rule: clip, never hidden. brief 1.3, 3.4 */
  clip-path: inset(50%);
  white-space: nowrap;
}

/* --- Motion -------------------------------------------------------------- */

/* Section reveal. The only entrance animation on the site. brief 3.5 */
[data-reveal] {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity var(--t-mid) var(--ease), transform var(--t-mid) var(--ease);
}
[data-reveal='in'] { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  [data-reveal] { opacity: 1; transform: none; }
}

/* ---- components.css ---- */
/* =============================================================================
   COMPONENTS — shared across every page: masthead, mobile menu, footer, buttons.
   All names are new. The superseded site.css still styles .site-header and
   .site-footer for the pages not yet rebuilt; those rules are now dead and go
   when it does.
   ========================================================================== */

/* --- Buttons ------------------------------------------------------------- */

.button {
  display: inline-block;
  padding: 15px var(--s6);
  border-radius: var(--r-control);
  font-family: var(--font-body);
  font-size: var(--step-body);
  font-weight: 500;
  line-height: 1.2;
  text-align: center;
  border: 1.5px solid transparent;
  cursor: pointer;
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease);
}

/* White on olive measures 5.95:1 — AA. Olive is the button colour (brief 3.1). */
.button--solid { background: var(--olive); color: #fff; border-color: var(--olive); }
.button--outline { background: transparent; color: var(--charcoal); border-color: var(--charcoal); }
/* For use over photography and on dark bands. */
.button--ghost { background: transparent; color: var(--paper); border-color: var(--paper); }
/* The primary button on a dark band. .button--solid is olive on olive there,
   which is no button at all. Forest on paper measures 14.14:1. */
.button--light { background: var(--paper); color: var(--forest); border-color: var(--paper); }

/* Hover states are guarded, because a touch browser applies :hover on tap and
   then holds it until you tap somewhere else — so every button you pressed
   stayed in its hover colour for the rest of the visit. :active gives touch a
   real press response in its place. */
@media (hover: hover) {
  .button--solid:hover  { background: var(--olive-hover); border-color: var(--olive-hover); color: #fff; }
  .button--outline:hover { background: var(--charcoal); color: var(--paper); }
  .button--ghost:hover  { background: rgba(247, 246, 242, 0.16); color: var(--paper); }
  .button--light:hover  { background: #fff; border-color: #fff; color: var(--forest); }
}

.button--solid:active  { background: var(--olive-deep); border-color: var(--olive-deep); color: #fff; }
.button--outline:active { background: var(--charcoal); color: var(--paper); }
.button--ghost:active  { background: rgba(247, 246, 242, 0.24); color: var(--paper); }
.button--light:active  { background: #fff; border-color: #fff; color: var(--forest); }

/* --- Masthead ------------------------------------------------------------
   Fixed and full-bleed: 100% of the viewport at every width, never a centred
   container. brief 4.1 DoD 1. */

.masthead {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 50;
  padding: var(--s4) var(--section-x);
  transition: background-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}

/* The masthead is fixed, so content needs clearing room. The hero pulls itself
   back up underneath it, which is the whole point of a translucent bar.

   ONE value, used twice, so the two can never drift. They were two hard-coded
   76s, and the bar does not measure 76px at any width — 75 on desktop, 67 on
   mobile and tablet.

   On the homepage the mismatch was invisible because the two cancel exactly.
   On every other page it was a 9px gap, also invisible against 64px of section
   padding. The real bug is the opposite case: the desktop bar's height scales
   with the root font size, because the Get a Quote button is its tallest child.
   At roughly 106% browser text it exceeded a fixed 76px and the fixed bar began
   covering the top of the page; at 200%, which WCAG 1.4.4 requires to work, it
   was hiding about 17px of the first heading on every non-homepage page. The
   calc tracks it instead. Mobile is a flat value — the toggle is built from
   fixed pixels, so that bar never changes height. */
/* 76px is the correct value for default text: 16px padding, a 44px tallest
   child (the toggle, the CTA and each nav link are all pinned to the 44px
   touch-target minimum), 16px padding.

   But it is only a FALLBACK. The bar's height moves with the root font size,
   and the previous two hard-coded 76s were wrong at every width — and wrong
   again the moment the toggle grew to 44px. site.js measures the real height
   with a ResizeObserver and overwrites this, so the reserved space tracks the
   bar exactly at any width and any text-zoom level rather than being guessed
   here and drifting. This value is what a no-JS visitor gets, and it is right
   for them. */
:root { --masthead-h: 76px; }

main { padding-top: var(--masthead-h); }
.hero { margin-top: calc(-1 * var(--masthead-h)); }

.masthead__inner {
  max-width: var(--measure);
  margin-inline: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s5);
}

/* State 1 — over the hero.
   A DARK translucent bar, not the milky light one the approved design used.
   The brief asks for light nav text over the hero, and light text on a light
   translucent bar cannot pass contrast over an unknown photograph. A dark bar
   plus the hero's own top scrim guarantees it regardless of the image. */
.masthead[data-scrolled='false'] {
  background-color: rgba(24, 26, 18, 0.28);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
}

/* State 2 — past the hero. Solid, and the one shadow token on the site. */
.masthead[data-scrolled='true'] {
  background-color: var(--paper);
  box-shadow: var(--shadow-header);
}

/* --- Logo. brief 3.6 ------------------------------------------------------
   Both versions occupy the same grid cell and cross-fade on opacity, so the
   state change costs nothing in layout and the logo moves with the background
   and the nav as one transition (brief 4.1 DoD 3).

   A 640px-wide source displayed at 180px leaves ~1.8x headroom at 3x device
   pixel ratio, so the raster is sharp here. The lack of a vector original is a
   real problem for vans, signage and print above A5 — see brief 1.4 — but not
   for a header at this size. */

.logo {
  display: grid;
  width: 180px;
  line-height: 0;
}
.logo:hover { text-decoration: none; }

.logo img {
  grid-area: 1 / 1;          /* stacked, so no reflow when they swap */
  width: 100%;
  height: auto;
  transition: opacity var(--t-fast) var(--ease);
}

.masthead[data-scrolled='false'] .logo__standard,
.masthead[data-scrolled='true']  .logo__reversed { opacity: 0; }

.masthead[data-scrolled='false'] .logo__reversed,
.masthead[data-scrolled='true']  .logo__standard { opacity: 1; }

@media (max-width: 600px) { .logo { width: 148px; } }

/* --- Masthead nav -------------------------------------------------------- */

.masthead__nav ul {
  display: flex;
  gap: var(--s7);
  align-items: center;
  list-style: none;
  margin: 0;
  padding: 0;
}

.masthead__nav a {
  font-weight: 500;
  /* Inline at 17px the hit box was ~21px tall. An iPad Pro in landscape is
     1366px — a TOUCH device that gets this desktop nav. */
  display: inline-block;
  padding-block: 8px;      /* 8 + 28.05 + 8 = 44.05, the touch minimum exactly */
  transition: color var(--t-fast) var(--ease);
}

/* The unscrolled state only ever sits over the hero photograph, which since the
   scrim went flat is bright behind the nav — measured 2.31:1 with the 0.28 bar
   alone. Same treatment as the hero text: see --text-on-photo in tokens.css.
   The reversed logo is an image, so it needs the filter form. */
.masthead[data-scrolled='false'] .masthead__nav a { color: var(--paper); text-shadow: var(--text-on-photo-sm); }
.masthead[data-scrolled='false'] .masthead__nav a:hover { color: #fff; }
.masthead[data-scrolled='false'] .masthead__toggle { text-shadow: var(--text-on-photo-sm); }
.masthead[data-scrolled='false'] .logo__reversed { filter: var(--icon-on-photo); }
.masthead[data-scrolled='false'] :focus-visible { outline-color: var(--paper); }

.masthead[data-scrolled='true'] .masthead__nav a { color: var(--charcoal); }
.masthead[data-scrolled='true'] .masthead__nav a:hover { color: var(--olive); }

.masthead__nav a[aria-current='page'] { text-decoration: underline; text-underline-offset: 6px; }

.masthead__cta { padding: 11px var(--s5); font-size: 0.9375rem; min-height: 44px; }

/* --- Mobile toggle and overlay ------------------------------------------ */

.masthead__toggle {
  display: none;
  background: none;
  border: 0;
  /* Was padding:var(--s3) around an 11px bar block = 50 x 35. This is the only
     way to reach navigation on every phone and it was 9px under the 44px
     guideline. Sized explicitly so it cannot drift with the bars.
     The media query below switches it to grid, not block, so place-items
     actually centres the bars. */
  padding: 0;
  min-width: 44px;
  min-height: 44px;
  place-items: center;
  cursor: pointer;
}
.masthead__bars { display: block; width: 26px; }
.masthead__bars span {
  display: block;
  height: 2px;
  border-radius: 2px;
  background-color: var(--paper);
  transition: background var(--t-fast) var(--ease);
}
.masthead__bars span + span { margin-top: 7px; }
.masthead[data-scrolled='true'] .masthead__bars span { background: var(--charcoal); }

/* `safe center`, not `center`.

   Plain `justify-content: center` on a scrollable flex container pushes any
   overflow out of BOTH ends, and the top end cannot be scrolled back to —
   scrollTop is already 0 there. Measured with the menu open at 667x375, a
   phone in landscape: 567px of content in a 375px panel put "Home" at
   top: -90px, permanently unreachable.

   `safe` falls back to flex-start the moment the content would overflow, which
   is exactly the behaviour wanted and is what the keyword exists for. */
.mobilemenu {
  position: fixed;
  inset: 0;
  z-index: 60;
  background-color: var(--paper);
  display: flex;
  flex-direction: column;
  justify-content: safe center;
  gap: var(--s6);
  padding: var(--s9) var(--section-x) var(--s8);
  overflow-y: auto;
  overscroll-behavior: contain;   /* stop scroll chaining to the page behind */
}

.mobilemenu[hidden] { display: none; }

/* CSS backstop for the rotation trap. site.js closes the menu when the viewport
   crosses above the toggle's breakpoint, but if the script fails for any reason
   this stops a full-screen overlay covering a page whose only dismiss control
   is already display:none. */
@media (min-width: 1041px) { .mobilemenu { display: none; } }

.mobilemenu__nav ul { list-style: none; margin: 0 0 var(--s6); padding: 0; display: grid; gap: var(--s5); }
/* display:block so the whole row is tappable, not just the label — the empty
   half of each row used to hit nothing. */
.mobilemenu__nav a { font-family: var(--font-display); font-size: 1.75rem; color: var(--charcoal);
                     display: block; padding-block: var(--s2); min-height: 44px; }
.mobilemenu__nav a[aria-current='page'] { color: var(--olive); }
.mobilemenu__contact { margin: var(--s5) 0 0; font-size: 1.125rem; }
/* Its own line above the number, and quieter than it. Block, because the number
   below is the thing being tapped and a label sitting inline beside it would
   read as part of it. Matches .sitefoot__label, which does the same job in the
   footer, rather than inventing a second treatment for one word. */
.mobilemenu__label {
  display: block;
  font-size: var(--step-eyebrow);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  opacity: 0.7;
  margin-bottom: var(--s2);
}
/* The phone number on a trades site is the most valuable tap on the build and
   it was a ~24.5px inline target sitting on the SC 2.5.8 floor. */
.mobilemenu__contact a { display: inline-flex; align-items: center; min-height: 44px; padding-inline: var(--s2); }

/* Short viewports — a phone in landscape has barely 330px to fit six links, a
   button and a phone number into.

   This block MUST come after the .mobilemenu__nav a rule above. It was written
   before it, where it had identical specificity and simply lost, so the whole
   thing was dead code: measured at 667x375 the links were still 62px and the
   panel still overflowed by 367px. Caught by measuring rather than by reading.

   The links stay at 44px minimum — the point is to reduce the type and the
   gaps, never the touch target. */
@media (max-height: 560px) {
  .mobilemenu { padding-block: var(--s6); gap: var(--s4); }
  .mobilemenu__nav ul { gap: var(--s2); margin-bottom: var(--s4); }
  .mobilemenu__nav a { font-size: 1.375rem; padding-block: var(--s1); }
  .mobilemenu__contact { margin-top: var(--s3); }
}

/* Raised from 900px to 1040px when the nav went from five items to six.

   Measured, not guessed: on one line the six labels need 650px, and with the
   logo at 117px, the Get a Quote button at 95px and two 24px gaps the header
   row needs 910px of inner width. The wrap takes 4vw of padding off each side,
   so the nav only fits from about 990px up. Between 900 and 990 the desktop
   nav was still being shown and the six links wrapped onto two lines inside
   the header bar — measured at 920px, where there was 48px of slack and the
   links sat on two rows.

   1040px rather than 990px so there is real headroom: at 1040 the inner width
   is 957px against the 910px needed. It does mean a landscape tablet gets the
   overlay menu, which is the right trade — the overlay is a working menu and a
   two-line header bar is a broken one. */
@media (max-width: 1040px) {
  .masthead__nav, .masthead__cta { display: none; }
  .masthead__toggle { display: grid; }   /* grid, so place-items centres the bars */
  .masthead__call { display: grid; }       /* the only rule that applies here */
}

/* --- Tap to call. Mobile only; see the note in partials/header.html ------- */

/* Hidden above 1040px, where Get a Quote is the single action in the bar.
   Written as min-width rather than a bare default, and that is not a style
   preference. The bare default sat AFTER the max-width:1040px block that
   reveals it, and media queries add no specificity — so source order won and
   the button was display:none at every width. Measured at 390px before this
   was corrected. Two rules that cannot both apply cannot fight. */
@media (min-width: 1041px) {
  .masthead__call { display: none; }
}

/* 44px, the same floor every other target on this site is pinned to and well
   clear of the 24px SC 2.5.8 minimum. The icon itself is 22px; the rest is hit
   area, which is the point.

   margin-left: auto rather than a wrapper element. .masthead__inner is
   space-between, and with the nav and the CTA display:none there would
   otherwise be three visible children spread across the bar. The auto margin
   pushes this and the toggle into a pair on the right and leaves the logo
   alone on the left — no extra div, no change to the desktop layout. */
.masthead__call {
  place-items: center;
  width: 44px;
  height: 44px;
  margin-left: auto;
  border-radius: var(--r-control);
  color: inherit;
  text-decoration: none;
  transition: color var(--t-fast) var(--ease);
}

/* Both masthead states, exactly as .masthead__nav a does them. An icon styled
   for only one of the two is the trap here: it would vanish against either the
   photograph or the paper bar depending on which state was forgotten. The
   drop-shadow token is the filter equivalent of --text-on-photo, and exists
   because text-shadow cannot reach an SVG. */
.masthead[data-scrolled='false'] .masthead__call { color: var(--paper); }
.masthead[data-scrolled='false'] .masthead__call svg { filter: var(--icon-on-photo); }
.masthead[data-scrolled='true'] .masthead__call { color: var(--charcoal); }

@media (hover: hover) {
  .masthead[data-scrolled='false'] .masthead__call:hover { color: #fff; }
  .masthead[data-scrolled='true'] .masthead__call:hover { color: var(--olive); }
}
.masthead[data-scrolled='true'] .masthead__call:active { color: var(--olive); }

/* A nav label is a name, not a sentence: never break one mid-word. */
.masthead__nav a { white-space: nowrap; }

/* --- Footer -------------------------------------------------------------- */

/* Forest, not charcoal. Charcoal is grey, so every page used to end on grey
   and none of the greens anchored to anything. pale on forest is 12.10:1. */
.sitefoot {
  background: var(--forest);
  color: var(--pale);
  padding: clamp(48px, 7vw, 80px) var(--section-x);
}

.sitefoot__inner {
  max-width: var(--measure);
  margin-inline: auto;
  display: grid;
  grid-template-columns: 1.4fr 1fr 1fr;
  gap: clamp(32px, 5vw, 72px);
}

.sitefoot p { margin: 0 0 var(--s2); }

/* The footer's links were var(--pale) on a footer whose text is also
   var(--pale) — the same colour, a 1.00:1 "distinction", with no underline
   because base.css had none at rest.

   So the phone number and the email address rendered pixel-identically to
   "Based in Langcliffe, near Settle" beside them. Nothing marked them as
   links at all. The only cue was hover to --paper, about 1.15:1 from pale,
   which is barely visible with a mouse and does not exist on a phone.

   A clean WCAG 1.4.1 failure, on every device, on the two most useful links
   on the site. It slipped through because the footer is `sitefoot on-dark`
   rather than `band--forest`, so it never picked up the underline base.css
   gives links on the other dark grounds. Same treatment applied here. */
.sitefoot a { color: var(--pale); text-decoration: underline; text-underline-offset: 4px; }
/* ~23px inline before this. Passed SC 2.5.8 only via the spacing exception,
   and that includes the footer's phone number and email address. */
.sitefoot a { display: inline-block; padding-block: 6px; }
.sitefoot a:hover { color: var(--paper); }

.sitefoot__logo { width: 200px; height: auto; margin-bottom: var(--s5); }
.sitefoot__area { opacity: 0.8; }
.sitefoot__label {
  font-size: var(--step-eyebrow);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  opacity: 0.7;
  margin-bottom: var(--s3);
}
.sitefoot__links { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s2); }
.sitefoot__block--end { display: flex; flex-direction: column; justify-content: flex-end; }
.sitefoot__credential { font-weight: 500; color: var(--paper); }
.sitefoot__note { opacity: 0.8; font-size: 0.9375rem; }

@media (max-width: 767px) {
  .sitefoot__inner { grid-template-columns: 1fr; }
  .sitefoot__block--end { justify-content: flex-start; }
}

/* --- Consent banner -------------------------------------------------------
   Added 19 August. Only ever in the document when a choice is actually needed:
   the markup lives in a <template> in src/partials/consent.html and site.js
   clones it. So there is nothing here to hide for a visitor who already
   answered, and nothing at all when JavaScript is off.

   FOREST, NOT PAPER. The banner sits over whatever page it lands on — a
   photograph on seven of them — so it needs a ground of its own rather than
   borrowing the page's. Forest is the site's dark anchor and paper on forest
   measures 14.14, which is the widest margin any pairing on this site has.

   THE TWO CHOICES ARE THE SAME ELEMENT. Same class, same size, same weight,
   same colour, differing only in their words. Do not restyle one of them and
   do not turn either into a text link — "reject is exactly as easy as accept"
   is the requirement, and equal styling is how it is met. -------------------- */

.cb-consent {
  position: fixed;
  inset: 0;
  z-index: 60;                    /* over the masthead, which sits below this */
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding: var(--s4);
  pointer-events: none;           /* see the scrim note below */
}

/* Dismisses on click, which is what makes "click outside is not consent" a
   thing a visitor can actually do.

   Deliberately NOT a scroll lock, and deliberately weak. Locking the page
   behind a consent prompt is a cookie wall; the visitor has to be able to read
   the site without answering. The tint is only enough to separate the panel
   from a photograph underneath it. */
.cb-consent__scrim {
  position: absolute;
  inset: 0;
  background: rgba(15, 42, 35, 0.55);
  pointer-events: auto;
}

.cb-consent__panel {
  position: relative;
  pointer-events: auto;
  width: 100%;
  max-width: 760px;
  background: var(--forest);
  color: var(--paper);
  border-radius: var(--r-media);
  padding: var(--s6) var(--s6) var(--s5);
  display: grid;
  gap: var(--s5);
}

.cb-consent__title {
  font-size: var(--step-sub);
  line-height: 1.2;
  margin: 0 0 var(--s3);
  color: var(--paper);
}

.cb-consent__body {
  margin: 0;
  max-width: 62ch;
  color: var(--paper);
}

/* The default link colour is olive, and olive on forest measures 2.54 — the
   pairing tokens.css warns about by name. Paper, underlined, on a dark ground:
   the same treatment the footer's links take. */
.cb-consent__body a { color: var(--paper); text-decoration: underline; text-underline-offset: 3px; }
.cb-consent__body a:hover { color: #fff; }

.cb-consent__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s4);
}

/* Both choices, identically. */
.cb-consent__choice {
  /* 1 1 0, not 1 1 auto. With auto the two buttons take their width from their
     own labels, and measured on 19 August that made accept 353px and reject
     327px — identical styling, but not identical size, and "reject is exactly
     as easy as accept" is a claim about what the eye lands on. A zero basis
     makes them exactly half each whatever the words are, in any language. */
  flex: 1 1 0;
  min-width: 200px;
  min-height: 44px;
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 500;
  line-height: 1.2;
  padding: var(--s3) var(--s5);
  border: 1px solid var(--paper);
  border-radius: var(--r-control);
  background: var(--paper);
  color: var(--forest);
  cursor: pointer;
  transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}

@media (hover: hover) {
  .cb-consent__choice:hover { background: #fff; border-color: #fff; }
}
.cb-consent__choice:active { background: #fff; border-color: #fff; }

/* 44px, matching every other touch target on the site and well clear of the
   24px WCAG 2.5.8 minimum. Positioned rather than in the flow so it cannot
   push the two choices around at narrow widths. */
.cb-consent__close {
  position: absolute;
  top: var(--s3);
  right: var(--s3);
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  font-size: 1.5rem;
  line-height: 1;
  background: transparent;
  border: 0;
  border-radius: var(--r-control);
  color: var(--paper);
  cursor: pointer;
}

@media (hover: hover) {
  .cb-consent__close:hover { background: rgba(247, 246, 242, 0.16); }
}
.cb-consent__close:active { background: rgba(247, 246, 242, 0.24); }

/* The dark-ground focus ring, same as .on-dark elsewhere. The panel is forest,
   so the default charcoal outline would nearly vanish on it. */
.cb-consent :focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }

@media (max-width: 559px) {
  .cb-consent { padding: var(--s3); }
  .cb-consent__panel { padding: var(--s5) var(--s4) var(--s4); gap: var(--s4); }
  /* Stacked, and still the same as each other. */
  .cb-consent__choice { flex: 1 1 100%; min-width: 0; }
  .cb-consent__close { top: var(--s2); right: var(--s2); }
}

/* A button that reads as a link. The change-your-mind control in the footer is
   a <button> because it performs an action rather than navigating, but it
   belongs visually with the links beside it. */
.linkish {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}

/* --- Page banner ----------------------------------------------------------
   The interior-page equivalent of the homepage hero, added 18 August. Every
   page but the homepage opened on the same eyebrow-h1-lede block on paper;
   Sam asked for a photograph in the top section of each.

   IT DOES NOT INHERIT THE HERO'S SCRIM, and that is the point of this block.
   .hero__scrim is rgba(24,26,18,0.10) and home.css records it as expected to
   FAIL brief 4.2 DoD 3 — an open decision. Repeating that seven more times
   would multiply an unresolved failure instead of shipping one.

   0.62 is the figure home.css's own note derives: over the brightest possible
   pixel, [255,255,255], paper text needs rgba(24,26,18,0.62) to reach 4.5:1.
   Because it is taken from the WHITE worst case it holds over any photograph
   by construction rather than by luck, which matters here — seven different
   pictures sit behind it and three of them are high-key. It is also the exact
   value already shipping on .closing__scrim, so this is a value the site has
   used and measured, not a new one.

   Measured per page all the same. Never assume a ratio you can sample. */
.pagebanner {
  position: relative;
  overflow: clip;                 /* house rule: clip, never hidden */
  min-height: clamp(300px, 34vw, 440px);
  display: flex;
  align-items: flex-end;
  /* Same trick as .hero — main reserves the fixed bar's height and the banner
     pulls itself back up underneath it, so the photograph runs to the top of
     the viewport and the translucent bar has something to sit over. */
  margin-top: calc(-1 * var(--masthead-h));
}

.pagebanner__media { position: absolute; inset: 0; }
/* The <picture> wrapper needs the height too, or the img's height:100% resolves
   against the picture's intrinsic box and the photograph stops short. Same bug
   home.css:34 documents for the hero; only areas.html hits it, because
   hero-conservatory is the one banner slot with a responsive block. */
.pagebanner__media picture { display: block; width: 100%; height: 100%; }
.pagebanner__media img { width: 100%; height: 100%; object-fit: cover; }

.pagebanner__scrim { position: absolute; inset: 0; background: rgba(24, 26, 18, 0.62); }

.pagebanner__inner {
  position: relative;
  width: 100%;
  max-width: var(--measure);
  margin-inline: auto;
  padding: calc(var(--masthead-h) + var(--s7)) var(--section-x) var(--s7);
}

/* Matches .hero__text, so the two treatments line up if a visitor moves from
   the homepage to any other page. */
.pagebanner__text { max-width: 820px; }

/* The global .eyebrow is olive, and olive on a 0.62 scrim fails. Same reasoning
   that produced .hero__eyebrow; scoped here rather than given its own class
   because the markup should stay ordinary. */
.pagebanner .eyebrow { color: var(--paper); text-shadow: var(--text-on-photo-xs); }

.pagebanner h1 {
  color: var(--paper);
  text-shadow: var(--text-on-photo);
  margin-bottom: var(--s4);
}

.pagebanner .lede {
  color: var(--paper);
  text-shadow: var(--text-on-photo-sm);
  margin-bottom: 0;
}

@media (max-width: 767px) {
  /* Below 768 the h1 is small enough that the display shadow reads as a blur
     rather than a halo — the swap the hero already makes at home.css:121. */
  .pagebanner h1 { text-shadow: var(--text-on-photo-xs); }
  .pagebanner__inner { padding-block: calc(var(--masthead-h) + var(--s6)) var(--s6); }
}

/* --- Shared page layout --------------------------------------------------
   Written for the services rebuild, moved here once About and Contact needed
   them too. */

/* Heading left, lede right. The superseded .section__head capped itself at
   44rem and sat left, so the right half of a 1200px measure was empty on every
   intro — half of the "too much white space" problem was horizontal. */
.pagehead {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--s5) var(--s7);
  align-items: end;
  margin-bottom: var(--s8);
}
.pagehead > :first-child > :last-child { margin-bottom: 0; }
.pagehead .lede { margin-bottom: 0; }

/* When the head IS the section, its bottom margin is dead space stacked on
   top of the section's own padding. */
.wrap > .pagehead:only-child { margin-bottom: 0; }

.twocol {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s7) var(--s8);
}
.twocol > * > :last-child { margin-bottom: 0; }

.mt-lg { margin-top: var(--s7); }

.cta { text-align: center; }
.cta .lede { margin-inline: auto; }
.btn-row { display: flex; gap: var(--s4); flex-wrap: wrap; justify-content: center; }

@media (max-width: 767px) {
  .pagehead { grid-template-columns: 1fr; align-items: start; gap: var(--s4); }
  .twocol { grid-template-columns: 1fr; }
}

/* --- The enquiry form ----------------------------------------------------
   Rebuilt from the superseded stylesheet, which leaned on legacy-only tokens
   (--sage, --ink, --forest).

   EVERY HOOK BELOW IS A CONTRACT WITH src/scripts/enquiry.js. It reads
   #enquiry-form, #form-status and #submit-btn, sets aria-invalid on inputs,
   writes into <fieldname>-error by id, and injects .alert / .alert--ok /
   .alert--error markup containing an h2, a p and a ul. Renaming any of those
   breaks the form silently — the page still renders, the errors just stop
   appearing. Do not tidy these names. brief 2.5 keeps enquiry.js untouched. */

.form { max-width: 40rem; }

.field { margin-bottom: var(--s5); }

.field label,
.consent label { display: block; font-weight: 500; margin-bottom: var(--s2); }

.field .hint {
  display: block;
  font-size: 0.9375rem;
  color: #55605A;          /* 6.05:1 on paper — quieter than body, still AA */
  margin-bottom: var(--s2);
}

.field input,
.field textarea {
  width: 100%;
  font: inherit;
  color: var(--charcoal);
  background: var(--paper);
  padding: var(--s3) var(--s4);
  border: 1px solid #6B7269;   /* 3.6:1 on paper — a control boundary must meet
                                  1.4.11 non-text contrast, and the old #D5D7D2
                                  was 1.4:1. */
  border-radius: var(--r-control);
}
.field textarea { resize: vertical; }

.field input:focus,
.field textarea:focus { border-color: var(--olive); }

.field input[aria-invalid='true'],
.field textarea[aria-invalid='true'] { border-color: var(--error); border-width: 2px; }

/* Written by enquiry.js. Empty until it has something to say.
   Not scoped to .field — the consent error lives inside the .consent label,
   so a .field .error rule left it rendering in body charcoal, which reads as
   ordinary text rather than as a problem. */
.error {
  display: block;
  margin-top: var(--s2);
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--error);
}
.error:empty { display: none; }

.req { color: var(--error); font-weight: 700; }

.consent { display: flex; gap: var(--s3); align-items: flex-start; margin-bottom: var(--s5); }
/* 22px was below the SC 2.5.8 hard floor of 24. The label is a big target
   too, but the control itself has to clear it. */
.consent input { width: 24px; height: 24px; margin: 2px 0 0; flex: none; accent-color: var(--olive); }
.consent label { font-weight: 400; font-size: 0.9375rem; margin: 0; }

/* The honeypot. Off-screen rather than display:none, so a bot filling every
   field still trips it. Never announced, never focusable. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: clip;          /* house rule: clip, never hidden. brief 1.3, 3.4 */
}

.note { font-size: 0.9375rem; color: #55605A; margin-top: var(--s5); }

/* --- Form status banner --------------------------------------------------
   .alert is injected by enquiry.js, so nothing in the markup carries it and
   grepping the pages for it finds nothing. It still has to be styled. */

.form__status { margin-bottom: var(--s5); }
.form__status:empty { display: none; }

.alert {
  border-radius: var(--r-media);
  padding: var(--s4) var(--s5);
  font-size: 0.9375rem;
}
.alert h2 { font-size: 1.125rem; margin: 0 0 var(--s2); }
.alert p:last-child, .alert ul:last-child { margin-bottom: 0; }
.alert ul { margin: var(--s2) 0 0; padding-left: var(--s5); }
.alert li { margin-bottom: var(--s1); }

.alert--ok    { background: #E6EEDF; border: 1px solid var(--olive); color: var(--forest); }
.alert--error { background: #F7E9E7; border: 1px solid #B0564A; color: var(--error-deep); }
.alert--error a { color: var(--error-deep); }

/* --- Brand reel ----------------------------------------------------------
   Wordmarks, never logos. See src/partials/brand-reel.html for why that is a
   rule rather than a preference.

   BRAND NAMES ARE CHARCOAL, NOT TAUPE, AND THAT WAS A CORRECTION.

   Taupe was the obvious choice — it is what the old .makers names used, the
   one sanctioned exception for taupe as type. But that exception was measured
   at 3.05:1 against PAPER, and this band is pale, which is darker. Measured
   here, taupe on pale is 2.60:1 against a 3.0 minimum for large text: all ten
   names failed. The exemption did not survive the change of background and the
   0.05 of margin it had on paper was never worth relying on.

   Charcoal on pale measures 10.57:1. Olive would also have passed at 4.70, but
   olive is the link colour and these are not links. Taupe stays where the rule
   actually wants it: fills and rules, never type. */

.reel { padding-block: clamp(32px, 4vw, 56px); }

/* overflow: clip, not hidden — house rule, brief 1.3 and 3.4. clip does not
   create a scroll container, so there is nothing here a keyboard user can get
   stuck inside and nothing that can be scrolled to off-screen. That distinction
   is exactly what bit the trust bar, which WAS a scroll container and could not
   be reached without a mouse. */
.reel__viewport {
  margin-top: var(--s4);
  display: flex;
  gap: clamp(32px, 5vw, 72px);
  overflow: clip;
  /* Fades the ends so names enter and leave rather than being chopped. */
  mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
}

.reel__track {
  display: flex;
  flex: none;
  align-items: baseline;
  gap: clamp(32px, 5vw, 72px);
  padding: 0;
  margin: 0;
  list-style: none;
}

.reel__track li {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 2.6vw, 2rem);
  line-height: 1.1;
  color: var(--charcoal);
  white-space: nowrap;
}

/* THE ANIMATION IS SCOPED TO no-preference, NOT APPLIED THEN DISABLED.

   base.css forces `animation-duration: 0.01ms !important` and
   `animation-iteration-count: 1 !important` on everything under reduced
   motion. A marquee written the usual way would therefore run its entire
   translate instantly and STOP AT THE END — parking the track on the duplicate
   half rather than showing a clean static row. Because !important cannot be
   overridden, the only correct fix is to never apply the animation in the
   first place. Declared this way round, the global rule never bites and the
   reduced-motion state is simply a still row. */
@media (prefers-reduced-motion: no-preference) {
  .reel__track { animation: reel-scroll 42s linear infinite; }

  /* Both halves are identical and sit side by side, so translating by exactly
     one track width plus one gap lands the copy where the original started.
     Anything else visibly jumps. */
  @keyframes reel-scroll {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - clamp(32px, 5vw, 72px))); }
  }

  /* HOVER NO LONGER PAUSES IT. Sam, 20 August: the reel stopping under the
     cursor read as the animation being broken rather than as a courtesy, and
     on a wide screen the pointer rests over that band often enough that the
     thing looked static most of the time.

     WCAG 2.2.2 is unaffected, and that is the reason this is safe to remove
     rather than a risk taken. The success criterion asks for A mechanism to
     pause, not for hover specifically, and two remain: keyboard focus anywhere
     inside the reel, and the control below, which is a real button with
     aria-pressed. Hover was the third and the least reliable of them — it is
     not available on a touchscreen at all. */
  .reel:focus-within .reel__track,
  .reel[data-paused='true'] .reel__track { animation-play-state: paused; }
}

/* Offscreen until focused — the skip-link pattern. A real WCAG 2.2.2 mechanism
   for anyone without a mouse, at no visual cost to the page. */
.reel__pause {
  position: absolute;
  left: -9999px;
  margin-top: var(--s3);
  padding: var(--s2) var(--s4);
  min-height: 44px;
  border: 1.5px solid var(--olive);
  border-radius: var(--r-control);
  background: var(--paper);
  color: var(--charcoal);
  font: inherit;
  font-size: 0.9375rem;
  cursor: pointer;
}
.reel__pause:focus { position: static; left: auto; }

.reel__note {
  margin: var(--s5) 0 0;
  max-width: 62ch;
  font-size: 0.9375rem;
}

/* Static under reduced motion, so the row simply wraps rather than running off
   the edge with no way to see the rest. */
@media (prefers-reduced-motion: reduce) {
  .reel__viewport { flex-wrap: wrap; mask-image: none; -webkit-mask-image: none; }
  .reel__track { flex-wrap: wrap; }
  .reel__track[aria-hidden='true'] { display: none; }
}

/* --- Contact aside -------------------------------------------------------- */

.site-contact-list { list-style: none; margin: 0 0 var(--s6); padding: 0; }
.site-contact-list li { margin-bottom: var(--s4); }
.site-contact-list strong { color: var(--olive); }

/* --- Long-form legal text. Privacy only. --------------------------------- */

.legal { max-width: 70ch; }
/* A policy cites URLs and regulator names, and a bare URL is one long
   unbreakable word. Measured at 200% text on a 390px screen, the citation
   "policies.google.com/privacy" pushed the page 13px sideways. Value is
   anywhere, not break-word: break-word only breaks a word that is alone on its
   line, which is not the failing case here. Scoped to .legal so ordinary prose
   keeps normal wrapping. */
.legal { overflow-wrap: anywhere; }
/* Single column, unlike .pagehead — a policy is read top to bottom and a
   measure of 70ch is the point of the page, not a gap to be filled. */
.legal__head { margin-bottom: var(--s7); }
.legal h2 { margin-top: var(--s8); font-size: clamp(1.375rem, 3vw, 1.75rem); }
.legal h2:first-of-type { margin-top: var(--s7); }
.legal ul { padding-left: var(--s5); }
.legal li { margin-bottom: var(--s2); }
.legal dl { margin: 0 0 var(--s5); }
.legal dt { font-weight: 700; color: var(--olive); margin-top: var(--s4); }
.legal dd { margin: var(--s1) 0 0; }

/* Unresolved placeholder in the policy. Deliberately conspicuous — there are
   15 of them and none may survive launch. Taupe is a fill here, never text:
   the text on it is charcoal. */
.ph {
  background: #F3E7D5;
  border-bottom: 2px solid var(--taupe);
  padding: 0 var(--s1);
  color: var(--charcoal);
}

/* The block form, for a whole section that is waiting on the client rather
   than a phrase that is. brief 6 DoD 2 wants a marker "impossible to miss at
   launch review", and an inline highlight inside a paragraph is missable when
   the paragraph is the thing that does not exist yet.

   Same palette as .ph so the two read as one system. Taupe is the rule, never
   the text — it fails AA at 3.05 on paper. Charcoal on #F3E7D5 carries the
   words. */
.ph-block {
  background: #F3E7D5;
  border-left: 4px solid var(--taupe);
  border-radius: 0 var(--r-control) var(--r-control) 0;
  padding: var(--s4) var(--s5);
  color: var(--charcoal);
}

.ph-block > :last-child { margin-bottom: 0; }
.ph-block strong { font-weight: 700; }

/* ---- home.css ---- */
/* =============================================================================
   HOMEPAGE — brief 4.
   Only the thermostat is a signature moment. Everything else is typography,
   photography and space (brief 3.5 restraint rule).
   ========================================================================== */

/* --- Hero. Full viewport, trust bar inside it. brief 4.2 ------------------ */

.hero {
  position: relative;
  /* 100vh first as a fallback: a browser without svh support drops the second
     declaration entirely, and without this the hero would collapse to content
     height rather than filling the screen. */
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  overflow: clip;
}

.hero__media {
  position: absolute;
  inset: 0;
  /* Defensive. The superseded stylesheet also defines .hero__media, and put a
     decorative arch across this photograph — border-radius: 46% 46% 0 0 /
     13% 13% 0 0 — plus an aspect-ratio and a background. legacy.css no longer
     loads here, but these stay so it cannot return silently. */
  border-radius: 0;
  aspect-ratio: auto;
  background: none;
  overflow: clip;
}
/* The <picture> wrapper needs the height too. Without it the picture box takes
   its intrinsic height, the img's height:100% resolves against THAT rather than
   against .hero__media, and the photograph stops short of the bottom of the
   hero. Measured at 375x812: the picture was 501px inside an 812px hero, so the
   lower 311px — sub, buttons and the whole trust bar — sat on the page's paper
   background with no photograph behind it at all. On desktop the same bug was
   invisible only because the landscape crop happens to overflow the hero. */
.hero__media picture { display: block; width: 100%; height: 100%; }
.hero__media img { width: 100%; height: 100%; object-fit: cover; }

/* FLAT SCRIM — UNDER EVALUATION, NOT SETTLED. 14 August.
   One uniform tone across the whole photograph, at the level the perimeter of
   the image was already sitting at. Requested so the picture can be judged
   evenly lit before deciding how the text gets its contrast.

   This is expected to fail brief 4.2 DoD 3. The photograph is high-key almost
   everywhere and the brightest pixel behind the headline is [253,255,255] (sun
   through the glass); paper text needs rgba(24,26,18,0.62) over that for 4.5:1,
   and this is 0.10. Do not ship it in this state without a decision on where
   the headline, sub, buttons and trust bar get their contrast from — the
   leading candidate being a solid band across the lower hero, taking the text
   off the picture entirely.

   The four-layer version this replaced, kept so it can be restored exactly:

     background:
       linear-gradient(to bottom,                        masthead strip
         rgba(24,26,18,0.45) 0%, rgba(24,26,18,0.10) 6%, rgba(24,26,18,0) 9%),
       linear-gradient(to top,                           bottom band, full width
         rgba(24,26,18,0.86) 0%, rgba(24,26,18,0.74) 12%, rgba(24,26,18,0.46) 26%,
         rgba(24,26,18,0.14) 38%, rgba(24,26,18,0) 50%),
       radial-gradient(ellipse 980px 350px at 42% 58%,   text pool
         rgba(24,26,18,0.78) 0%, rgba(24,26,18,0.72) 58%,
         rgba(24,26,18,0.34) 84%, rgba(24,26,18,0) 100%),
       linear-gradient(rgba(24,26,18,0.10), rgba(24,26,18,0.10));   uniform floor

     @media (max-width: 767px) — same masthead strip and floor, with a steeper
     bottom band: 0.88 0%, 0.80 20%, 0.72 45%, 0.63 64%, 0.30 70%, 0.08 80%, 0 90%.

   Its measured figures, worst pixel per tight text rect, 1425/1905/390:
     headline 7.27 / 7.33 / 5.39   sub 9.94 / 10.77 / 8.21
     buttons 14.42 / 13.83 / —     trust bar 9.42 / 9.85 / 11.41 */
.hero__scrim {
  position: absolute;
  inset: 0;
  background: rgba(24, 26, 18, 0.10);
}

.hero__body {
  position: relative;
  max-width: var(--measure);
  width: 100%;
  margin-inline: auto;
  padding: 0 var(--section-x) var(--s7);
}

/* padding reset is defensive — see the note on .hero__media above. */
.hero__text { max-width: 820px; padding: 0; }

/* Copy v2.1 section 1.1. Deliberately NOT .eyebrow: that class is olive, and
   olive over a bright photograph fails badly. Paper plus the small-text shadow
   is the same treatment the hero sub already uses. */
.hero__eyebrow {
  color: var(--paper);
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  margin: 0 0 var(--s4);
  text-shadow: var(--text-on-photo-xs);
}

/* The scrim is flat at 0.10, so every piece of hero text carries its own
   contrast. See --text-on-photo in tokens.css for what that is and why it is
   the second shadow on the site. */
.hero h1 {
  color: var(--paper);
  margin-bottom: var(--s5);
  text-shadow: var(--text-on-photo);
}

/* On a phone the headline is 36px, not 80px, so it behaves like the small text
   rather than like display type: the halo is too small to notice, and the
   thinner strokes need the density. Measured on the portrait crop, the denser
   token is better on BOTH counts — 19.3% -> 11.6% under 4.5:1, with the
   surround 2–3px out no darker. Not a trade, just the right size of tool. */
@media (max-width: 767px) {
  .hero h1 { text-shadow: var(--text-on-photo-xs); }
}

.hero__sub {
  color: var(--pale);
  font-size: 1.1875rem;
  max-width: 56ch;
  margin-bottom: var(--s6);
  text-shadow: var(--text-on-photo-sm);
}

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

/* The ghost button is paper text on a 1px paper border and no fill, which over
   a bright photograph leaves almost nothing to see. A shadow on a 1px border
   reads as a smudge, so it gets a translucent fill instead — the same value
   the masthead already uses over this photograph. Scoped to the hero; the
   ghost button is unchanged everywhere else.

   0.50 is not a look, it is a measurement: over the worst photo pixel behind
   this button, [247,212,162], paper text needs that much fill to reach 4.5:1.
   At 0.28 it measured 2.75:1. Unlike the text shadows, this one is a real
   background colour, so the ratio is a genuine WCAG pass. */
.hero__actions .button--ghost {
  background: rgba(24, 26, 18, 0.50);
  border-color: rgba(247, 246, 242, 0.85);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
}
@media (hover: hover) {
  .hero__actions .button--ghost:hover { background: rgba(24, 26, 18, 0.66); }
}

/* --- Trust bar. Inside the hero, above the fold. brief 4.2 --------------- */

.trustbar {
  position: relative;
  /* Lifted from 0.28 — at that alpha the rule was invisible over the bright
     photograph, and it is the only thing separating the trust bar from the
     buttons above it. */
  border-top: 1px solid rgba(222, 231, 221, 0.55);
  margin-top: var(--s7);
  padding-top: var(--s5);
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: var(--s5) var(--s4);
}

.trustbar__item {
  display: flex;
  align-items: center;
  gap: var(--s3);
  color: var(--paper);
}

/* text-shadow does not touch SVG strokes, so the icons need the equivalent as
   a filter or they float unsupported next to shadowed labels. */
.trustbar__item svg {
  flex: none;
  width: 28px;
  height: 28px;
  filter: var(--icon-on-photo);
}

.trustbar__item p {
  margin: 0;
  text-shadow: var(--text-on-photo-xs);
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.35;
}

/* Short viewports. Written for a 1280x720 laptop, but it is unqualified by
   width so it also fires on an iPhone SE in portrait (667px), on every phone in
   landscape, and on tablets in landscape. That is desirable, and it is now
   load-bearing: it buys the vertical room the two-column trust bar below needs.
   Tightened 16 Aug for that reason — the eyebrow added height to the hero and
   the trust bar went from one row to three. */
@media (max-height: 820px) {
  .hero__eyebrow { font-size: 0.8125rem; margin-bottom: var(--s3); }
  .hero h1 { font-size: clamp(2.25rem, 4.4vw, 3.25rem); margin-bottom: var(--s3); }
  .hero__sub { font-size: 1.0625rem; margin-bottom: var(--s4); }
  .hero__body { padding-bottom: var(--s5); }
  .trustbar { margin-top: var(--s4); padding-top: var(--s4); }
  .trustbar__item p { font-size: 0.875rem; }
}

/* Below 900px: two columns, three rows. NOT a sideways scroller.

   It was one — `display: flex; overflow-x: auto; scroll-snap-type: x mandatory`
   with the scrollbar hidden — and the old comment above it claimed that kept
   all five "reachable inside the first screen". Measured on a 375px phone, the
   container was 335px against 974px of content, so ONE of the five points was
   on screen and nothing indicated the other four existed.

   Two separate failures, and the second is the serious one:

     - brief 4.2 DoD 1 requires all five visible WITHOUT scrolling. Four were
       not visible at all.
     - the scroller had no tabindex and contained no focusable children, so a
       keyboard user could not scroll it. Content that exists and cannot be
       reached is WCAG 2.1.1, Level A.

   A grid fixes both outright: nothing scrolls, so there is nothing to reach.
   scroll-snap, scrollbar-width, the ::-webkit-scrollbar rule and a dead
   -webkit-overflow-scrolling (removed from iOS in 2019) all went with it.

   It is tight vertically and that is why the rule above was tightened in the
   same pass: measured at 375x667 there are 110px between the buttons and the
   fold, and three rows need about 108px. */
@media (max-width: 900px) {
  .trustbar {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--s3) var(--s4);
  }
  /* The fifth item has no partner on the last row; letting it span keeps the
     row rhythm rather than leaving a hole beside it. */
  .trustbar__item:last-child { grid-column: 1 / -1; }
}

@media (max-width: 400px) {
  .trustbar { gap: var(--s2) var(--s3); }
  .trustbar__item { gap: var(--s2); }
  .trustbar__item svg { width: 22px; height: 22px; }
}

/* Very short viewports — a 320x568 phone, which is the narrowest the brief
   supports. Measured there with the rules above alone, the fifth trust point
   finished 5px BELOW the fold, so brief 4.2 DoD 1 failed by five pixels. The
   labels wrap to two lines at 320px, which makes each item 38px rather than 22,
   and three rows of that does not fit what was left.
   This buys it back out of the spacing around the hero text, not out of the
   trust bar itself. */
@media (max-height: 620px) {
  .hero__sub { margin-bottom: var(--s3); }
  .hero__actions { gap: var(--s3); }
  .hero__body { padding-bottom: var(--s4); }
  .trustbar { margin-top: var(--s3); padding-top: var(--s3); }
}

/* --- Thermostat. The one signature moment. brief 4.3 -------------------- */

.thermo { padding-block: var(--section-y); transition: background var(--t-slow) var(--ease); }
.thermo[data-season='summer'] { background: #E3EAE6; }
.thermo[data-season='winter'] { background: #EAE3D9; }

.thermo__inner { max-width: var(--measure); margin-inline: auto; padding-inline: var(--section-x); }
.thermo__head { text-align: center; max-width: 640px; margin: 0 auto var(--s7); }

.thermo__grid {
  display: grid;
  grid-template-columns: 1fr 1.3fr 1fr;
  grid-template-areas: 'summer dial winter' 'scap dial wcap';
  gap: var(--s5);
  align-items: stretch;
}

.thermo__media { position: relative; border-radius: var(--r-media); overflow: clip; min-height: 220px; transition: opacity var(--t-slow) var(--ease); }
.thermo__media img { width: 100%; height: 100%; object-fit: cover; min-height: 220px; }
.thermo__media--summer { grid-area: summer; }
.thermo__media--winter { grid-area: winter; }

/* The scrim carrying "Summer, outside 28°" over an unknown photograph.

   Was a single ramp, rgba(24,26,18,0.7) -> transparent. The 0.7 reads
   reassuringly in the CSS but it is only reached at the very bottom edge: the
   glyphs sit 50–91% down this box, which on a `to top` gradient is positions
   9–50%, where the ramp had already fallen to 0.35–0.60. Against a worst-case
   white photograph that measured 2.87:1 for the 14px label and 2.03:1 for the
   32px figure. Both fail.

   Held at 0.82+ across the whole band the text occupies. 0.614 is the value
   already measured as carrying paper text over a pure white pixel, so this
   clears it with room. Verified against WHITE rather than against the current
   placeholder, which means it holds for any photograph and does not need
   re-measuring when the real ones land. */
.thermo__outside {
  position: absolute;
  inset: auto 0 0 0;
  padding: var(--s8) var(--s4) var(--s3);
  background: linear-gradient(to top,
    rgba(24, 26, 18, 0.88) 0%,
    rgba(24, 26, 18, 0.82) 55%,
    rgba(24, 26, 18, 0.30) 78%,
    rgba(24, 26, 18, 0) 100%);
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  color: var(--paper);
}
.thermo__outside span:first-child { font-size: var(--step-eyebrow); letter-spacing: var(--tracking-eyebrow); text-transform: uppercase; }
.thermo__outside span:last-child { font-family: var(--font-display); font-size: 2rem; }

.thermo__dial {
  grid-area: dial;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s5);
  border-radius: var(--r-media);
  padding: var(--s6) var(--s5);
  transition: background var(--t-slow) var(--ease);
}
.thermo[data-season='summer'] .thermo__dial { background: #F2F6F4; }
.thermo[data-season='winter'] .thermo__dial { background: #F6F1E9; }

.thermo__face { position: relative; width: min(100%, 380px); container-type: inline-size; }
.thermo__face svg { width: 100%; display: block; }

.thermo__arc { transition: stroke var(--t-slow) var(--ease); }
.thermo__dot { transition: fill var(--t-slow) var(--ease); }

.thermo__readout {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  padding-bottom: 4%;
}
.thermo__label { margin: 0; font-size: var(--step-eyebrow); letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.65; }
.thermo__temp { margin: 0; font-family: var(--font-display); font-size: 26cqw; line-height: 1.05; }
.thermo__caption-inline { margin: 0; font-size: 0.9375rem; opacity: 0.7; }

/* Radio group, not a tablist. The approved design used role="tablist" with no
   tabpanel, which announces tabs that control nothing. brief 4.3. */
.thermo__switch { display: flex; border: 1.5px solid var(--olive); border-radius: var(--r-control); overflow: clip; }
.thermo__switch button {
  border: 0;
  cursor: pointer;
  font-family: inherit;
  font-size: 0.9375rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  /* The UA stylesheet puts line-height:normal on <button> via the font
     shorthand, and a UA declaration beats inheritance — so --leading-body never
     reached these and 10px of padding gave a ~39px control. min-height rather
     than more padding, so the label stays optically centred.
     This is the site's one signature interaction; it should not be the
     fiddliest thing to tap on it. */
  padding: 10px var(--s5);
  min-height: 44px;
  background: transparent;
  color: var(--charcoal);
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.thermo__switch button[aria-checked='true'] { background: var(--olive); color: #fff; }

.thermo__caption { background: var(--paper); border-radius: var(--r-media); padding: var(--s5); transition: opacity var(--t-slow) var(--ease); }
.thermo__caption--summer { grid-area: scap; }
.thermo__caption--winter { grid-area: wcap; }
.thermo__caption p:last-child { margin-bottom: 0; }

/* Copy v2.1 section 1.3. Sits under the whole grid, centred, as the section's
   closing statement. Display face because it is a conclusion, not body copy. */
.thermo__close {
  margin: var(--s7) 0 0;
  text-align: center;
  font-family: var(--font-display);
  font-size: clamp(1.125rem, 1.8vw, 1.5rem);
  line-height: 1.35;
}

.thermo[data-season='summer'] .thermo__media--winter,
.thermo[data-season='summer'] .thermo__caption--winter,
.thermo[data-season='winter'] .thermo__media--summer,
.thermo[data-season='winter'] .thermo__caption--summer { opacity: 0.45; }

@media (max-width: 1023px) {
  .thermo__grid { grid-template-columns: 1fr 1fr; grid-template-areas: 'summer winter' 'dial dial' 'scap wcap'; }
}
@media (max-width: 767px) {
  /* Dial BETWEEN the two photographs, not under them. Stacked summer-winter-dial
     you scroll past both images before reaching the control that switches them,
     so you never see the switch do anything — which is the entire point of the
     section. Between them, one bright image and one dimmed one sit either side
     of the control and the comparison reads at a glance. Sam, 19 August. */
  .thermo__grid { grid-template-columns: 1fr; grid-template-areas: 'summer' 'dial' 'winter' 'scap' 'wcap'; }
}

/* --- Services preview. brief 4.4 ---------------------------------------- */

/* Padding and media ratio are tuned to keep the whole section inside 80vh at
   1440px — brief 4.4 DoD 3. A 4:3 image put it at 1.14x the viewport. */
.preview { padding-block: clamp(48px, 6vw, 88px); }
.preview__inner { max-width: var(--measure); margin-inline: auto; padding-inline: var(--section-x); }
.preview__head { max-width: 640px; margin-bottom: var(--s6); }
.preview__feature { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(28px, 4vw, 56px); align-items: center; }
.preview__media { border-radius: var(--r-media); overflow: clip; }
.preview__media img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
@media (max-width: 767px) { .preview__feature { grid-template-columns: 1fr; } }

/* --- How a job runs. brief 4.5 ------------------------------------------ */

/* Tone, foregrounds and focus ring now come from .band--olive in base.css.
   This rule set the same olive and the same paper by hand, from before the
   band system existed; two mechanisms doing one job is how they drift apart. */
.process { padding-block: var(--section-y); }
.process__inner { max-width: var(--measure); margin-inline: auto; padding-inline: var(--section-x); }
.process h2 { margin-bottom: var(--s7); }
.process__steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(28px, 4vw, 56px); list-style: none; margin: 0; padding: 0; }
.process__step { border-top: 1.5px solid rgba(222, 231, 221, 0.4); padding-top: var(--s5); }
/* Decorative: the ordered list already conveys the sequence to assistive
   technology, so the large numeral would be a duplicate. brief 4.5 DoD 3. */
.process__num { font-family: var(--font-display); font-size: 2.75rem; color: var(--pale); margin-bottom: var(--s2); line-height: 1; }
.process__step h3 { font-family: var(--font-body); font-size: 1.1875rem; font-weight: 500; color: var(--paper); margin-bottom: var(--s2); }
.process__step p { color: var(--pale); margin: 0; }
@media (max-width: 767px) { .process__steps { grid-template-columns: 1fr; } }

/* --- Brands section removed 16 August -----------------------------------
   .makers, .makers__inner, .makers__label and .makers__name lived here. The
   section held four manufacturer names that copy v2.1 section 8.3 says are
   unconfirmed, and with the names gone there was nothing left in it — see the
   comment on the homepage where it stood. Replaced by an ordinary
   .section/.wrap band, so these rules are dead and go with it.

   Worth noting what dies with them: .makers__name was the ONE sanctioned use
   of taupe as type anywhere on the site, at 26px display size. Taupe is 3.05:1
   on paper and fails AA below 24px, so nothing here should be revived at body
   size if the manufacturer names ever come back. */

/* --- Closing CTA. brief 4.6 --------------------------------------------- */

.closing { position: relative; padding-block: clamp(96px, 14vw, 180px); overflow: clip; }
.closing__media { position: absolute; inset: 0; }
.closing__media img { width: 100%; height: 100%; object-fit: cover; }
.closing__scrim { position: absolute; inset: 0; background: rgba(24, 26, 18, 0.62); }
.closing__inner { position: relative; max-width: var(--measure); margin-inline: auto; padding-inline: var(--section-x); text-align: center; }
.closing h2 { color: var(--paper); margin-bottom: var(--s6); }

/* ---- services.css ---- */
/* =============================================================================
   SERVICES — brief 13 step 6, in progress.

   The room picker: a grid of six that drives one panel at a time.

   Every class here is namespaced `picker*`. This page still carries
   "legacy": true and so still loads the superseded stylesheet, which defines
   .room, .room__media, .card, .grid, .steps, .split and .brands. Reusing any
   of those names is exactly how the homepage hero acquired a decorative
   semicircle earlier in the build. Do not shorten these names until
   services.html stops loading legacy.css.

   The picker is a stated exception to the "no second signature interaction"
   non-goal, ruled by Sam. It is kept deliberately still — no auto-advance, no
   motion beyond an opacity cross-fade — so it cannot compete with the
   thermostat, which remains the only signature moment on the site.
   ========================================================================== */

/* This page no longer carries "legacy": true, so legacy.css is not served with
   it and the shared primitives .section and .wrap are unambiguous again. While
   it did, both stylesheets defined them and legacy won every tie — it silently
   beat `.picker { padding-top: 0 }` and sized the section from the legacy
   --wrap and --gutter rather than --measure and --section-x. The picker used
   private substitutes until the page was converted.

   The `picker*` namespace stays. contact.html and privacy.html are still
   legacy, and these class names should not have to be re-audited if anything
   here is ever reused on one of them. */

.picker__grid {
  list-style: none;
  margin: 0 0 var(--s8);
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s4);
}

/* Cards separate by space and tone, never by shadow. brief 3.5 */
.picker__card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  height: 100%;
  padding: var(--s5);
  border: 1px solid var(--band-rule, rgba(47, 47, 47, 0.14));
  border-radius: var(--r-media);
  /* Flips against whatever band it is on — paper cards on the pale band here,
     pale cards if this ever sits on paper. Set by the band, see base.css. */
  background: var(--band-card, var(--paper));
  color: var(--charcoal);
  text-decoration: none;
  transition:
    background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease);
}

/* Guarded. Mobile browsers apply :hover on tap and hold it until you tap
   somewhere else, so a tapped card kept its olive border indefinitely — sitting
   alongside the olive aria-selected fill on whichever card was actually chosen.
   Two cards looked selected at once, which is the one sticky-hover case here
   that genuinely misleads rather than just looking untidy. */
@media (hover: hover) {
  .picker__card:hover { border-color: var(--olive); }
}
/* Touch gets a real press response instead. */
.picker__card:active { border-color: var(--olive); }

/* The active card inverts to olive. Its old treatment was a pale fill, which
   worked when the section was paper and stopped working the moment the band
   became pale — the active card would have been exactly the colour of the
   ground behind it.

   Marked two ways, since colour alone is not a distinction (brief 3.4): a full
   lightness inversion, and the rule down the leading edge. The rule is a
   pseudo-element, not an inset box-shadow — the site has one shadow token and
   it is the scrolled header's. A thicker border would jog the card by 2px.

   Measured on olive: paper text 5.56:1, pale eyebrow 4.79:1. */
.picker__card[aria-selected='true'] {
  background: var(--olive);
  border-color: var(--olive);
  color: var(--paper);
}

.picker__card[aria-selected='true'] .picker__flag { color: var(--pale); }

.picker__card[aria-selected='true']::before {
  content: '';
  position: absolute;
  inset: -1px auto -1px -1px;
  width: 3px;
  border-radius: var(--r-media) 0 0 var(--r-media);
  background: var(--paper);
}

/* Out of flow, deliberately.

   As an ordinary flex child this was the third box in a column of three, on the
   one card in six that has one — so it pushed that card's heading and note down
   by its own height plus the 8px gap, about 25px, and the Conservatories card
   sat visibly lower than the five beside it. Absolute positioning takes it out
   of the column entirely, so all six headings start at the same y. The card was
   already `position: relative` for the selected-state rule, so nothing else
   moves.

   It used to share a line with the card icon. The icons were removed on 16
   August pending a set from Claude Design, so the flag now shares its line with
   the HEADING instead — same y, opposite ends of the card. That is why the
   gutter rule below exists and why it is not optional: without it a long name
   runs straight under the badge. line-height matches the heading's 24px so the
   two sit on one line rather than 2px apart.

   DOM order is untouched, so the tab order and the accessible name site.js
   derives from .picker__name are both unaffected. */
.picker__flag {
  position: absolute;
  top: var(--s5);
  right: var(--s5);
  font-size: 0.8125rem;
  font-weight: 500;
  line-height: 24px;               /* == .picker__name's line box */
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--olive);
}

/* Only the flagged card pays for the badge. A general sibling combinator rather
   than :has() — the flag is always the first child, so this is exact, and it
   needs no support caveat. Width is measured, not guessed: see the report. */
.picker__flag ~ .picker__name { padding-right: 112px; }

/* Below the three-column layout the badge moves to its own line.

   The 112px right-hand gutter that keeps "Ian's focus" clear of the heading on
   desktop also inflates that card's MIN-CONTENT width, and `1fr` is
   `minmax(auto, 1fr)` — it cannot shrink below min-content. So across the whole
   600–1023px two-column band the grid went lopsided: measured at 600px the
   columns were 299.5px and 236.5px, a 63px skew where they should be equal.

   Reserving a line at the top of EVERY card instead costs 28px of height, keeps
   all six headings aligned exactly as they are now, and lets the columns go
   back to equal because nothing forces a min-content floor any more. */
@media (max-width: 1023px) {
  .picker__card { padding-top: calc(var(--s5) + 26px); }
  .picker__flag { top: var(--s5); left: var(--s5); right: auto; }
  .picker__flag ~ .picker__name { padding-right: 0; }
}

@media (max-width: 599px) {
  /* The padding-top that used to be here was DEAD and had to go. There is a
     second @media (max-width: 599px) block further down this file whose
     `padding: var(--s4)` shorthand reset it — same selector, same specificity,
     same breakpoint, so source order won. The badge is absolutely positioned,
     so with its reserved line gone it printed straight over the heading:
     measured on the live site at 390px, badge y 17-41 against heading y 17-65,
     a 24px overlap. Reported by Sam, 19 August.

     The replacement lives with that other block so the two cannot drift apart
     again. This one only repositions the badge, which is real work. */
  .picker__flag { top: var(--s4); left: var(--s4); }
}

.picker__name {
  font-family: var(--font-display);
  font-size: 1.25rem;
  line-height: 1.2;
  /* Two lines' worth, whether the name uses them or not.

     Without this the notes go ragged wherever one name in a row wraps and its
     neighbours do not. Measured on the services page: 0px of drift at most
     widths, but 24px at 1024px and again at 640px, which are the two points
     where "Kitchens and living rooms" turns over and nothing else does.

     On the benefits page it is not an edge case at all — "Rooms the heating
     never reached" and "Cheaper than a plug-in heater" both wrap at the normal
     three-column desktop width while "Filtered air" does not, so the ragged
     version would be what most people saw. Reserving the second line costs one
     line of space on the cards that do not need it and buys alignment at every
     width on both pages. 2.4em = two lines at line-height 1.2. */
  min-height: 2.4em;
}

/* --- Icons removed 16 August ---------------------------------------------
   `.picker__card .icon` and `.picker__media--icon` lived here. Sam is having a
   set drawn properly, so rather than leave twelve of mine in place they came
   out whole: the sprite partial, the markup and these rules.

   The benefits panels used .picker__media--icon where the services panels use a
   photograph, so those panels are now single-column prose. Six empty tinted
   rectangles would have been exactly the hatched placeholder block brief 3.7
   rule 1 forbids. .picker__panel--solo below restores the single column; the
   two-column layout comes back by deleting that one class from the markup. */
.picker__panel--solo { grid-template-columns: minmax(0, 1fr); }
.picker__panel--solo .picker__prose { max-width: 68ch; }

/* The note follows the heading directly, and that is deliberate.

   `margin-top: auto` was tried here first, on the theory that pushing the notes
   to the bottom of equal-height cards would line them up. Measured at 1024px it
   did the opposite: the notes are different lengths, so bottom-aligning them
   put a one-line note 21px below a two-line one and the tops went ragged. Tops
   are what a reader sees as "lined up", so the notes stay in flow and every
   card's note starts directly under a heading that now begins at the same y.

   The remaining case is a heading long enough to wrap on one card and not the
   others, which would push that card's note down. .picker__name reserves a
   consistent height so that cannot happen. */
.picker__note { font-size: 0.9375rem; line-height: 1.4; }


/* --- The stage ----------------------------------------------------------- */

.picker__panel {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
  gap: var(--s7);
  align-items: center;
}

/* Only ever set by site.js. Without JavaScript every panel stays visible and
   the page is six stacked sections, which is what the markup ships as. */
.picker__panel[hidden] { display: none; }

/* Where a tap-to-scroll lands. Expressed as scroll-margin rather than arithmetic
   in the JS, so the fixed masthead never covers the panel heading — and because
   --masthead-h is republished live by the ResizeObserver, this stays correct at
   any text-zoom level for free. Harmless above 767px, where nothing scrolls. */
.picker__panel { scroll-margin-top: calc(var(--masthead-h) + var(--s4)); }

.picker__media { overflow: clip; border-radius: var(--r-media); background: var(--band-card, var(--paper)); }
.picker__media img { width: 100%; height: auto; display: block; }


.picker__prose > :last-child { margin-bottom: 0; }
.picker__prose h2 { margin-bottom: var(--s4); }

/* Panels separate visually when JavaScript is off and all six are stacked. */
.picker__panel + .picker__panel { margin-top: var(--s8); }

/* The only motion in the picker: the incoming panel fades up. No cross-fade —
   the panels sit in normal flow at differing heights, so overlapping them
   would need absolute positioning and a height animation, which is jank for
   no gain. base.css already reduces this to 0.01ms under prefers-reduced-
   motion, so it snaps there without a rule of its own. */
.picker__panel--enter { animation: picker-fade var(--t-mid) var(--ease) both; }

@keyframes picker-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* --- Prev / next --------------------------------------------------------- */

.picker__nav {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s4);
  margin-top: var(--s6);
}

.picker__arrow {
  width: 44px;                 /* 44px minimum touch target */
  height: 44px;
  display: grid;
  place-items: center;
  font-size: 1.5rem;
  line-height: 1;
  border: 1px solid var(--olive);
  border-radius: var(--r-control);
  background: transparent;
  color: var(--olive);
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}

@media (hover: hover) {
  .picker__arrow:hover { background: var(--olive); color: var(--paper); }
}
.picker__arrow:active { background: var(--olive); color: var(--paper); }

.picker__count {
  margin: 0;
  font-size: 0.9375rem;
  font-variant-numeric: tabular-nums;   /* so the row does not jog on 1 -> 2 */
  color: var(--charcoal);
}

/* =============================================================================
   The rest of the services page, rebuilt so it can stop loading legacy.css.
   These are general enough to move into components.css when About and Contact
   are rebuilt and need them too.
   ========================================================================== */

/* .pagehead, .twocol, .mt-lg, .cta and .btn-row moved to components.css when
   About and Contact turned out to need them too. */

/* --- Numbered steps ------------------------------------------------------ */

.steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--s7);
}

/* Separated by a rule and space, not a card — three boxes in a row directly
   under six boxes in a grid would have read as more of the same. */
.steps__item { border-top: 2px solid var(--olive); padding-top: var(--s4); }
.steps__item > :last-child { margin-bottom: 0; }
.steps__item h3 { margin-bottom: var(--s3); }

.steps__num {
  font-family: var(--font-display);
  font-size: 1.5rem;
  line-height: 1;
  color: var(--olive);
  margin: 0 0 var(--s2);
}

/* Names as text. Never manufacturer logos — brief 15. */
.brands {
  list-style: none;
  margin: var(--s5) 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--s3);
}

.brands li {
  font-family: var(--font-display);
  font-size: 1.0625rem;
  padding: var(--s2) var(--s4);
  border: 1px solid var(--band-rule, rgba(47, 47, 47, 0.2));
  border-radius: var(--r-control);
}

/* --- Responsive ---------------------------------------------------------- */

@media (max-width: 1023px) {
  .picker__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* minmax(0, 1fr), NOT 1fr. Measured at 200% text on a 390px screen: the
     panel was 335px wide and its single column computed to 384.95px, pushing
     the whole page 30px sideways. 1fr means minmax(auto, 1fr) and cannot
     shrink below its content's min-content width, which the prose exceeds at
     that text size. A zero floor lets it.

     Same trap the picker GRID hit across the 600-1023px band, written up
     further up this file — same cause, different element, and found the second
     time by a text-zoom check rather than a width sweep. */
  .picker__panel { grid-template-columns: minmax(0, 1fr); gap: var(--s5); }
  .picker__media { order: -1; }
  .steps { grid-template-columns: 1fr; gap: var(--s6); }
}

@media (max-width: 599px) {
  .picker__grid { grid-template-columns: 1fr; gap: var(--s3); margin-bottom: var(--s7); }
  .picker__card { padding: var(--s4); }

  /* Gives the badge its line back, on the one card in six that has one. A
     general sibling combinator rather than :has(), matching the gutter rule
     higher up this file and for the same reason — the flag is always the first
     child, so this is exact and needs no support caveat. It is HERE, in the
     same block as the padding shorthand, so nothing downstream can quietly
     undo it the way the old rule was undone. */
  .picker__flag ~ .picker__name { margin-top: 26px; }

  .picker__nav { justify-content: space-between; }
}

