@charset "UTF-8";

/* --- Fonts ----------------------------------------------------------------
   Self-hosted rather than loaded from Google. Fetching them from
   fonts.googleapis.com meant a DNS lookup, TLS handshake and stylesheet round
   trip to a third party before a single character could render, and the font
   files themselves only began downloading after that stylesheet arrived.
   Served from our own origin they are already on a warm connection.

   Both faces are variable, so one file covers every weight the site uses, and
   Archivo also carries the width axis the display type depends on. Only the
   Latin subset ships, which covers everything on the site including em dashes,
   curly quotes, degree signs and middots.

   `swap` keeps text visible in a fallback while they load. -------------- */

@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400 800;
  font-stretch: 100% 125%;
  font-display: swap;
  src: url('../assets/fonts/archivo-latin-var.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Schibsted Grotesk';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('../assets/fonts/schibsted-grotesk-latin-var.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ==========================================================================
   AERO DUCT HYGIENE
   Static stylesheet — no build step required.

   Contents
   01. Tokens
   02. Reset & base
   03. Typography
   04. Layout
   05. Buttons & links
   06. Header / navigation
   07. Mobile action bar
   08. Hero
   09. Components
   10. Photo art direction (duotone treatment)
   11. Footer
   12. Motion
   13. Utilities & print
   ========================================================================== */


/* 01. TOKENS ============================================================== */

:root {
  /* --- Brand olive ---------------------------------------------------------
     #6B6A40 is sampled straight from the logo files and is never altered.

     Worth knowing: that colour sits at hue 58.6°, and pure yellow is 60°. The
     brand is technically a dark yellow-olive, not a green. Tints made by
     simply lightening it therefore drift further into yellow and start to read
     as tan. The lighter steps below are nudged toward 66–70° to hold an olive
     reading as they lighten, while 500 stays exact. */
  --olive-50:  #F4F4EC;
  --olive-100: #E4E4CF;
  --olive-200: #C4CAA5;   /* hue 70 */
  --olive-300: #A7AE7A;   /* hue 68 */
  --olive-400: #888D58;   /* hue 66 */
  --olive-500: #6B6A40;   /* brand — exact logo colour, hue 58.6 */
  --olive-600: #535634;   /* hue 65 */
  --olive-700: #414428;
  --olive-800: #2C2E1C;
  --olive-900: #191A11;

  /* --- Desert sand. Secondary accent: warms the olive without competing
         with it. Named "dune" so it cannot collide with the --sand-* warm
         neutrals below, which carry body text. ---------------------------- */
  --dune-200: #E8D8C0;
  --dune-300: #D9C3A3;
  --dune-400: #C9AE8B;   /* desert sand accent — 9.6:1 on --surface */
  --dune-600: #9A7F5C;

  /* --- Warm neutrals. Deliberately not pure black (OLED smear). --------- */
  --ink-950: #0B0B09;
  --ink-900: #100F0D;
  --ink-850: #141410;
  --ink-800: #1A1A15;
  --ink-700: #23231D;
  --ink-600: #2F2F27;
  --ink-500: #3D3D34;

  --sand-100: #F5F4EE;
  --sand-300: #C9C7BA;
  --sand-500: #8E8C7F;

  /* --- Semantic. Components reference these, never raw hex. -------------
     Contrast on --surface (#0B0B09):
       --text          17.4:1   AAA
       --text-muted     6.0:1   AA
       --accent         8.4:1   AA  (interactive text)
       --brand          3.5:1   large text / UI glyphs only — never body copy
       white on --brand 5.6:1   AA  (primary button)                        */
  --surface:        var(--ink-950);
  --surface-raised: var(--ink-850);
  --surface-sunken: var(--ink-900);
  --surface-card:   var(--ink-800);

  --text:        var(--sand-100);
  --text-muted:  var(--sand-500);
  --text-subtle: var(--sand-300);

  --brand:        var(--olive-500);
  --brand-strong: var(--olive-400);
  --accent:       var(--olive-300);
  --accent-warm:  var(--dune-400);   /* desert sand — eyebrows, rules, marks */
  --on-brand:     #FFFFFF;

  /* The only red on the site, and only ever for form validation. Two values
     because one cannot do both jobs: the brick reads correctly as a border or
     fill, but as text on the tinted invalid-field background it measures
     4.34:1 and misses AA. The lighter tone is for words only — 6.4:1 there. */
  --danger:       #C9553D;
  --danger-text:  #E07A62;

  --border:       color-mix(in srgb, var(--sand-100) 12%, transparent);
  --border-strong:color-mix(in srgb, var(--sand-100) 22%, transparent);
  --border-brand: color-mix(in srgb, var(--olive-400) 45%, transparent);

  --ring: var(--olive-300);

  /* --- Type ------------------------------------------------------------- */
  /* Three roles, two faces.
       --font-display : Instrument Serif. High-contrast display serif, used only
                        for h1/h2 and pull quotes. Its thick/thin modulation is
                        what a grotesque cannot give you at large sizes, and it
                        supplies the contrast a single-family system lacked.
       --font-ui      : Schibsted Grotesk. Every control, label, number and
                        small heading — anything that has to stay legible at
                        13px or be read as interactive.
       --font-body    : Schibsted Grotesk. Body copy.
     Serif for voice, grotesque for work. Neither is Inter or Roboto. */
  /* Archivo, run at an expanded width. The logo wordmark is wide, squared and
     technical with flat terminals — a high-contrast editorial serif (the
     previous Instrument Serif) fought it rather than supporting it. Archivo's
     width axis lets the headings echo the mark's proportions directly. */
  --font-display: 'Archivo', 'Archivo Expanded', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
  --font-ui: 'Schibsted Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-body: 'Schibsted Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;

  --fs-xs:   0.75rem;
  --fs-sm:   0.875rem;
  --fs-base: 1rem;
  --fs-md:   1.0625rem;
  --fs-lg:   clamp(1.125rem, 0.4vw + 1.05rem, 1.3125rem);
  --fs-xl:   clamp(1.375rem, 1vw + 1.1rem, 1.75rem);
  --fs-2xl:  clamp(1.75rem, 2vw + 1.2rem, 2.5rem);
  --fs-3xl:  clamp(2rem, 4.2vw + 0.8rem, 3.25rem);
  --fs-4xl:  clamp(2.5rem, 6vw + 0.6rem, 4.5rem);

  --lh-tight: 1.06;
  --lh-head:  1.16;
  --lh-body:  1.7;

  --tracking-wide: 0.18em;
  --tracking-mid:  0.08em;

  /* --- Space (4/8 rhythm) ----------------------------------------------- */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-7: 2.5rem;
  --space-8: 3rem;
  --space-9: 4rem;
  --space-10: 5rem;
  --space-11: 6rem;
  --space-12: 8rem;

  /* Generous by default. The site reads as calmer when sections are given
     room to separate rather than relying on borders to divide them.

     Three values, not one. A single repeated gap is even but it groups
     nothing — every section ends up exactly as related to its neighbour as
     to a section four screens away, which is why a long page can be well
     spaced and still read as a list of unconnected slabs. See the rhythm
     block further down for how the joints are chosen. */
  --section-y: clamp(5.5rem, 12vw, 10rem);
  --section-y-close: clamp(3.5rem, 7vw, 6rem);
  --section-y-open: clamp(7rem, 15vw, 12.5rem);
  --container: 1200px;
  --container-narrow: 720px;
  --gutter: clamp(1.25rem, 4vw, 2.5rem);

  /* --- Radius / elevation ----------------------------------------------- */
  --r-sm: 4px;
  --r-md: 8px;
  --r-lg: 14px;
  --r-xl: 22px;
  --r-pill: 999px;

  --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.55);
  --shadow-lg: 0 28px 64px -24px rgb(0 0 0 / 0.7);
  --shadow-brand: 0 18px 48px -20px color-mix(in srgb, var(--olive-500) 55%, transparent);

  /* --- Motion ------------------------------------------------------------ */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);      /* expo.out */
  --ease-in:  cubic-bezier(0.7, 0, 0.84, 0);
  --ease-soft:cubic-bezier(0.33, 1, 0.68, 1);

  --dur-fast: 150ms;
  --dur-mid:  260ms;
  --dur-slow: 420ms;
  --dur-reveal: 700ms;

  /* --- Layering ---------------------------------------------------------- */
  --z-base: 0;
  --z-raised: 10;
  --z-sticky: 40;
  --z-header: 60;
  --z-drawer: 80;
  --z-modal: 100;

  --header-h: 72px;
  --actionbar-h: 64px;

  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

@media (max-width: 767px) {
  :root { --header-h: 64px; }
}


/* 02. RESET & BASE ======================================================== */

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

html {
  -webkit-text-size-adjust: 100%;
  /* Kill the browser's default tap flash. Android Chrome paints a bright blue
     wash over anything you touch and iOS a grey box — on a dark olive palette
     both read as an unfinished app, and it is the most obvious handheld tell
     this site had. Safe to remove only because real :active states already
     exist on .btn and .actionbar a, so the tap still confirms itself. */
  -webkit-tap-highlight-color: transparent;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-h) + 1.5rem);
  /* Reserve the scrollbar track permanently. Locking body scroll for the
     drawer or the booking modal otherwise removes the scrollbar and shifts
     the entire page sideways by ~15px — a visible jolt every time either
     opens, and one of the more obvious cheap-site tells. */
  scrollbar-gutter: stable;
}

body {
  margin: 0;
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--fs-md);
  line-height: var(--lh-body);
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
  position: relative;
}

/* --- Page atmosphere -----------------------------------------------------
   Flat near-black reads cheap at this scale. Two fixed layers sit behind all
   content and give the page depth without adding a single request:
     1. a very soft warm/olive wash, so the "black" is never actually flat
     2. fine film grain over everything, which is what stops large dark areas
        looking like banded, empty space on cheap panels
   Both are pointer-events:none and never intercept interaction.            */

body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background:
    /* Warm high corner — the sun side. */
    radial-gradient(ellipse 85% 55% at 82% -8%,  color-mix(in srgb, var(--dune-600) 30%, transparent), transparent 60%),
    /* Olive counterweight, low and left. */
    radial-gradient(ellipse 75% 55% at 4% 34%,   color-mix(in srgb, var(--olive-500) 30%, transparent), transparent 62%),
    /* Deep pool under the fold, so long pages do not flatten out. */
    radial-gradient(ellipse 120% 65% at 55% 106%, color-mix(in srgb, var(--olive-600) 26%, transparent), transparent 66%),
    /* Slow vertical tonal shift. This is what stops a dark page reading as a
       flat black rectangle — kept under ~6% luminance so it never bands. */
    linear-gradient(178deg, #12120E 0%, #0B0B09 42%, #0B0B09 68%, #100F0C 100%),
    var(--surface);
  background-attachment: fixed;
}

body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 9999;
  pointer-events: none;
  background-image: var(--grain);
  background-size: 170px 170px;
  opacity: 0.035;
}

/* Room for the sticky mobile call/book bar. */
@media (max-width: 900px) {
  body { padding-bottom: calc(var(--actionbar-h) + env(safe-area-inset-bottom, 0px)); }
}

/* Scroll lock. No padding compensation is needed here: `scrollbar-gutter:
   stable` on <html> already reserves the track permanently, so hiding the
   scrollbar does not change the usable width. Adding padding on top of that
   corrected for it a second time and shifted content the other way. */
body.is-locked { overflow: hidden; }

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

button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; padding: 0; }

ul, ol { margin: 0; padding: 0; list-style: none; }

hr {
  border: 0;
  height: 1px;
  background: var(--border);
  margin: var(--space-8) 0;
}

::selection { background: var(--olive-500); color: #fff; }

:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
  border-radius: var(--r-sm);
}
:focus:not(:focus-visible) { outline: none; }

.skip-link {
  position: absolute;
  left: var(--space-4);
  top: -100px;
  z-index: var(--z-modal);
  padding: var(--space-3) var(--space-5);
  background: var(--brand);
  color: var(--on-brand);
  border-radius: var(--r-md);
  font-weight: 600;
  transition: top var(--dur-mid) var(--ease-out);
}
.skip-link:focus { top: var(--space-4); }


/* 03. TYPOGRAPHY ========================================================== */

h1, h2, h3, h4, h5 {
  margin: 0;
  line-height: var(--lh-head);
  text-wrap: balance;
}

/* h1 and h2 are the display voice: Instrument Serif at 400. A high-contrast
   serif carries size on its thick/thin modulation rather than on weight, so
   it needs no bolding — and far less negative tracking than a grotesque,
   which would otherwise collide the thin strokes. */
/* Expanded width plus heavy weight is what gives headings the same stance as
   the logo. Tracking goes negative to stop wide letterforms drifting apart at
   display size. */
h1, h2 {
  font-family: var(--font-display);
  font-weight: 700;
  font-variation-settings: 'wdth' 112;
  letter-spacing: -0.022em;
}
/* Line-height still leaves room for descenders (g, y, p) — the reveal wipe
   uses overflow:hidden and will slice them otherwise. */
h1 { font-size: var(--fs-4xl); line-height: 1.06; }
h2 { font-size: var(--fs-3xl); line-height: 1.1; }

/* h3/h4 stay in the grotesque. At card and list sizes they behave as labels,
   and a display serif at 20px reads as decoration rather than structure. */
h3, h4, h5 {
  font-family: var(--font-ui);
  font-weight: 600;
  letter-spacing: -0.015em;
}
h3 { font-size: var(--fs-xl); }
h4 { font-size: var(--fs-lg); }

p { margin: 0 0 var(--space-4); text-wrap: pretty; }
p:last-child { margin-bottom: 0; }

a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 0.22em; }
a:hover { color: var(--olive-200); }

strong, b { font-weight: 600; color: var(--text); }

.lede {
  font-size: var(--fs-lg);
  line-height: 1.65;
  color: var(--text-subtle);
  max-width: 62ch;
}

.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  /* Olive, not sand. Eyebrows are the most-repeated small text on the site, so
     putting the warm tan hue (34°) on all of them was what made the palette
     read brownish overall. Sand is now reserved for a few accents. */
  color: var(--accent);
  margin: 0 0 var(--space-5);
}

.muted { color: var(--text-muted); }
.measure { max-width: 62ch; }

/* --- Measure -------------------------------------------------------------
   Line length was the last place the layout still read as a desktop design
   being viewed small. Measured across every page:

     820px  (tablet, splits stacked)   87-106 characters a line, every page
     1440px (desktop)                  three blocks at 92, 92 and 160

   45-75 characters is the comfortable range; past about 80 the eye loses its
   place returning to the next line. The tablet band was worst because it is
   the one width where the split sections have collapsed to a single column
   but the prose still has the full 739px container to fill — longer lines
   than either the phone or the desktop it sits between.

   Capping the container-width prose fixes both at once. On desktop the split
   columns are already narrower than the cap so it changes nothing there; on a
   phone the viewport is narrower still. It bites only where a paragraph would
   otherwise run the whole container. */
.container > p,
.section-head--rail p { max-width: 68ch; }

/* A <p> that is itself centred — the one wrapping the "Full service details"
   button — still picks up the 68ch cap above, and with no auto margin that
   capped box sits hard left. The text then centres perfectly inside a box that
   is itself 200px off-centre, which reads as a button dropped at random under
   the grid. Centre the box, not just its contents. */
.container > p.text-center { margin-inline: auto; }

.hero__note { max-width: 46ch; }
.hero--center .hero__note { margin-inline: auto; }

/* Paragraphs only. An earlier version capped `ul` here too and broke the
   service-area list: .area-list is a grid whose columns are
   minmax(min(100%, 190px), 1fr), so giving the list a ch-based max-width
   changed what its 100% resolved against, and the grid item — which will not
   shrink below min-content — pushed the document to 727px inside a 390px
   viewport. Lists on this site are layout, not prose. */
@media (max-width: 899px) {
  .split > * > p,
  .card > p,
  .form > p,
  .field__help,
  .stack > p { max-width: 68ch; }

  /* Checklist rows and tier blurbs were missed by the caps above. Between the
     two-column breakpoint and the phone layout they run the full card width —
     measured at 90 characters per line on a 768px tablet, well past the 45-75
     that stays comfortable to read. The cap is on the span, not the <li>,
     because the row is a flex line whose icon must stay put. */
  .trustbar__item > span { max-width: 62ch; }
  .tier__blurb,
  .tier__list li > span { max-width: 62ch; }

  /* Blocks that are centred stay centred: a max-width with no auto margin
     would pin the narrowed box to the left and leave the heading above it
     visibly off-axis. */
  .text-center p,
  .cta-band p,
  .cta-band__price,
  .section-head--center p { margin-inline: auto; }
}

/* 12px eyebrows are fine on a desktop where they sit beside 60px headlines.
   On a phone they are the smallest thing on the page and the one a reader has
   to squint at, so they come up a step. */
@media (max-width: 620px) {
  .eyebrow,
  .cta-band__proof figcaption { font-size: 0.8125rem; }
}

/* Tabular figures keep stat rows and prices from shifting. */
.tabular { font-variant-numeric: tabular-nums; }


/* 04. LAYOUT ============================================================== */

.container {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.container--narrow { max-width: var(--container-narrow); }

.section {
  padding-block: var(--pad-top, var(--section-y)) var(--pad-bottom, var(--section-y));
  position: relative;
}
.section--tight { --pad-top: clamp(3rem, 6vw, 4.5rem); --pad-bottom: clamp(3rem, 6vw, 4.5rem); }

/* --- Section rhythm ------------------------------------------------------
   Which gap a joint gets is an editorial decision, so it is declared on the
   section that starts the new thought rather than computed from anything.

     (default)            sections are related but the subject has moved on
     .section--continues  same argument, second half — the pair should read
                          as one movement with a beat in it
     .section--turns      a real change of subject; the page takes a breath

   The joint closes from both sides. A section whose successor continues it
   pulls its own bottom padding in to match, so the gap stays symmetrical and
   nobody has to keep two classes in sync by hand. Same for the tight CTA and
   figure bands, which should always sit close to what sets them up — the
   "from $329" figure is the payoff of the process section above it, and it
   looked orphaned across a full-size gap.

   :has() is already load-bearing elsewhere in this file (the self-declaring
   clickable rows), so this leans on the same support floor. Browsers without
   it fall back to the even default, which is what the site shipped with. */
.section--continues { --pad-top: var(--section-y-close); }
.section--turns     { --pad-top: var(--section-y-open); }

.section:has(+ .section--continues) { --pad-bottom: var(--section-y-close); }
.section:has(+ .section--turns)     { --pad-bottom: var(--section-y-open); }
.section:has(+ .section--tight)     { --pad-bottom: var(--section-y-close); }

/* Translucent rather than solid, so the fixed page wash reads through and
   sections separate by tone instead of by hard blocks of flat colour. */
.section--sunken { background: color-mix(in srgb, var(--ink-900) 72%, transparent); }
.section--raised { background: color-mix(in srgb, var(--ink-850) 68%, transparent); }

/* Hairline dividers that stay visible in the dark palette. */
.section--edge { border-top: 1px solid var(--border); }

.section-head { max-width: 68ch; margin-bottom: var(--space-8); }
.section-head--center { margin-inline: auto; text-align: center; }
.section-head p { margin-top: var(--space-4); color: var(--text-subtle); font-size: var(--fs-lg); }

.grid { display: grid; gap: var(--space-5); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); }
.grid--4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }

/* Cards in a row have three bands — icon, heading, copy — and left to
   themselves each sizes those bands to its own text. A two-line heading beside
   a one-line heading drops that card's paragraph half a line, and across four
   cards the row reads as though nothing sits on a baseline. That is the
   bouncing look.

   Subgrid pins all three bands to shared tracks, so headings start together
   and paragraphs start together however the copy wraps. Row gap is zeroed
   because a subgrid inherits the parent's gap, which would otherwise be
   injected between the bands inside each card as well as between cards. */
@supports (grid-template-rows: subgrid) {
  @media (min-width: 700px) {
    .grid--cards {
      grid-template-rows: auto auto auto;
      row-gap: 0;
      align-items: stretch;
    }
    .grid--cards > .card {
      display: grid;
      grid-template-rows: subgrid;
      grid-row: span 3;
      gap: var(--space-4);
    }
  }
}

.split {
  display: grid;
  gap: clamp(2rem, 5vw, 4.5rem);
  align-items: center;
}
/* Centring is right when one side is an image and the other is a short block.
   It is wrong when both sides are content of very different heights — the
   contact page put a long form beside a short details list, and centring the
   short one dropped its heading well below the form's. Top-align those. */
.split--top { align-items: start; }

@media (min-width: 900px) {
  .split { grid-template-columns: 1fr 1fr; }
  .split--wide-left { grid-template-columns: 1.15fr 0.85fr; }
  .split--wide-right { grid-template-columns: 0.85fr 1.15fr; }
}

.stack > * + * { margin-top: var(--space-4); }
.stack-lg > * + * { margin-top: var(--space-6); }

.cluster {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  align-items: center;
}


/* 05. BUTTONS & LINKS ===================================================== */

.btn {
  --btn-bg: transparent;
  --btn-fg: var(--text);
  --btn-bd: var(--border-strong);

  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);

  /* 52px tall clears the 44px minimum with room to spare. */
  min-height: 52px;
  padding: 0 var(--space-6);

  font-family: var(--font-ui);
  font-size: var(--fs-base);
  font-weight: 600;
  letter-spacing: 0.01em;
  text-decoration: none;
  white-space: nowrap;

  color: var(--btn-fg);
  /* background-color, not the shorthand: the primary variant rides a gradient
     on background-image for its hover sheen, and the shorthand would wipe it. */
  background-color: var(--btn-bg);
  border: 1px solid var(--btn-bd);
  border-radius: var(--r-pill);

  transition:
    background-color var(--dur-mid) var(--ease-out),
    border-color var(--dur-mid) var(--ease-out),
    color var(--dur-mid) var(--ease-out),
    transform var(--dur-fast) var(--ease-out),
    box-shadow var(--dur-mid) var(--ease-out);
  touch-action: manipulation;
}

.btn:hover { transform: translateY(-2px); }
.btn:active { transform: translateY(0) scale(0.985); }

.btn--primary {
  --btn-bg: var(--brand);
  --btn-fg: var(--on-brand);
  --btn-bd: var(--brand);
  box-shadow: var(--shadow-brand);

  /* Hover sheen. One pass of warm light, in brand colour rather than the
     gradient-rainbow the pattern usually ships with. Rest position keeps the
     bright band off the left edge, so nothing shows until hover. */
  background-image: linear-gradient(
    100deg,
    transparent 40%,
    color-mix(in srgb, #FFFFFF 20%, transparent) 50%,
    transparent 60%
  );
  background-repeat: no-repeat;
  background-size: 250% 100%;
  background-position: 175% 0;
}
.btn--primary:hover { --btn-bg: var(--brand-strong); --btn-bd: var(--brand-strong); }

/* Plays once per hover. `forwards` parks the band off the right edge instead
   of letting the transition run backwards across the button on mouse-out —
   both end states are off-canvas, so neither entry nor exit shows a jump. */
.btn--primary:hover,
.btn--primary:focus-visible {
  animation: btn-sheen 720ms var(--ease-out) 1 forwards;
}

@keyframes btn-sheen {
  from { background-position: 175% 0; }
  to   { background-position: -75% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .btn--primary { background-image: none; }
  .btn--primary:hover,
  .btn--primary:focus-visible { animation: none; }
}

.btn--light {
  --btn-bg: var(--sand-100);
  --btn-fg: var(--ink-950);
  --btn-bd: var(--sand-100);
}
.btn--light:hover { --btn-bg: #fff; --btn-bd: #fff; }

.btn--ghost { --btn-bd: var(--border-brand); --btn-fg: var(--text); }
.btn--ghost:hover { --btn-bg: color-mix(in srgb, var(--olive-500) 16%, transparent); --btn-bd: var(--olive-400); }

.btn--lg { min-height: 60px; padding: 0 var(--space-7); font-size: var(--fs-md); }
.btn--block { width: 100%; }

.btn[disabled], .btn[aria-disabled="true"] {
  opacity: 0.42;
  pointer-events: none;
}

.btn .icon { width: 18px; height: 18px; flex: none; }

/* Text link with an animated rule — used for "read more" style affordances. */
.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: var(--fs-sm);
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  text-decoration: none;
  color: var(--accent);
  min-height: 44px;
}
.link-arrow .icon {
  width: 16px; height: 16px;
  transition: transform var(--dur-mid) var(--ease-out);
}
.link-arrow:hover .icon { transform: translateX(4px); }


/* 06. HEADER / NAVIGATION ================================================= */

.header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: var(--z-header);
  height: var(--header-h);
  display: flex;
  align-items: center;
  background: color-mix(in srgb, var(--ink-950) 72%, transparent);
  border-bottom: 1px solid transparent;
  transition: background-color var(--dur-mid) var(--ease-out),
              border-color var(--dur-mid) var(--ease-out);
}
@supports (backdrop-filter: blur(1px)) {
  .header { backdrop-filter: blur(14px) saturate(140%); }
}
.header.is-scrolled {
  background: color-mix(in srgb, var(--ink-950) 92%, transparent);
  border-bottom-color: var(--border);
}

.header__inner {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--gutter);
  display: flex;
  align-items: center;
  gap: var(--space-5);
}

/* No auto margin here — .nav carries margin-inline:auto so it centres itself
   between the logo and the actions, and .nav-toggle has its own margin-left
   auto for the mobile case. Two competing auto margins made the spacing
   dependent on viewport width in a way that looked accidental. */
.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  text-decoration: none;
  min-height: 44px;
  flex: none;
}
.brand img { height: 30px; width: auto; }
@media (min-width: 768px) { .brand img { height: 34px; } }

/* Nav appears at 1024px. The original cramping was labels *wrapping* onto two
   lines, which white-space:nowrap below now makes impossible; the breakpoint
   was briefly raised to 1140 as well, which was redundant and cost the tabs on
   any laptop window under that width.

   Measured at exactly 1024 with the phone hidden: brand 211 + nav 511 +
   actions 135, leaving 36px clear on each side and no wrapping. */
.nav { display: none; }
@media (min-width: 1024px) {
  .nav {
    display: flex;
    align-items: center;
    gap: clamp(0.25rem, 1.2vw, 1rem);
    margin-inline: auto;
  }
}

.nav__link {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 var(--space-3);
  font-family: var(--font-ui);
  font-size: 0.9375rem;
  font-weight: 450;
  letter-spacing: -0.005em;
  color: var(--text-subtle);
  text-decoration: none;
  white-space: nowrap;              /* never let a label wrap */
  transition: color var(--dur-mid) var(--ease-out);
}

/* A hairline that grows from the centre. Quieter — and more expensive-looking
   — than a filled hover pill, which tends to read as a default UI kit. */
.nav__link::after {
  content: "";
  position: absolute;
  left: var(--space-3);
  right: var(--space-3);
  bottom: 10px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform var(--dur-mid) var(--ease-out), background-color var(--dur-mid) var(--ease-out);
}

.nav__link:hover { color: var(--text); }
.nav__link:hover::after { transform: scaleX(1); }

/* Current page: colour AND a persistent rule — never colour alone. */
.nav__link[aria-current="page"] { color: var(--text); }
.nav__link[aria-current="page"]::after {
  transform: scaleX(1);
  background: var(--accent-warm);
}

.header__actions { display: none; }
/* Matches the nav breakpoint — the Book Online button and the tabs appear
   together, so the header never shows tabs with no action beside them. */
@media (min-width: 1024px) {
  .header__actions { display: flex; align-items: center; gap: var(--space-3); flex: none; }
}

/* The header number is information, not an action — on a desktop a tel: link
   usually has nothing to hand off to, and a button that appears to do nothing
   when clicked is worse than plain text. Styled as text; still a tel: link so
   softphones and click-to-call extensions can use it, and main.js copies it
   to the clipboard on click so the interaction always does something. */
.header__phone {
  display: none;
  align-items: center;
  gap: var(--space-2);
  min-height: 44px;
  font-family: var(--font-ui);
  font-size: 0.9375rem;
  font-weight: 500;
  letter-spacing: -0.005em;
  color: var(--text-subtle);
  text-decoration: none;
  white-space: nowrap;
  transition: color var(--dur-mid) var(--ease-out);
}
@media (min-width: 1320px) {
  .header__phone { display: inline-flex; }
}
.header__phone:hover { color: var(--text); }
.header__phone .icon { width: 16px; height: 16px; color: var(--accent-warm); flex: none; }

/* Brief confirmation after a click-to-copy. */
.is-copied { position: relative; }
.is-copied::after {
  content: "Copied";
  position: absolute;
  left: 50%;
  bottom: calc(100% + 6px);
  transform: translateX(-50%);
  padding: 4px 10px;
  border-radius: var(--r-pill);
  background: var(--olive-500);
  color: #fff;
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
  pointer-events: none;
  animation: copied-in 1.6s var(--ease-out) forwards;
}
@keyframes copied-in {
  0%   { opacity: 0; transform: translateX(-50%) translateY(4px); }
  12%  { opacity: 1; transform: translateX(-50%) translateY(0); }
  75%  { opacity: 1; }
  100% { opacity: 0; }
}

.header .btn {
  min-height: 44px;
  padding-inline: var(--space-5);
  font-size: 0.9375rem;
  font-weight: 500;
}

/* --- Hamburger --- */
.nav-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px; height: 44px;
  margin-left: auto;
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  color: var(--text);
}
@media (min-width: 1024px) { .nav-toggle { display: none; } }

.nav-toggle__box { position: relative; width: 18px; height: 12px; }
.nav-toggle__box span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 1.5px;
  background: currentColor;
  border-radius: 2px;
  transition: transform var(--dur-mid) var(--ease-out), opacity var(--dur-fast) linear;
}
.nav-toggle__box span:nth-child(1) { top: 0; }
.nav-toggle__box span:nth-child(2) { top: 5.25px; }
.nav-toggle__box span:nth-child(3) { top: 10.5px; }

.nav-toggle[aria-expanded="true"] .nav-toggle__box span:nth-child(1) { transform: translateY(5.25px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle__box span:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] .nav-toggle__box span:nth-child(3) { transform: translateY(-5.25px) rotate(-45deg); }

/* --- Drawer --- */
.drawer {
  position: fixed;
  inset: 0;
  z-index: var(--z-drawer);
  display: grid;
  grid-template-columns: 1fr min(88vw, 400px);
  visibility: hidden;
  pointer-events: none;
  /* The closed panel is parked at translateX(100%), a full panel-width past
     the right edge. Without clipping here it still counts toward the
     document's scroll width and produces phantom horizontal overflow. */
  overflow: hidden;
  /* Hold visibility until the panel has finished sliding out, then flip it.
     Opening flips instantly (delay 0). Without this the panel goes from
     visibility:hidden straight into a transform transition, so the browser is
     still creating its layer while the first frames play — that dropped frame
     or two is the hiccup. */
  transition: visibility 0s linear var(--dur-slow);
}
.drawer.is-open {
  visibility: visible;
  pointer-events: auto;
  transition-delay: 0s;
}

.drawer__scrim {
  background: rgb(0 0 0 / 0.62);
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-out);
  border: 0;
  width: 100%;
  height: 100%;
}
.drawer.is-open .drawer__scrim { opacity: 1; }

.drawer__panel {
  /* Solid, not the translucent --surface-raised the sections use. A slide-out
     panel you can see the page through reads as a rendering fault, and the
     transparency also costs a full-page repaint on every animation frame. */
  background: var(--ink-850);
  will-change: transform;
  border-left: 1px solid var(--border);
  padding: calc(var(--header-h) + var(--space-5)) var(--space-6) var(--space-8);
  padding-bottom: max(var(--space-8), env(safe-area-inset-bottom, 0px));
  overflow-y: auto;
  overscroll-behavior: contain;
  transform: translateX(100%);
  transition: transform var(--dur-slow) var(--ease-out);
  display: flex;
  flex-direction: column;
  gap: var(--space-6);
}
.drawer.is-open .drawer__panel { transform: none; }

.drawer__close {
  position: absolute;
  top: calc((var(--header-h) - 44px) / 2);
  right: var(--gutter);
  width: 44px; height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  color: var(--text);
}

.drawer__nav { display: flex; flex-direction: column; }
.drawer__nav a {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 56px;
  padding: var(--space-2) 0;
  font-family: var(--font-ui);
  font-size: var(--fs-lg);
  font-weight: 500;
  color: var(--text);
  text-decoration: none;
  border-bottom: 1px solid var(--border);
}
.drawer__nav a[aria-current="page"] { color: var(--accent); }
.drawer__nav a[aria-current="page"]::after { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }


/* 07. MOBILE ACTION BAR =================================================== */

.actionbar {
  position: fixed;
  inset: auto 0 0 0;
  z-index: var(--z-sticky);
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1px;
  background: var(--border);
  border-top: 1px solid var(--border);
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
@media (min-width: 901px) { .actionbar { display: none; } }

.actionbar a {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--actionbar-h);
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  font-weight: 600;
  text-decoration: none;
  background: var(--ink-900);
  color: var(--text);
  transition: background-color var(--dur-fast) var(--ease-out);
}
.actionbar a:active { background: var(--ink-800); }
.actionbar a.is-primary { background: var(--brand); color: var(--on-brand); }
.actionbar a.is-primary:active { background: var(--olive-600); }
.actionbar .icon { width: 18px; height: 18px; }


/* 08. HERO ================================================================ */

.hero {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 90svh;
  padding-top: calc(var(--header-h) + clamp(3rem, 8vw, 6rem));
  padding-bottom: clamp(3.5rem, 9vw, 7rem);
  overflow: hidden;
  isolation: isolate;
}

/* Interior pages get a shorter banner — only the homepage earns full height. */
.hero--compact { min-height: 0; }

/* Interior banner carrying a photograph. Taller than a text-only banner so the
   image has room to be an image, but well short of the homepage's full height.
   The subject sits right of centre, so the copy keeps the left and the scrim
   is weighted to match. */
.hero--split {
  min-height: clamp(400px, 56vh, 600px);
  align-items: center;
}
.hero--split .hero__media img { object-position: 58% 62%; }

/* Subject sits high in frame: the ceiling vent the hose feeds into starts about
   2% down the image, and it is the point of the shot. On a wide, short hero
   (1920x600) the crop discards 472px of height, so anything past ~5% loses the
   vent entirely. Anchored near the top instead — what gets dropped is the
   bottom of the ladder, which costs nothing. */
.hero--split .hero__media--high img { object-position: 64% 4%; }

/* Contact's figure sits on the same full-bleed treatment as the other photo
   heroes rather than a constrained cut-out box. The subject already occupies
   the right of the frame with clear black to the left, so the split scrim
   works on it unchanged, and its black point is graded to #0B0B09 at export
   so image and page are literally the same black where they meet.

   The old .hero--figure rules (a 52%-wide contained cut-out, plus a mask to
   feather its bounding box) are gone with it; contact was the only user. */
.hero--split .hero__media--figure img { object-position: 62% 0%; }


.hero--split .hero__scrim {
  background:
    linear-gradient(96deg,
      rgb(11 11 9 / 0.94) 0%,
      rgb(11 11 9 / 0.86) 38%,
      rgb(11 11 9 / 0.42) 72%,
      rgb(11 11 9 / 0.5) 100%),
    linear-gradient(to bottom,
      rgb(11 11 9 / 0.6) 0%,
      transparent 30%,
      transparent 60%,
      var(--surface) 100%);
}
@media (max-width: 767px) {
  .hero--split .hero__media img { object-position: 62% 60%; }
  .hero--split .hero__scrim {
    background:
      linear-gradient(to bottom,
        rgb(11 11 9 / 0.88) 0%,
        rgb(11 11 9 / 0.74) 40%,
        rgb(11 11 9 / 0.86) 78%,
        var(--surface) 100%);
  }
}

/* Heroes whose imagery moves under the copy — a drifting still, or video —
   cannot be signed off against a single frame. Sampled across a full cycle,
   both put the lede on or under the 4.5 floor at some point (4.50 for the
   valley pan, 4.41 where the duct light drifts). Four extra points of alpha on
   the text side buys margin without dimming the right-hand reveal.
   Scoped to min-width so the mobile rule above still wins on phones. */
@media (min-width: 768px) {
  .hero__media--motion + .hero__scrim {
    background:
      linear-gradient(96deg,
        rgb(11 11 9 / 0.96) 0%,
        rgb(11 11 9 / 0.90) 38%,
        rgb(11 11 9 / 0.46) 72%,
        rgb(11 11 9 / 0.52) 100%),
      linear-gradient(to bottom,
        rgb(11 11 9 / 0.6) 0%,
        transparent 30%,
        transparent 60%,
        var(--surface) 100%);
  }
}


/* --- Scroll-driven hero ---------------------------------------------------
   The media starts as a centred window and opens to full bleed as you scroll,
   easing out of a slight zoom at the same time. The section is taller than the
   viewport and the inner panel is sticky, so the frame expands *in place*
   while the page moves underneath it.

   --hero-clip and --hero-zoom are written by main.js each frame. Their
   fallbacks are the finished state, so with JS off or reduced motion on, the
   hero is simply full-bleed from the start — no dead scroll, nothing hidden. */

.hero--scroll {
  display: block;
  min-height: 0;
  height: calc(100svh + var(--hero-scroll, 1000px));
  padding: 0;
  overflow: visible;
  isolation: auto;
}

.hero--scroll .hero__sticky {
  position: sticky;
  top: 0;
  height: 100svh;
  display: flex;
  align-items: center;
  overflow: hidden;
  isolation: isolate;
  padding-top: var(--header-h);
}

/* Opens from an inset frame to full bleed. The inset is deliberately moderate:
   the reference effect starts at 25% per side, but that shrinks the window to
   half the viewport and the footage — the strongest thing on the page — is
   barely visible at first paint, which is the moment that matters most. A
   13/10% frame still reads clearly as a window opening while showing the shot
   immediately, and leaves the centred headline room inside it. */
.hero--scroll .hero__media,
.hero--scroll .hero__scrim {
  clip-path: inset(
    calc(var(--hero-clip, 0) * 10%)
    calc(var(--hero-clip, 0) * 13%)
    round calc(var(--hero-clip, 0) * 20px)
  );
}
@media (max-width: 767px) {
  /* Less inset on phones — a small screen cannot spare the margin. */
  .hero--scroll .hero__media,
  .hero--scroll .hero__scrim {
    clip-path: inset(
      calc(var(--hero-clip, 0) * 5%)
      calc(var(--hero-clip, 0) * 6%)
      round calc(var(--hero-clip, 0) * 16px)
    );
  }
}

.hero--scroll .hero__media video,
.hero--scroll .hero__media img {
  transform: scale(var(--hero-zoom, 1));
  transform-origin: center;
}

/* The copy lifts and fades away as the frame finishes opening, so it never
   sits parked on screen while the user is still scrolling. */
.hero--scroll .hero__inner {
  opacity: var(--hero-text-opacity, 1);
  transform: translate3d(0, calc(var(--hero-text-shift, 0) * -28px), 0);
}

/* --- Full-bleed hero media -------------------------------------------- */

.hero__media {
  position: absolute;
  inset: 0;
  z-index: -3;
  overflow: hidden;
  background: var(--ink-900);
}

/* Poster and video are stacked, not nested. The poster is a real <img> rather
   than the video's poster attribute, because the video element is held at
   opacity 0 until playback starts — which would hide its own poster in every
   fallback case (phones, reduced motion, decode failure), leaving the hero
   with no imagery at all. */
.hero__media img,
.hero__media video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}

/* The initial reveal is short on purpose. main.js holds the video paused on
   frame zero until it is fully opaque, and the poster IS frame zero (measured:
   1.71/255 mean difference on the gallery hero, 2.75 on the home one), so this
   fade is covering the join between two near-identical stills. There is
   nothing to see, and every millisecond spent on it is a millisecond before
   the footage moves. Must stay in step with FADE_IN in main.js. */
.hero__media video {
  opacity: 0;
  transition: opacity 450ms var(--ease-out);
}
.hero__media video.is-playing { opacity: 1; }

/* Once the loop partner exists, both elements switch to the longer crossfade
   used for the handoffs — 700ms, matched to the FADE constant in main.js.
   Linear, because an eased crossfade between two copies of the same footage
   reads as a pulse.

   Keyed on the partner rather than on .hero--video-playing, which is added at
   reveal time and so used to override the initial fade with the 700ms curve
   before there was anything to cross-fade with. */
.hero__media:has(.hero__video--alt) video { transition: opacity 700ms linear; }

/* Slow drift across a still, so a photographic banner has the same quiet life
   as the video heroes rather than sitting dead on the page.

   Rate is matched to the home hero's footage, measured at 0.42% of frame
   width per second. Three things have to line up and the first version got
   two of them wrong, shipping at roughly half speed:

     - a +/-X% pair travels 2X in total, not X;
     - `scale` is applied after `translate`, so it multiplies the swing;
     - the rate must be expressed against the CONTAINER width, which is what
       the viewer sees, not the scaled image width.

   So: 2 x 4.35% x 1.16 = 10.09% of container travel over 24s = 0.42%/s.
   Verified against the composited matrix rather than the keyframe values.

   `alternate` reverses at each end so it never cuts back to the start. Scale
   supplies the overscan: 1.16 leaves 8% of image beyond each edge against a
   5.05% swing, so no edge is ever exposed. */
@media (prefers-reduced-motion: no-preference) {
  .hero__media--pan img {
    will-change: transform;
    animation: hero-drift 24s ease-in-out infinite alternate;
  }
}

@keyframes hero-drift {
  from { transform: scale(1.16) translate3d(-4.35%, 0, 0); }
  to   { transform: scale(1.16) translate3d( 4.35%, 0, 0); }
}

/* Scrim. Text sits on the left, so the gradient is heaviest there and lifts
   toward the right to let the footage show. Without this the headline fails
   contrast over bright frames. */
.hero__scrim {
  position: absolute;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background:
    linear-gradient(96deg,
      rgb(11 11 9 / 0.88) 0%,
      rgb(11 11 9 / 0.8)  34%,
      rgb(11 11 9 / 0.42) 64%,
      rgb(11 11 9 / 0.55) 100%),
    linear-gradient(to bottom,
      rgb(11 11 9 / 0.7) 0%,
      transparent 26%,
      transparent 58%,
      var(--surface) 100%);
}
@media (max-width: 767px) {
  /* Narrow screens put text over the middle of the frame, so it needs more. */
  .hero__scrim {
    background:
      linear-gradient(to bottom,
        rgb(11 11 9 / 0.92) 0%,
        rgb(11 11 9 / 0.78) 45%,
        rgb(11 11 9 / 0.88) 75%,
        var(--surface) 100%);
  }
}

/* Centred copy sits over the middle of the frame, so the scrim has to be
   symmetrical — a soft central pool plus edge falloff, rather than the
   side-weighted gradient used for left-aligned text. */
.hero--center .hero__scrim,
.hero--center .hero__scrim {
  background:
    radial-gradient(ellipse 62% 58% at 50% 48%, rgb(11 11 9 / 0.82), rgb(11 11 9 / 0.52) 62%, transparent 100%),
    linear-gradient(to bottom,
      rgb(11 11 9 / 0.72) 0%,
      rgb(11 11 9 / 0.42) 24%,
      rgb(11 11 9 / 0.42) 62%,
      rgb(11 11 9 / 0.9) 92%,
      var(--surface) 100%);
}
@media (max-width: 767px) {
  .hero--center .hero__scrim {
    background:
      radial-gradient(ellipse 90% 52% at 50% 46%, rgb(11 11 9 / 0.86), rgb(11 11 9 / 0.62) 70%, transparent 100%),
      linear-gradient(to bottom,
        rgb(11 11 9 / 0.8) 0%,
        rgb(11 11 9 / 0.6) 30%,
        rgb(11 11 9 / 0.72) 70%,
        var(--surface) 100%);
  }
}

/* Slow-drifting ambient wash. Purely atmospheric, killed by reduced-motion. */
.hero__glow {
  position: absolute;
  z-index: -2;
  width: min(920px, 120vw);
  aspect-ratio: 1;
  border-radius: 50%;
  filter: blur(90px);
  opacity: 0.4;
  pointer-events: none;
}
.hero__glow--a {
  top: -34%;
  right: -18%;
  background: radial-gradient(circle, color-mix(in srgb, var(--olive-500) 78%, transparent), transparent 66%);
  animation: drift-a 22s var(--ease-soft) infinite alternate;
}
.hero__glow--b {
  bottom: -46%;
  left: -22%;
  width: min(700px, 100vw);
  opacity: 0.28;
  background: radial-gradient(circle, color-mix(in srgb, var(--olive-300) 55%, transparent), transparent 68%);
  animation: drift-b 27s var(--ease-soft) infinite alternate;
}

@keyframes drift-a {
  from { transform: translate3d(0, 0, 0) scale(1); }
  to   { transform: translate3d(-5%, 5%, 0) scale(1.1); }
}
@keyframes drift-b {
  from { transform: translate3d(0, 0, 0) scale(1.06); }
  to   { transform: translate3d(6%, -5%, 0) scale(1); }
}

/* Faint grid, evokes duct sheet metal. Suppressed only once video is actually
   playing — grid lines over footage read as noise. Until then the glow and
   grid are the fallback background, so a hero whose video never loads still
   looks composed rather than like a flat empty box. */
.hero--video-playing .hero__glow { opacity: 0; }
.hero--video-playing::before { opacity: 0; }
.hero__glow, .hero::before { transition: opacity 900ms var(--ease-out); }

.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-image:
    linear-gradient(to right, color-mix(in srgb, var(--sand-100) 4%, transparent) 1px, transparent 1px),
    linear-gradient(to bottom, color-mix(in srgb, var(--sand-100) 4%, transparent) 1px, transparent 1px);
  background-size: 72px 72px;
  mask-image: radial-gradient(ellipse 80% 60% at 50% 30%, #000 30%, transparent 78%);
  pointer-events: none;
}

.hero__inner { position: relative; width: 100%; }
.hero h1 { margin-bottom: var(--space-6); max-width: 15ch; }
.hero .lede { margin-bottom: var(--space-8); max-width: 46ch; }
.hero__cta { gap: var(--space-4); }

/* One quiet line under the buttons. It answers the objection this trade has
   earned — the number that changes once somebody is standing in your hallway —
   at the first moment of contact, before any scrolling. */
.hero__note {
  margin-top: var(--space-5);
  font-size: var(--fs-sm);
  color: var(--text-subtle);
}

/* Centred hero composition. */
.hero--center .hero__inner { text-align: center; }
.hero--center h1 { max-width: 18ch; margin-inline: auto; }
.hero--center .lede { max-width: 52ch; margin-inline: auto; text-wrap: balance; }
.hero--center .eyebrow { justify-content: center; }
.hero--center .hero__cta { justify-content: center; }

/* Floating price card. Concrete numbers do more work in a hero than another
   sentence of prose, and it gives the composition something to balance
   against the headline. */
.hero__card {
  position: relative;
  margin-top: var(--space-9);
  display: inline-flex;
  align-items: stretch;
  gap: var(--space-6);
  padding: var(--space-5) var(--space-6);
  border-radius: var(--r-lg);
  border: 1px solid var(--border-strong);
  background: color-mix(in srgb, var(--ink-900) 78%, transparent);
}
@supports (backdrop-filter: blur(1px)) {
  .hero__card { backdrop-filter: blur(16px) saturate(130%); }
}
@media (min-width: 900px) {
  .hero__card { position: absolute; right: 0; bottom: 0; margin-top: 0; }
}

.hero__card dt {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-2);
  white-space: nowrap;
}
.hero__card dd {
  margin: 0;
  font-size: var(--fs-xl);
  font-weight: 700;
  letter-spacing: -0.03em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.hero__card dd small {
  display: block;
  font-size: var(--fs-sm);
  font-weight: 400;
  letter-spacing: 0;
  color: var(--accent-warm);
  margin-top: 2px;
}
.hero__card .divider { width: 1px; background: var(--border); flex: none; }


/* 09. COMPONENTS ========================================================== */

/* --- Trust strip --- */
.trustbar {
  border-block: 1px solid var(--border);
  background: color-mix(in srgb, var(--ink-900) 68%, transparent);
  padding-block: var(--space-7);
}
.trustbar__list {
  display: grid;
  gap: var(--space-5) var(--space-6);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
  align-items: start;
}
.trustbar__item {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  font-size: var(--fs-sm);
  color: var(--text-subtle);
  line-height: 1.5;
}
.trustbar__item .icon { width: 20px; height: 20px; color: var(--accent); flex: none; margin-top: 1px; }

/* --- Card --- */
.card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  /* Open column, not a filled box. A bordered, tinted rectangle repeated in a
     grid is the most template-looking device on the web, and it was appearing
     in five separate sections. Reduced to a hairline top rule and space: the
     content still groups, but the page reads as editorial columns rather than
     a component library. Use .card--boxed where containment is genuinely
     wanted (pricing panels). */
  padding: var(--space-6) 0 0;
  background: none;
  border: 0;
  border-top: 1px solid var(--border-strong);
  border-radius: 0;
  transition: border-color var(--dur-mid) var(--ease-out),
              transform var(--dur-mid) var(--ease-out),
              box-shadow var(--dur-mid) var(--ease-out),
              background-color var(--dur-mid) var(--ease-out);
}
.card p { color: var(--text-muted); font-size: var(--fs-base); }

/* Hover lifts the rule and the heading, not a whole panel. A shadowed box
   floating on hover is the giveaway of a stock component; brightening the
   single hairline reads as considered. */
.card--link:hover,
.card--link:focus-within {
  border-top-color: var(--accent);
  transform: translateY(-3px);
}
.card--link:hover h3,
.card--link:focus-within h3 { color: var(--accent); }
.card h3 { transition: color var(--dur-mid) var(--ease-out); }

/* Quiet emphasis for one card in an otherwise uniform grid. A two-column span
   was tried first and rejected: it made the card three times the depth of its
   neighbours, which read as a layout fault rather than a priority. Colouring
   the hairline marks it at a glance and costs no extra room, so the grid stays
   two clean rows of three. The hover rule still wins on specificity. */
.card--accent { border-top-color: var(--olive-400); }

/* The few places containment is genuinely right — pricing panels. */
.card--boxed {
  padding: var(--space-6);
  background: color-mix(in srgb, var(--ink-800) 78%, transparent);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
}
.card--boxed:hover { transform: none; }

/* Whole card clickable, but the real anchor stays the accessible target. */
.card__overlay-link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
}

/* Sand, not olive. With every icon, rule and label in the same olive the page
   read monotone-green; splitting the palette by ROLE fixes it without adding
   a colour — olive means "action" (buttons, links), sand means "detail"
   (icons, numerals, marks). Two-tone reads considered; one-tone reads flat. */
.card__icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 48px; height: 48px;
  border-radius: var(--r-md);
  background: color-mix(in srgb, var(--dune-600) 20%, transparent);
  border: 1px solid color-mix(in srgb, var(--dune-400) 34%, transparent);
  color: var(--accent-warm);
  flex: none;
  transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out);
}
.card--link:hover .card__icon,
.card--link:focus-within .card__icon {
  color: var(--dune-200);
  border-color: color-mix(in srgb, var(--dune-400) 60%, transparent);
}
.card__icon .icon { width: 24px; height: 24px; }

.card h3 { font-size: var(--fs-lg); }

/* --- Numbered process steps --- */
/* --- Process steps --------------------------------------------------------
   Four beats hung from one shared hairline. This was four stacked h3+p blocks
   with a small light numeral — accurate, and completely inert.

   Three changes carry it: the numerals are set in the display face at a size
   that reads as typography rather than a list marker; a short brand-coloured
   tick anchors each step to the rule so the four scan as one run instead of
   four separate cards; and each step states how long it takes, which is the
   question every customer has and almost none of them ask. */

.steps {
  counter-reset: step;
  list-style: none;
  display: grid;
  gap: var(--space-9) var(--space-6);
  margin: 0;
  padding: 0;
}
@media (min-width: 700px)  { .steps { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1100px) { .steps { grid-template-columns: repeat(4, 1fr); } }

.step {
  counter-increment: step;
  position: relative;
  padding-top: var(--space-6);
  border-top: 1px solid var(--border);
}

/* Brand tick sitting over the shared rule, width-matched to the numeral. */
.step::after {
  content: "";
  position: absolute;
  top: -1px;
  left: 0;
  width: 2.75rem;
  height: 1px;
  background: var(--olive-400);
}

.step::before {
  content: counter(step, decimal-leading-zero);
  display: block;
  font-family: var(--font-display);
  font-size: clamp(2.75rem, 5vw, 3.75rem);
  font-weight: 700;
  font-variation-settings: 'wdth' 112;
  line-height: 0.9;
  letter-spacing: -0.045em;
  /* Held well back: it should register as rhythm, not compete with the copy. */
  color: color-mix(in srgb, var(--text) 20%, transparent);
  font-variant-numeric: tabular-nums;
  margin-bottom: var(--space-5);
}

.step__time {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--accent-warm);
  margin-bottom: var(--space-3);
}

.step h3 { font-size: var(--fs-lg); margin-bottom: var(--space-3); }
.step p { color: var(--text-muted); font-size: var(--fs-base); margin: 0; }

/* --- Stats --- */
.stats {
  display: grid;
  gap: var(--space-6);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
}
.stat { border-left: 2px solid var(--border-brand); padding-left: var(--space-5); }
.stat__value {
  display: block;
  font-family: var(--font-ui);
  font-size: var(--fs-3xl);
  font-weight: 300;
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  margin-bottom: var(--space-3);
}
.stat__label {
  font-size: var(--fs-sm);
  color: var(--text-muted);
  line-height: 1.45;
  display: block;
}

/* --- Accordion (FAQ) --- */
.accordion { border-top: 1px solid var(--border); }
.accordion__item { border-bottom: 1px solid var(--border); }

.accordion__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-5);
  width: 100%;
  min-height: 68px;
  padding: var(--space-4) 0;
  text-align: left;
  font-family: var(--font-ui);
  font-size: var(--fs-lg);
  font-weight: 500;
  color: var(--text);
  transition: color var(--dur-mid) var(--ease-out);
}
.accordion__trigger:hover { color: var(--accent); }

.accordion__sign { position: relative; width: 20px; height: 20px; flex: none; color: var(--accent); }
.accordion__sign::before,
.accordion__sign::after {
  content: "";
  position: absolute;
  left: 50%; top: 50%;
  background: currentColor;
  border-radius: 2px;
  transform: translate(-50%, -50%);
  transition: transform var(--dur-mid) var(--ease-out), opacity var(--dur-mid) var(--ease-out);
}
.accordion__sign::before { width: 16px; height: 1.5px; }
.accordion__sign::after  { width: 1.5px; height: 16px; }
.accordion__trigger[aria-expanded="true"] .accordion__sign::after { transform: translate(-50%, -50%) rotate(90deg); opacity: 0; }

.accordion__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--dur-slow) var(--ease-out);
}
.accordion__panel[data-open="true"] { grid-template-rows: 1fr; }
.accordion__panel > div { overflow: hidden; }
.accordion__panel p {
  color: var(--text-muted);
  max-width: 68ch;
  padding-bottom: var(--space-5);
  margin: 0;
}

/* --- Testimonials --- */
.quote {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  padding: var(--space-6);
  background: var(--surface-card);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  height: 100%;
}
.quote blockquote {
  margin: 0;
  font-size: var(--fs-xl);
  line-height: 1.35;
  color: var(--text);
  font-family: var(--font-display);
  font-weight: 400;
  letter-spacing: -0.01em;
}
.quote figcaption { margin-top: auto; font-size: var(--fs-sm); color: var(--text-muted); }
.quote figcaption strong { display: block; color: var(--text); font-weight: 600; }

.rating { display: flex; gap: 2px; color: var(--brand-strong); }
.rating .icon { width: 16px; height: 16px; }

/* --- Service area list --- */
.area-list {
  display: grid;
  gap: var(--space-2) var(--space-5);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));
}
.area-list a {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 48px;
  /* No negative inline margin. Pulling the hover background out to the text
     edge made every link 24px wider than its grid column, which overflowed
     the document at narrow widths. The padding alone is enough. */
  padding: var(--space-2);
  border-radius: var(--r-md);
  font-size: var(--fs-base);
  color: var(--text-subtle);
  text-decoration: none;
  transition: background-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.area-list a:hover { background: var(--ink-800); color: var(--text); }
.area-list .icon { width: 15px; height: 15px; color: var(--brand-strong); flex: none; }

/* --- Callout / CTA band --- */
.cta-band {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  border-radius: var(--r-xl);
  border: 1px solid var(--border-brand);
  background: linear-gradient(140deg, var(--olive-800), var(--ink-900) 62%);
  padding: clamp(2.25rem, 6vw, 4.5rem);
  text-align: center;
}
.cta-band::before {
  content: "";
  position: absolute;
  inset: -40% -10% auto -10%;
  height: 130%;
  z-index: -1;
  background: radial-gradient(ellipse at 50% 0%, color-mix(in srgb, var(--olive-400) 40%, transparent), transparent 62%);
  pointer-events: none;
}
.cta-band h2 { margin-bottom: var(--space-4); }
.cta-band p { color: var(--text-subtle); max-width: 54ch; margin-inline: auto; margin-bottom: var(--space-7); font-size: var(--fs-lg); }
.cta-band .cluster { justify-content: center; }

/* Gallery, About and Service Areas can each be someone's entry page, and none
   of them carried a price anywhere. Asking for a booking from a visitor who
   has never seen a number is asking them to go and find one. Stated once,
   quietly, immediately above the buttons — the confidence is in saying it
   plainly and then not mentioning it again.

   Set in body case, not the small-caps used elsewhere for eyebrows: at this
   length uppercase ran to two lines and shouted, which is the opposite of the
   intent. */
.cta-band__price {
  margin: calc(var(--space-7) * -0.5) auto var(--space-7);
  max-width: 52ch;
  font-size: var(--fs-sm);
  color: var(--text-subtle);
}
.cta-band__price strong { color: var(--accent); font-weight: 600; }

/* Proof at the moment of decision. The closing band used to be an empty panel
   asking to be trusted; one real inspection frame turns the ask into "look".
   Small on purpose — it is a witness, not a gallery. */
.cta-band__proof {
  max-width: min(320px, 74%);
  margin: 0 auto var(--space-6);
}
.cta-band__proof img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--r-md);
  border: 1px solid var(--border-strong);
  box-shadow: 0 18px 44px rgb(0 0 0 / 0.4);
}
.cta-band__proof figcaption {
  margin-top: var(--space-3);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-subtle);
}

/* --- Receipt --------------------------------------------------------------
   The $329 block's prose says what Ducts covers; this shows it, line by
   line, the way a bill would. dd carries the "cost", which is the point: the
   right-hand column is Included, Included, and $0 — with the one line that is
   not in this tier naming the tier it is in, rather than quietly going
   missing. */
.receipt {
  max-width: 46ch;
  margin-top: var(--space-6);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  background: color-mix(in srgb, var(--ink-900) 62%, transparent);
  padding: var(--space-4) var(--space-6);
}
.receipt > div {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-5);
  padding-block: var(--space-3);
  border-bottom: 1px solid var(--border);
}
.receipt > div:last-child { border-bottom: 0; }
.receipt dt { color: var(--text-subtle); font-size: var(--fs-sm); }
.receipt dd {
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: var(--fs-sm);
  color: var(--accent);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
/* What the package does NOT cover. Setting these in the same accent as the
   Included rows made all five values read as one block of good news, which is
   the opposite of what the list is for — the eye has to be able to split it
   without reading every word. */
.receipt dd.is-out { color: var(--text-muted); font-weight: 400; }

/* Names the list. Without it the rows read as a bare feature table and the
   word "Complete" in the right-hand column looks like a status, not the name
   of the other package — which is exactly how a first-time reader took it. */
.receipt__cap {
  max-width: 46ch;
  margin: var(--space-6) 0 var(--space-3);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-muted);
}
.receipt__cap strong { color: var(--accent); font-weight: 600; }
.receipt__cap + .receipt { margin-top: 0; }

/* Sits under the receipt and carries the one number the homepage was missing.
   Deliberately outside the bordered block: it is a second offer, not another
   line of the same bill, and putting it inside would have read as a charge. */
.receipt__note {
  max-width: 46ch;
  margin-top: var(--space-4);
  font-size: var(--fs-sm);
  color: var(--text-subtle);
}
.receipt__note strong { color: var(--accent); font-weight: 600; }

/* --- Quick facts ----------------------------------------------------------
   The four things this buyer wants settled before booking: how long, do I have
   to be there, will you wreck the place, how do I pay. They were answered in
   the FAQ at roughly screen 12 of 15 on a phone — long after the decision gets
   made. Here they sit against the price.

   Deliberately quieter than .receipt directly above it: no border box, no
   accent on the values. It is reassurance, and reassurance that shouts reads
   as defensiveness. */
/* Separated from the note above by a hairline rather than by more space
   alone — four rows of small type stacked straight under a paragraph read as
   one crowded block, and a rule tells the eye where the answers start without
   costing any vertical room. */
.quickfacts {
  margin: var(--space-6) 0 0;
  padding-top: var(--space-6);
  border-top: 1px solid var(--border);
  max-width: 46ch;
  display: grid;
  gap: var(--space-4);
}
.quickfacts > div {
  display: grid;
  grid-template-columns: 10.5rem 1fr;
  gap: var(--space-4);
  align-items: baseline;
}
.quickfacts dt {
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-muted);
}
.quickfacts dd {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-subtle);
  line-height: 1.5;
}
/* Two columns need ~26rem to avoid the label wrapping to two lines and the
   answer stranding beside it. Below that, stack. */
@media (max-width: 30rem) {
  .quickfacts > div { grid-template-columns: 1fr; gap: var(--space-1); }
}

/* --- Definition rows (contact details, hours) --- */
.detail-list { display: grid; gap: var(--space-5); }
.detail {
  display: flex;
  gap: var(--space-4);
  padding-bottom: var(--space-5);
  border-bottom: 1px solid var(--border);
}
.detail:last-child { border-bottom: 0; padding-bottom: 0; }
.detail__icon {
  width: 40px; height: 40px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--r-md);
  border: 1px solid var(--border-brand);
  background: color-mix(in srgb, var(--olive-500) 16%, transparent);
  color: var(--accent);
  flex: none;
}
.detail__icon .icon { width: 20px; height: 20px; }
.detail dt {
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-1);
}
.detail dd { margin: 0; font-size: var(--fs-md); color: var(--text); }
.detail dd a { color: var(--text); text-decoration: none; }
.detail dd a:hover { color: var(--accent); }

/* --- Badge / pill --- */
.badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--r-pill);
  border: 1px solid var(--border-brand);
  background: color-mix(in srgb, var(--olive-500) 14%, transparent);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--accent);
}
.badge .icon { width: 14px; height: 14px; }

/* --- Placeholder flag: anything awaiting real business content. --------- */
.needs-content {
  outline: 1px dashed color-mix(in srgb, var(--olive-300) 55%, transparent);
  outline-offset: 4px;
  border-radius: var(--r-sm);
}

/* --- Forms --- */

.form { display: grid; gap: var(--space-5); }

.field-row {
  display: grid;
  gap: var(--space-5);
  grid-template-columns: 1fr;
}
@media (min-width: 560px) {
  .field-row { grid-template-columns: 1fr 1fr; }
}

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

.field label {
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-subtle);
}
.field label span[aria-hidden] { color: var(--accent); }

.field input,
.field select,
.field textarea {
  /* 16px minimum stops iOS zooming the viewport on focus. */
  font-size: 16px;
  font-family: inherit;
  line-height: 1.5;
  width: 100%;
  min-height: 52px;
  padding: var(--space-3) var(--space-4);
  color: var(--text);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  transition: border-color var(--dur-mid) var(--ease-out),
              background-color var(--dur-mid) var(--ease-out),
              box-shadow var(--dur-mid) var(--ease-out);
  -webkit-appearance: none;
  appearance: none;
}

.field textarea { min-height: 132px; resize: vertical; }

.field select {
  /* Custom chevron; native arrow is invisible against the dark surface. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none' stroke='%238E8C7F' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 8 5 5 5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--space-4) center;
  padding-right: var(--space-8);
}

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

.field input:hover,
.field select:hover,
.field textarea:hover { border-color: var(--border-strong); }

.field input:focus,
.field select:focus,
.field textarea:focus {
  outline: none;
  border-color: var(--olive-400);
  background: var(--ink-850);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--olive-400) 26%, transparent);
}
.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible { outline: none; }

/* --- Detail rows that lead somewhere ---------------------------------------
   The icon reads as a button but was outside the anchor, so clicking the mail
   glyph or the Instagram glyph did nothing — only the text beside it worked.
   Any row whose <dd> holds a single link now has that link stretched over the
   whole row, so the icon, the label and the value are all one target.

   Done with :has() rather than a class on every row, since the row already
   tells us what it is. Where :has() is unsupported the row simply is not
   stretched and the link still works exactly as before — nothing to fall back
   to. Rows with no link (Hours, Service area copy) are untouched. */

.detail:has(dd > a:only-child) { position: relative; cursor: pointer; }
.detail:has(dd > a:only-child) dd > a::after { content: ""; position: absolute; inset: 0; }

/* The whole row lifts together, so it is obvious the icon is part of the
   target rather than decoration sitting next to one. */
.detail:has(dd > a:only-child) .detail__icon,
.detail:has(dd > a:only-child) dd > a { transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out); }
.detail:has(dd > a:only-child):hover .detail__icon,
.detail:has(dd > a:only-child):focus-within .detail__icon {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--olive-400) 46%, transparent);
}

/* The stretched ::after covers the row, so a focus ring on the anchor itself
   would be hidden behind it. Draw it on the row. */
.detail:has(dd > a:only-child) dd > a:focus-visible { outline: none; }
.detail:has(dd > a:only-child):focus-within {
  outline: 2px solid var(--accent);
  outline-offset: 6px;
  border-radius: var(--r-sm);
}


/* --- Validation ----------------------------------------------------------
   The browser's own validation bubble is a grey OS tooltip that ignores every
   token on this page and vanishes the moment focus moves. main.js turns the
   native popup off and drives these states instead, so the message sits in the
   layout, stays put, and is announced to screen readers.

   Nothing is flagged until the visitor has actually finished with a field —
   marking a form red before anyone has typed is hostile. */

.field { --field-bd: var(--border-strong); }

.field.is-invalid input,
.field.is-invalid select,
.field.is-invalid textarea {
  border-color: var(--danger);
  background: color-mix(in srgb, var(--danger) 7%, var(--ink-900));
}
.field.is-invalid input:focus,
.field.is-invalid select:focus,
.field.is-invalid textarea:focus {
  border-color: var(--danger);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 26%, transparent);
}
.field.is-invalid label { color: var(--danger-text); }

.field__error {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--fs-sm);
  line-height: 1.4;
  color: var(--danger-text);
}
.field__error .icon { width: 15px; height: 15px; flex: none; }

/* Slides in rather than appearing, so a field growing by a line does not make
   the rest of the form jump. */
@media (prefers-reduced-motion: no-preference) {
  .field__error { animation: field-error-in var(--dur-mid) var(--ease-out) both; }
}
@keyframes field-error-in {
  from { opacity: 0; transform: translateY(-3px); }
  to   { opacity: 1; transform: none; }
}

/* Summary above the submit button when a submit attempt fails, so the problem
   is stated once in words rather than only as red outlines further up. */
.form__summary {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-4) var(--space-5);
  border: 1px solid color-mix(in srgb, var(--danger) 46%, transparent);
  border-left-width: 3px;
  border-radius: var(--r-md);
  background: color-mix(in srgb, var(--danger) 10%, var(--ink-900));
  color: var(--text);
  font-size: var(--fs-sm);
  line-height: 1.5;
}
.form__summary .icon { width: 18px; height: 18px; flex: none; color: var(--danger-text); margin-top: 1px; }
.form__summary a { color: var(--danger-text); text-underline-offset: 3px; }

.field__help {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-muted);
  line-height: 1.5;
}

/* --- Before / after comparison ------------------------------------------
   Both frames are the same shot, same angle, same camera, and are graded with
   identical parameters offline — otherwise the improvement could be put down
   to colour correction rather than cleaning.

   Driven by a real <input type="range">, not a synthetic div. That gets
   keyboard support, arrow-key stepping, Home/End, touch, and screen-reader
   semantics from the platform instead of reimplementing them by hand. The
   input is stretched invisibly over the whole frame; the visible handle is
   drawn separately and follows the value. */

.compare {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--r-lg);
  border: 1px solid var(--border);
  background: var(--ink-900);
  isolation: isolate;
  touch-action: pan-y;               /* let the page still scroll vertically */
  --pos: 50%;
}

.compare__pane { position: absolute; inset: 0; }
.compare__pane img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: none;
}

/* The "before" sits on top and is revealed/hidden by the clip. */
.compare__pane--before { clip-path: inset(0 calc(100% - var(--pos)) 0 0); }

.compare__divider {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--pos);
  width: 2px;
  margin-left: -1px;
  background: var(--sand-100);
  box-shadow: 0 0 0 1px rgb(0 0 0 / 0.28), 0 0 22px rgb(0 0 0 / 0.4);
  pointer-events: none;
  z-index: 3;
}

.compare__handle {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  width: 52px;
  height: 52px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--sand-100);
  color: var(--ink-950);
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.45);
  transition: scale var(--dur-mid) var(--ease-out);
}
.compare__handle .icon { width: 22px; height: 22px; stroke-width: 2; }

.compare__range {
  position: absolute;
  inset: 0;
  z-index: 4;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: ew-resize;
  -webkit-appearance: none;
  appearance: none;
  background: transparent;
}
/* Give the native thumb the full height so dragging works anywhere vertically */
.compare__range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 56px;
  height: 100vh;
  cursor: ew-resize;
}
.compare__range::-moz-range-thumb {
  width: 56px;
  height: 100vh;
  border: 0;
  border-radius: 0;
  cursor: ew-resize;
}

.compare__range:focus-visible ~ .compare__divider .compare__handle,
.compare:has(.compare__range:focus-visible) .compare__handle {
  outline: 3px solid var(--ring);
  outline-offset: 3px;
}
.compare:has(.compare__range:active) .compare__handle { scale: 1.08; }

.compare__label {
  position: absolute;
  top: var(--space-4);
  z-index: 2;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--ink-950) 78%, transparent);
  border: 1px solid var(--border-strong);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text);
  pointer-events: none;
}
@supports (backdrop-filter: blur(1px)) {
  .compare__label { backdrop-filter: blur(8px); }
}
/* Opposite corners, not both along the top. The labels carry real wording
   ("Actual inspection" / "Reference") rather than Before/After, and two long
   pills on one edge collide as soon as the frame is narrower than their
   combined width. Diagonal placement cannot overlap at any size. */
.compare__label--before { top: var(--space-4); left: var(--space-4); }
.compare__label--after  { top: auto; bottom: var(--space-4); right: var(--space-4); }

/* A label has to leave with the image it names — and it should go as the
   divider crosses it, not only once the drag reaches the very end. A pill
   reading "Reference" sitting on top of the inspection frame is just wrong.

   main.js measures each label's own box and computes the value, because the
   fade spans exactly the label's width and that depends on its text and on the
   loaded display face. Falling back to 1 keeps both visible without script. */
.compare__label--before { opacity: var(--b-op, 1); }
.compare__label--after  { opacity: var(--a-op, 1); }

@media (max-width: 620px) {
  .compare__label {
    padding: var(--space-1) var(--space-3);
    font-size: 0.6875rem;
    letter-spacing: 0.05em;
    max-width: calc(100% - var(--space-8));
  }
}

.compare__caption {
  margin-top: var(--space-4);
  font-size: var(--fs-sm);
  color: var(--text-muted);
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.compare__caption .icon { width: 16px; height: 16px; color: var(--accent-warm); flex: none; }

/* The slider is the most convincing thing on either page it appears on, and
   it used to end the thought. One line, in body copy rather than a button,
   so the proof is what persuades and the link is only there to be taken. */
.compare__next {
  margin-top: var(--space-4);
  font-size: var(--fs-sm);
  color: var(--text-subtle);
  max-width: 46ch;
}

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


/* --- Oversized figure -----------------------------------------------------
   One moment per page where the eye lands hard. After the hero, nothing on
   the page was larger than an h2, so the rhythm went flat immediately. A
   single number at display scale re-establishes hierarchy without adding a
   section or a word. */

.figure-moment {
  display: grid;
  gap: clamp(1.25rem, 3vw, 2.5rem);
  /* Centred, not bottom-aligned. `end` was right while the right-hand column
     was a single paragraph and the two blocks were a similar height. Once the
     receipt went in, that column grew to 460px against the number's 157px and
     bottom-aligning dropped the number 135px below the column's centre, which
     read as a mistake because it was one. */
  align-items: center;
}
/* Two columns only once the number and a readable measure both fit. Sizing
   the first track to the figure itself (rather than a fraction) keeps the
   digits from running under the paragraph at in-between widths. */
@media (min-width: 1000px) {
  .figure-moment { grid-template-columns: max-content minmax(22ch, 1fr); }
}

.figure-moment__value {
  display: block;
  font-family: var(--font-display);
  font-weight: 700;
  font-variation-settings: 'wdth' 118;
  font-size: clamp(5rem, 17vw, 10rem);
  line-height: 0.82;
  letter-spacing: -0.05em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.figure-moment__value sup {
  font-size: 0.32em;
  vertical-align: top;
  top: 0.42em;
  position: relative;
  margin-right: 0.18em;
  color: var(--accent-warm);
  letter-spacing: 0;
}
/* At display size a full-height currency symbol competes with the digits it
   is qualifying. Set smaller and raised, the way a price is set in print. */
.figure-moment__value .cur {
  font-size: 0.46em;
  vertical-align: baseline;
  position: relative;
  top: -0.62em;
  margin-right: 0.02em;
  letter-spacing: -0.02em;
}

/* Trailing unit (%, sq ft). Unlike .cur this stays on the baseline — a raised
   percent sign reads as a footnote marker. */
.figure-moment__value .unit {
  font-size: 0.54em;
  letter-spacing: -0.02em;
  margin-left: 0.02em;
  color: var(--accent-warm);
}

.figure-moment__label {
  display: block;
  white-space: normal;
  margin-top: var(--space-4);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--accent-warm);
}
.figure-moment__body { max-width: 46ch; }
.figure-moment__body p { color: var(--text-subtle); }


/* --- Off-axis section heads -----------------------------------------------
   Centred heads everywhere read as generated. This puts the eyebrow in a
   narrow left rail with the heading beside it, so a few sections sit off the
   centre line. Asymmetry is most of what separates art-directed from
   auto-laid-out. */

@media (min-width: 900px) {
  .section-head--rail {
    display: grid;
    grid-template-columns: 12ch minmax(0, 1fr);
    gap: var(--space-6);
    align-items: start;
    max-width: none;
  }
  .section-head--rail .eyebrow {
    margin: 0;
    padding-top: 0.7em;
    line-height: 1.4;
  }
  .section-head--rail > :not(.eyebrow) { grid-column: 2; }

  /* Without an eyebrow there is nothing to put in the rail, so the grid just
     indents the heading by 12ch for no reason and reads as a mistake. Guard it
     rather than relying on every author remembering the eyebrow. */
  .section-head--rail:not(:has(.eyebrow)) {
    display: block;
    max-width: 68ch;
  }
}


/* Footnote under the gallery grid. Was a bare <p> picking up the generic
   68ch prose cap, which left it on a different left edge from the grid it
   annotates once the section head was also indented. Own class, own measure,
   flush with the grid above it. */
.gallery__note {
  max-width: 78ch;
  margin-top: var(--space-8);
  font-size: var(--fs-sm);
  color: var(--text-muted);
}

/* --- Area index -----------------------------------------------------------
   Eleven bordered boxes with a pin icon in each is the single most generic
   thing a service site can do. This is the same information set as an
   editorial index instead: city name at display size, neighbourhoods as
   quiet secondary text, hairline rules doing the separating.

   Fewer containers, more air, bigger type — the elevation comes from removing
   the boxes, not from adding anything. */

.area-index {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--border);
  counter-reset: area;
}

.area-index li {
  counter-increment: area;
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: baseline;
  gap: var(--space-3) var(--space-5);
  padding: clamp(1.1rem, 2.2vw, 1.75rem) 0;
  border-bottom: 1px solid var(--border);
}
@media (min-width: 860px) {
  .area-index li {
    grid-template-columns: 4ch minmax(0, 8fr) minmax(0, 11fr);
    align-items: baseline;
  }
}

/* Index numerals — small, tabular, deliberately understated. */
.area-index li::before {
  content: counter(area, decimal-leading-zero);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  font-variant-numeric: tabular-nums;
  color: var(--accent-warm);
  opacity: 0.75;
}

.area-index__name {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3.2vw, 2.25rem);
  font-weight: 400;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--text);
  transition: color var(--dur-mid) var(--ease-out);
}

.area-index__detail {
  font-size: var(--fs-base);
  line-height: 1.5;
  color: var(--text-muted);
  grid-column: 2 / -1;
  transition: color var(--dur-mid) var(--ease-out);
}
@media (min-width: 860px) {
  .area-index__detail { grid-column: 3; }
}

/* Whole row responds, so the eye tracks across the pairing. */
.area-index li:hover .area-index__name { color: var(--accent); }
.area-index li:hover .area-index__detail { color: var(--text-subtle); }


/* --- Full-bleed image band --- */

.bleed {
  position: relative;
  margin: 0;
  width: 100%;
  overflow: hidden;
  isolation: isolate;
}
.bleed img {
  width: 100%;
  height: clamp(260px, 46vw, 560px);
  object-fit: cover;
  object-position: center;
  display: block;
}

/* Caption sits on the image, over a gradient sized to the text rather than a
   full overlay — the point of a full-bleed shot is to see the shot. */
.bleed__caption {
  position: absolute;
  inset: auto 0 0 0;
  padding: clamp(2rem, 8vw, 4rem) var(--gutter) clamp(1.25rem, 3vw, 2rem);
  background: linear-gradient(to top, rgb(11 11 9 / 0.92) 0%, rgb(11 11 9 / 0.6) 45%, transparent 100%);
  color: var(--text);
  font-family: var(--font-display);
  font-size: var(--fs-xl);
  font-weight: 400;
  letter-spacing: -0.01em;
  line-height: 1.25;
}
.bleed__caption span {
  display: block;
  max-width: var(--container);
  margin-inline: auto;
  text-wrap: balance;
}

/* --- Pricing table --- */

.pricing {
  border: 1px solid var(--border-brand);
  border-radius: var(--r-lg);
  background: var(--surface-card);
  overflow: hidden;
}

.price-table {
  width: 100%;
  border-collapse: collapse;
}

/* --- Rows book -----------------------------------------------------------
   Finding your square footage is the highest-intent moment on the page, and
   until now it dead-ended: the visitor read a number, then had to hunt for a
   button. Each row is now a link straight into the booking flow.

   One real <a> per row rather than six buttons — six more "Book" buttons in a
   table would be exactly the repetition that got the CTAs cut from eight to
   four earlier. The anchor lives in the price cell and its ::after stretches
   over the whole row, so the entire line is the target while a screen reader
   still hears one link with a full description of which band it books.

   `position: relative` on a <tr> is honoured in every current engine, which
   is what makes the stretch possible without abandoning table semantics. */

.price-table tbody tr { position: relative; transition: background-color var(--dur-mid) var(--ease-out); }

.price-table__link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--text);
  text-decoration: none;
  font-weight: 600;
}
.price-table__link::after { content: ""; position: absolute; inset: 0; }

.price-table__link .icon {
  width: 15px; height: 15px;
  color: var(--accent);
  opacity: 0;
  transform: translateX(-4px);
  transition: opacity var(--dur-mid) var(--ease-out), transform var(--dur-mid) var(--ease-out);
}

/* The same link used outside the table — the standalone dryer vent price —
   has no sibling rows to establish that the block is interactive, so its arrow
   is shown at rest instead of waiting for hover. It still moves and brightens
   on hover, so the affordance is a hint rather than a static decoration. */
.detail .price-table__link .icon { opacity: 0.55; transform: none; }
.detail:hover .price-table__link,
.detail:focus-within .price-table__link { color: var(--accent); }
.detail:hover .price-table__link .icon,
.detail:focus-within .price-table__link .icon { opacity: 1; transform: translateX(3px); }

.price-table tbody tr:hover,
.price-table tbody tr:focus-within { background: color-mix(in srgb, var(--olive-500) 12%, transparent); }
.price-table tbody tr:hover .price-table__link,
.price-table tbody tr:focus-within .price-table__link { color: var(--accent); }
.price-table tbody tr:hover .icon,
.price-table tbody tr:focus-within .icon { opacity: 1; transform: none; }

/* The stretched ::after swallows the row, so the ring has to be drawn on the
   row itself or keyboard focus would be invisible. */
.price-table__link:focus-visible { outline: none; }
.price-table tbody tr:focus-within {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.price-table thead th {
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-muted);
  text-align: left;
  padding: var(--space-4) var(--space-5);
  border-bottom: 1px solid var(--border);
  background: var(--surface-sunken);
}
/* The price cell's link is an inline-flex holding the figure and an arrow, and
   the arrow keeps its 15px + gap even at opacity 0 so nothing shifts on hover.
   That gutter pushes the figure inboard of the cell edge, while the column
   heading was still anchored flush right — the two read as misaligned by about
   23px down the whole table. The heading now clears the same gutter. */
.price-table { --price-arrow: calc(15px + var(--space-2)); }
.price-table thead th:last-child {
  text-align: right;
  padding-right: calc(var(--space-5) + var(--price-arrow));
}

.price-table tbody th {
  font-weight: 400;
  text-align: left;
  color: var(--text-subtle);
  padding: var(--space-4) var(--space-5);
  border-bottom: 1px solid var(--border);
}

/* Tabular figures on the price column only, so the dollar amounts align in a
   clean rule. Applying it to the whole table widens the commas in the size
   labels ("1 , 501"), which looks like a typo. */
.price-table tbody td {
  text-align: right;
  padding: var(--space-4) var(--space-5);
  border-bottom: 1px solid var(--border);
  font-family: var(--font-ui);
  font-size: var(--fs-lg);
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.price-table tbody tr:last-child th,
.price-table tbody tr:last-child td { border-bottom: 0; }

/* The over-4,000 row. Set in the same weight as a price but in the warm accent
   rather than white, so the column still scans as a price column while the eye
   registers that this one is a conversation instead of a number. */
.price-call {
  color: var(--accent-warm);
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  font-size: var(--fs-sm);
}

.price-table tbody tr { transition: background-color var(--dur-fast) var(--ease-out); }
.price-table tbody tr:hover { background: var(--ink-700); }

.price-note {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  margin: 0;
  padding: var(--space-5);
  background: color-mix(in srgb, var(--olive-500) 16%, transparent);
  border-top: 1px solid var(--border-brand);
  font-size: var(--fs-base);
  color: var(--text-subtle);
  line-height: 1.55;
}
.price-note .icon { width: 20px; height: 20px; color: var(--accent-warm); flex: none; margin-top: 2px; }
.price-note strong { color: var(--text); }

/* Narrow phones: stack the label above the figure so nothing wraps badly. */
@media (max-width: 420px) {
  .price-table thead { display: none; }
  .price-table tbody tr {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: var(--space-4);
    border-bottom: 1px solid var(--border);
  }
  .price-table tbody tr:last-child { border-bottom: 0; }
  .price-table tbody th,
  .price-table tbody td { border-bottom: 0; padding-inline: var(--space-4); }
}

/* --- Two tiers -----------------------------------------------------------
   Ducts and Complete, side by side, each carrying its own full
   square-footage table. Twelve prices on one screen is a lot — the size
   picker above them exists to cut that down to two.

   Both tables stay in the DOM at every breakpoint. "The price is public" is
   one of the four commitments on the About page, so nothing here is allowed
   to hide behind a control; the picker highlights, it does not filter. It
   also keeps all twelve figures crawlable.

   auto-fit rather than a fixed 1fr 1fr: at 340px per column the pair drops to
   a single column on its own, which is the right result on a phone — Complete
   lands second, directly under the tier it is an upgrade from. */

/* One explicit breakpoint rather than auto-fit, because the alignment rule
   below has to fire at exactly the width where the second column appears — and
   auto-fit's flip point is a function of container width and gap that no media
   query can name. 900px is the same place .split goes two-column, so the page
   changes shape once instead of twice. Below it the cards stack, Complete
   directly under the tier it is an upgrade from. */
.tiers {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-6);
  align-items: start;
}


.tier {
  position: relative;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  background: var(--surface-card);
  overflow: hidden;
}

/* The recommendation is carried by weight, not by scale. A transform here
   would knock the two tables out of horizontal alignment, and aligned rows are
   the whole reason the cards sit side by side.

   Three quiet signals instead: the surface lifts a step, the border goes to
   the brand olive at full strength, and the brand shadow pools underneath.
   Read together they make Complete the obvious answer without a colour shout
   or a "SAVE 20%" flash — the loud version of this would undo the positioning
   the rest of the site is built on. */
.tier--featured {
  border-color: var(--olive-400);
  background: var(--surface-raised);
  box-shadow: var(--shadow-brand);
}
.tier--featured .tier__head,
.tier--featured .tier__list { border-bottom-color: var(--border-brand); }
.tier--featured .price-table thead th {
  background: color-mix(in srgb, var(--olive-500) 12%, var(--surface-sunken));
  color: var(--accent);
}
.tier--featured .price-table tbody th,
.tier--featured .price-table tbody td { border-bottom-color: var(--border-brand); }
.tier--featured .tier__from { color: #fff; }

/* The flag sits on the same line as the tier name rather than in a banner
   above the card. As a banner it added its own height to one card only, which
   pushed every row below it out of line with the card alongside — and reading
   the two tables against each other is the entire job of this layout. */
.tier__topline {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
  min-height: 30px;
}

.tier__flag {
  margin: 0;
  padding: 4px var(--space-3);
  border-radius: var(--r-pill);
  border: 1px solid var(--border-brand);
  background: color-mix(in srgb, var(--olive-500) 22%, transparent);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--accent);
  white-space: nowrap;
}

.tier__head {
  padding: var(--space-6) var(--space-5) var(--space-5);
  border-bottom: 1px solid var(--border);
}

.tier__name {
  margin: 0;
  font-size: var(--fs-lg);
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  font-family: var(--font-ui);
  color: var(--text-muted);
}

/* Same construction as .figure-moment__value at about a third the size: the
   "from" and the dollar sign are set small and raised so the digits carry the
   line, the way a price is set in print. */
.tier__from {
  display: block;
  margin: var(--space-3) 0 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-variation-settings: 'wdth' 118;
  font-size: clamp(2.75rem, 7vw, 3.75rem);
  line-height: 0.9;
  letter-spacing: -0.04em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.tier__from sup {
  font-size: 0.3em;
  vertical-align: top;
  position: relative;
  top: 0.5em;
  margin-right: 0.2em;
  color: var(--accent-warm);
  letter-spacing: 0;
  font-weight: 600;
}
.tier__from .cur {
  font-size: 0.46em;
  position: relative;
  top: -0.58em;
  margin-right: 0.02em;
  letter-spacing: -0.02em;
}

.tier__blurb {
  margin: var(--space-4) 0 0;
  color: var(--text-muted);
  font-size: var(--fs-base);
  line-height: 1.55;
}

.tier__list {
  list-style: none;
  margin: 0;
  padding: var(--space-5);
  display: grid;
  gap: var(--space-4);
  border-bottom: 1px solid var(--border);
}
.tier__list li {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  font-size: var(--fs-base);
  color: var(--text-subtle);
  line-height: 1.5;
}
.tier__list .icon {
  width: 18px; height: 18px;
  flex: none;
  margin-top: 2px;
  color: var(--accent);
}
.tier__list strong { color: var(--text); font-weight: 600; }
.tier__list a { color: var(--accent); }

/* The table sits flush inside the card, so it drops the outer chrome that
   .pricing would otherwise have given it. */
.tier__table { margin-top: auto; }
.tier__table thead th { background: var(--surface-sunken); }

@media (min-width: 900px) {
  .tiers { grid-template-columns: 1fr 1fr; }

  /* Side by side, the two cards have to agree on where each band starts, or
     the rows they exist to be compared against sit at different heights.
     Complete carries more feature copy than Ducts and its bullets wrap
     further, so without this the tables drift apart by about a row.

     Subgrid pins head / features / table to shared tracks, so the two price
     tables start on the same line however the copy reflows. Where subgrid is
     unsupported the cards just size themselves — the same thing they already
     do stacked on a phone, so nothing breaks. */
  @supports (grid-template-rows: subgrid) {
    .tiers {
      grid-template-rows: auto auto auto;
      align-items: stretch;
      /* A subgrid inherits its parent's gaps, so the 32px meant to separate the
         two cards was also being inserted between head, features and table
         inside each one — leaving the bordered sections floating clear of the
         blocks they belong to. Horizontal only. */
      column-gap: var(--space-6);
      row-gap: 0;
    }
    /* Scoped to direct children so it can never catch .tier--solo, which is a
       sibling of .tiers rather than a card inside it. */
    .tiers > .tier {
      display: grid;
      grid-template-rows: subgrid;
      grid-row: span 3;
    }
    /* The flex fallback uses margin-top:auto to push the table to the bottom of
       an equal-height card. In the subgrid branch the rows do that job, and
       leaving the auto margin in place re-opens the same dead band under the
       shorter feature list that subgrid is here to close. */
    .tiers > .tier .tier__table { margin-top: 0; }
    .tiers > .tier .tier__list { align-content: start; }
  }
}

/* --- Size picker ---------------------------------------------------------
   Hidden until main.js unhides it: without JS these buttons would do nothing,
   and a row of dead controls above a price list is worse than no row at all.
   The tables are complete on their own, so nothing is lost. */

.size-pick[hidden] { display: none; }

.size-pick {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-3) var(--space-4);
  margin-bottom: var(--space-6);
}
.size-pick__label {
  margin: 0;
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-muted);
}
.size-pick__row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  justify-content: center;
}
.size-pick__btn {
  appearance: none;
  cursor: pointer;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--r-pill);
  border: 1px solid var(--border);
  background: transparent;
  font-family: var(--font-ui);
  font-size: var(--fs-sm);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--text-subtle);
  transition: color var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out),
              background-color var(--dur-fast) var(--ease-out);
}
.size-pick__btn:hover {
  color: var(--text);
  border-color: var(--border-strong);
}
.size-pick__btn[aria-pressed="true"] {
  color: var(--ink-950);
  background: var(--accent);
  border-color: var(--accent);
}
.size-pick__btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

/* The match. Deliberately not a background flood — the rows already use
   background on hover, and two background states on one row read as a bug.
   A left rule plus a brighter figure is unambiguous and survives hover. */
.price-table tbody tr.is-match {
  background: color-mix(in srgb, var(--olive-500) 14%, transparent);
  box-shadow: inset 3px 0 0 var(--accent);
}
.price-table tbody tr.is-match th { color: var(--text); }
.price-table tbody tr.is-match .price-table__link { color: var(--accent); }
.price-table tbody tr.is-match .price-table__link .icon { opacity: 1; transform: none; }

/* The bundle-arithmetic note under the two cards. Same construction as
   .price-note but it is a standalone block rather than the foot of a bordered
   panel, so it carries its own border on all four sides. */
/* Sets the frame before the price is read. Without it, a third priced card
   stacked under two package cards reads as "and also this" — vertical
   sequence implies a list, and a customer who has just chosen a package can
   take the next number as something else they owe. */
.solo-lead {
  margin-top: var(--space-9);
  max-width: 60ch;
  display: grid;
  gap: var(--space-3);
}
.solo-lead h3 { font-size: var(--fs-xl); }
.solo-lead p { color: var(--text-subtle); font-size: var(--fs-base); }
.solo-lead + .tier--solo { margin-top: var(--space-5); }

/* --- Dryer vent, sold on its own ------------------------------------------
   Same card chrome and the same price type as the two packages, because it is
   a service people buy without ever considering duct cleaning — not a line on
   somebody else's invoice. Full width and two columns: the offer on the left,
   the reason on the right. */
.tier--solo { display: block; }
.tier--solo .tier__topline { margin-bottom: var(--space-3); }
.tier--solo .tier__from { margin-top: 0; }

.tier__solo-buy,
.tier__solo-case { padding: var(--space-6) var(--space-5); }

.tier__solo-case {
  border-top: 1px solid var(--border);
  background: var(--surface-sunken);
}
.tier__solo-case .tier__list {
  padding: 0;
  margin-top: var(--space-5);
  border-bottom: 0;
}

@media (min-width: 900px) {
  .tier--solo {
    display: grid;
    grid-template-columns: 5fr 7fr;
    align-items: start;
  }
  .tier__solo-buy { padding: var(--space-7) var(--space-6); }
  .tier__solo-case {
    border-top: 0;
    border-left: 1px solid var(--border);
    padding: var(--space-7) var(--space-6);
    align-self: stretch;
  }
}

/* The fire figure, restated at the point of decision. Set at roughly half the
   scale of .figure-moment — enough to stop the eye, not enough to compete with
   the two prices above it. */
.solo-stat {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-variation-settings: 'wdth' 118;
  font-size: clamp(3rem, 8vw, 4.25rem);
  line-height: 0.9;
  letter-spacing: -0.04em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.solo-stat .unit { font-size: 0.54em; color: var(--accent-warm); margin-left: 0.02em; }
.solo-stat__note {
  margin: var(--space-3) 0 0;
  max-width: 46ch;
  color: var(--text-subtle);
  font-size: var(--fs-base);
  line-height: 1.55;
}
.solo-stat__note a { color: var(--accent); }

/* Warning-flavoured variant of the tier flag, for the one service on the page
   with a hazard behind it rather than a benefit. */
.tier__flag--alert {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  border-color: color-mix(in srgb, var(--dune-400) 45%, transparent);
  background: color-mix(in srgb, var(--dune-400) 14%, transparent);
  color: var(--accent-warm);
}
.tier__flag--alert .icon { width: 13px; height: 13px; }

/* It is a direct child <p> of .container, so the prose measure cap
   (.container > p, specificity 0-1-1) would otherwise clip it to 68ch and
   leave it hanging under the left card. It summarises both cards, so it has to
   span both — hence the child selector here, which outranks the cap. */
.container > .price-note--free {
  max-width: none;
  margin-top: var(--space-6);
  border: 1px solid var(--border-brand);
  border-radius: var(--r-lg);
}

@media (prefers-reduced-motion: reduce) {
  .size-pick__btn { transition: none; }
}

/* --- Booking embed shell --- */
.booking-shell {
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  background: var(--surface-card);
  padding: clamp(1.5rem, 4vw, var(--space-8));
  min-height: 320px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: var(--space-5);
  text-align: center;
}
.booking-shell p { color: var(--text-muted); max-width: 46ch; }


/* 10. PHOTO ART DIRECTION ================================================= */
/*
   The inspection stills are 1280x720 borescope frames: low light, washed
   contrast, on-screen timestamps. Rather than upscale them — which reads as
   mush — they are treated as evidence: duotoned into the brand, given grain
   and a vignette, framed, and captioned with the depth reading. That makes the
   camera-feed quality look deliberate instead of accidental.
*/

.frame {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  border-radius: var(--r-lg);
  border: 1px solid var(--border);
  background: var(--ink-900);
}

.duo { position: relative; isolation: isolate; overflow: hidden; }

.duo img {
  width: 100%;
  display: block;
  /* FULL COLOUR, deliberately. Dust, rust staining and debris read far more
     clearly in colour than in any duotone — and legibility of the dirt is the
     entire commercial point of these images. The JPEGs are already contrast-
     corrected and sharpened offline, so almost nothing is added here. */
  filter: saturate(1.06) contrast(1.02);
  transform: scale(1.02);
  transition: transform 900ms var(--ease-out), filter var(--dur-slow) var(--ease-out);
}

/* No colour overlay. A faint vignette only, to seat the frame against the
   dark page without touching the image content. */
.duo::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  background-image:
    radial-gradient(ellipse 94% 92% at 50% 48%, transparent 68%, rgb(0 0 0 / 0.3) 100%);
}

.frame:hover .duo img { transform: scale(1.055); }

/* Depth readout, echoing the camera overlay in the source footage.
   Anchored top-right: the camera burns its own timestamp into the top-left,
   and anchoring to the bottom would collide with the caption below. */
.frame__meta {
  position: absolute;
  z-index: 4;
  right: var(--space-4);
  top: var(--space-4);
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--ink-950) 82%, transparent);
  border: 1px solid var(--border-strong);
  font-family: var(--font-ui);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-mid);
  text-transform: uppercase;
  color: var(--text-subtle);
  font-variant-numeric: tabular-nums;
}
@supports (backdrop-filter: blur(1px)) {
  .frame__meta { backdrop-filter: blur(8px); }
}
.frame__meta .dot {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--accent-warm);
  flex: none;
}

.frame figcaption {
  padding: var(--space-4) var(--space-5);
  font-size: var(--fs-sm);
  color: var(--text-muted);
  border-top: 1px solid var(--border);
  background: var(--surface-card);
}

/* Gallery: deliberately varied so 1280x720 frames never tile like stock. */
.gallery {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}
@media (min-width: 900px) {
  /* Two over three, for exactly five frames.

     The previous rule set was written for seven and mixed span-4 with span-2
     in the same row. Because the frames stretch to the row height but the
     images did not, the two narrow tiles ended up as 161px images sitting in
     430px frames — 166px of dead black under each — and the fifth frame was
     left as a half-width orphan on a row of its own.

     Every tile in a row now has the same span, so every image in that row
     resolves to the same height and there is nothing left to fill. */
  .gallery--feature { grid-template-columns: repeat(6, 1fr); }
  .gallery--feature > *:nth-child(1),
  .gallery--feature > *:nth-child(2) { grid-column: span 3; }
  .gallery--feature > *:nth-child(3),
  .gallery--feature > *:nth-child(4),
  .gallery--feature > *:nth-child(5) { grid-column: span 2; }
}

/* Gallery tiles drop the outer card. A border around picture *and* caption
   made each one read as a product tile; the picture carries the frame and the
   caption sits under it as plate text, which is how a gallery is set.
   Scoped to .gallery so the framed figures on other pages are untouched. */
.gallery .frame {
  display: flex;
  flex-direction: column;
  border: 0;
  border-radius: 0;
  background: none;
  overflow: visible;
}
/* Pictures are locked to the source 16:9, so tiles of equal span resolve to
   exactly equal picture heights and their bottom edges line up across a row.
   Letting the picture flex instead would hand it whatever the caption did not
   use, and a two-line caption beside a one-line caption would knock the two
   images out of alignment by ~23px. Captions run to their natural depth below;
   with the card border gone there is no box left to look ragged. */
.gallery { align-items: start; }
.gallery .frame .duo {
  flex: none;
  aspect-ratio: 16 / 9;
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
  background: var(--ink-900);
}
.gallery .frame .duo img { height: 100%; object-fit: cover; }

.gallery .frame figcaption {
  padding: var(--space-4) 0 0;
  border-top: 0;
  background: none;
  font-size: var(--fs-sm);
  color: var(--text-muted);
  max-width: 46ch;
}

/* Reserve the 16:9 box so nothing shifts while the frame decodes. */
.frame .duo { aspect-ratio: 16 / 9; }
.frame .duo img { height: 100%; object-fit: cover; }

/* Vector duct schematic sits beside the footage, giving the eye something
   genuinely sharp to land on. */
.schematic { width: 100%; height: auto; overflow: visible; }
.schematic .duct-wall { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
.schematic .duct-fill { fill: color-mix(in srgb, var(--olive-500) 12%, transparent); }
.schematic .duct-label {
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  fill: var(--text-muted);
}
.schematic .flow {
  fill: none;
  stroke: var(--accent);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 6 12;
}
.schematic .flow--animated { animation: flow 2.4s linear infinite; }
@keyframes flow { to { stroke-dashoffset: -36; } }

.schematic .node { fill: var(--brand-strong); }


/* 11. FOOTER ============================================================== */

.footer {
  background: var(--surface-sunken);
  border-top: 1px solid var(--border);
  padding-top: var(--space-10);
  padding-bottom: var(--space-7);
  font-size: var(--fs-base);
}

.footer__grid {
  display: grid;
  gap: var(--space-8) var(--space-6);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
  padding-bottom: var(--space-8);
  border-bottom: 1px solid var(--border);
}
@media (min-width: 900px) {
  .footer__grid { grid-template-columns: 1.6fr 1fr 1fr 1.2fr; }
}

.footer__brand img { height: 34px; width: auto; margin-bottom: var(--space-5); }
.footer__brand p { color: var(--text-muted); font-size: var(--fs-sm); max-width: 34ch; }

.footer h2 {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-4);
}

/* Every footer link clears 44px vertically — this is the most thumb-hostile
   region of a phone layout, and short labels make easy targets easy to miss. */
.footer__links a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--text-subtle);
  text-decoration: none;
  font-size: var(--fs-sm);
  transition: color var(--dur-fast) var(--ease-out);
}
.footer__links a:hover { color: var(--accent); }

.footer .detail dd a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

.footer__bottom {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-6);
  align-items: center;
  justify-content: space-between;
  padding-top: var(--space-4);
  font-size: var(--fs-sm);
  color: var(--text-muted);
}
.footer__bottom nav { display: flex; flex-wrap: wrap; gap: var(--space-4); }
.footer__bottom a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding-inline: var(--space-2);
  border-radius: var(--r-sm);
  color: var(--text-muted);
  text-decoration: none;
}
.footer__bottom a:hover { color: var(--text); }


/* 12. MOTION ============================================================== */

/* Scroll reveal. transform + opacity only — never layout properties.
   The hidden start state is scoped to .js, which a tiny inline script in
   <head> sets synchronously. If scripting is off or main.js fails to load,
   the selector never matches and every element renders normally — the page
   is never left invisible waiting for an observer that will not run. */
.js [data-reveal] {
  opacity: 0;
  transform: translate3d(0, 22px, 0);
  transition:
    opacity var(--dur-reveal) var(--ease-out),
    transform var(--dur-reveal) var(--ease-out);
  transition-delay: var(--reveal-delay, 0ms);
  will-change: opacity, transform;
}
.js [data-reveal="left"]  { transform: translate3d(-26px, 0, 0); }
.js [data-reveal="right"] { transform: translate3d(26px, 0, 0); }
.js [data-reveal="scale"] { transform: scale(0.965); }

.js [data-reveal].is-visible {
  opacity: 1;
  transform: none;
  will-change: auto;
}

/* Hero lines wipe up from a clipped box.

   The clipping box is padded at the bottom and pulled back by an equal
   negative margin. Without that, overflow:hidden cuts the box off at the
   baseline-ish edge of the line box and slices the descenders off letters
   like g, y and p — most visible on a display serif. The padding moves the
   clip edge down; the negative margin keeps the layout unchanged.

   The translate must then clear the taller box, so it is 125% rather than
   105% — otherwise the top of the text peeks in below the mask before the
   animation starts. */
.reveal-line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.16em;
  margin-bottom: -0.16em;
}
.js .reveal-line > span {
  display: block;
  transform: translate3d(0, 125%, 0);
  transition: transform 900ms var(--ease-out);
  transition-delay: var(--reveal-delay, 0ms);
}
.js.is-ready .reveal-line > span { transform: none; }


/* --- Candlelight ---------------------------------------------------------
   A soft warm halo that trails the pointer everywhere, not a ring that only
   appears over images. No hard edge anywhere in it: it is three stacked
   radial stops fading to nothing, blended with screen so it only ever adds
   light to the dark page and can never darken or wash anything out.

   It sits below the grain layer, so the grain still reads across it. */

.cursor-glow {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1;
  width: 300px;
  height: 300px;
  border-radius: 50%;
  pointer-events: none;
  mix-blend-mode: screen;
  opacity: 0;
  transition: opacity 700ms var(--ease-out);
  will-change: transform;
  /* Deliberately faint — you should notice the room feels slightly warmer
     where the cursor is, not see a light following it. */
  background:
    radial-gradient(circle closest-side,
      color-mix(in srgb, var(--dune-300) 9%, transparent) 0%,
      color-mix(in srgb, var(--dune-400) 5%, transparent) 26%,
      color-mix(in srgb, var(--olive-400) 2.5%, transparent) 52%,
      transparent 74%);
}
.cursor-glow.is-on { opacity: 1; }

/* A touch more over imagery, where a warm lift flatters the shot. */
.cursor-glow.is-over-media { opacity: 1.3; }


/* --- Pinned section labels -----------------------------------------------
   The eyebrow holds at the top of the viewport while its section scrolls past,
   so there is always a quiet marker of where you are. Opt-in per section, and
   only above the mobile breakpoint — on a phone the sticky header plus a
   pinned label would eat too much of a short viewport. */

@media (min-width: 900px) {
  .section--pinned .section-head { position: relative; }
  .section--pinned .eyebrow {
    position: sticky;
    top: calc(var(--header-h) + var(--space-6));
    z-index: 2;
  }
}


/* --- Cross-document view transitions -------------------------------------
   Native in supporting browsers; a no-op everywhere else. Pages crossfade
   instead of flashing white, which is the single clearest "this is one
   application, not a stack of documents" signal. */

@view-transition { navigation: auto; }

::view-transition-old(root) {
  animation: vt-out 180ms var(--ease-in) both;
}
::view-transition-new(root) {
  animation: vt-in 320ms var(--ease-out) both;
}
@keyframes vt-out { to   { opacity: 0; } }
@keyframes vt-in  { from { opacity: 0; transform: translate3d(0, 8px, 0); } }

/* The header and the sticky bar persist across pages, so they are excluded
   from the crossfade — otherwise they visibly blink on every navigation. */
.header { view-transition-name: site-header; }
.actionbar { view-transition-name: site-actionbar; }

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


/* --- Housecall Pro chat bubble -------------------------------------------
   The vendor pins its container to bottom:0 right:0 at z-index 1200. On a
   phone that lands squarely on top of the sticky Book Online button, so it is
   lifted clear of the action bar. Marked !important because the widget sets
   these inline. */

@media (max-width: 900px) {
  #proChatIframeContainer {
    bottom: calc(var(--actionbar-h) + env(safe-area-inset-bottom, 0px) + 4px) !important;
  }
}

/* Keep it out of the way of an open mobile drawer and the booking modal. */
.is-locked #proChatIframeContainer { opacity: 0; pointer-events: none; }
#proChatIframeContainer { transition: opacity var(--dur-mid) var(--ease-out); }


/* 13. UTILITIES & PRINT =================================================== */

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

.text-center { text-align: center; }
.mt-0 { margin-top: 0; }
.mt-6 { margin-top: var(--space-6); }
.mt-8 { margin-top: var(--space-8); }
.mb-0 { margin-bottom: 0; }

.icon { display: inline-block; vertical-align: middle; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.icon--solid { fill: currentColor; stroke: none; }


/* Reduced motion: strip decorative movement, keep everything legible.
   Content that relies on reveal must still be visible. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }

  [data-reveal] { opacity: 1 !important; transform: none !important; }
  .reveal-line > span { transform: none !important; }
  .hero__glow { animation: none !important; }
  .schematic .flow--animated { animation: none !important; }
  .btn:hover, .card--link:hover { transform: none !important; }
  .duo img { transform: none !important; }
  .frame:hover .duo img { transform: none !important; }
}

@media print {
  .header, .drawer, .actionbar, .hero__glow, .cta-band { display: none !important; }
  body { background: #fff; color: #000; padding: 0; }
  a { color: #000; }
  .section { padding-block: 1rem; }
}

/* Sits directly under the two prices. Same construction as .price-note but
   full width and quieter — it is an easing of the decision, not a feature. */
.pay-note {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  max-width: none;
  margin: var(--space-6) 0 0;
  padding: var(--space-4) var(--space-5);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  background: var(--surface-sunken);
  font-size: var(--fs-sm);
  color: var(--text-subtle);
  line-height: 1.55;
}
.pay-note .icon { width: 18px; height: 18px; color: var(--accent); flex: none; margin-top: 2px; }
.pay-note strong { color: var(--text); font-weight: 600; }

/* --- Touch: stop hover styles sticking -----------------------------------
   36 rules in this file use :hover. On a touch screen a tap fires :hover and
   it stays applied until the visitor taps something else — so a card sits lit
   up, a table row stays highlighted, and an arrow stays nudged long after the
   finger has gone. It reads as a stuck interface.

   Rather than wrap all 36 in @media (hover: hover), this resets the visual
   result on pointers that cannot hover. Additive, so no existing rule had to
   be touched and desktop is untouched. :active still fires on touch, which is
   where the real feedback comes from. */
@media (hover: none) {
  .card--link:hover,
  .card--link:focus-within { transform: none; box-shadow: none; }
  .card--link:hover { border-top-color: var(--border-strong); }
  .card--link:hover h3 { color: inherit; }

  .price-table tbody tr:hover { background: transparent; box-shadow: none; }
  .price-table tbody tr:hover .price-table__link { color: var(--text); }
  .price-table tbody tr:hover .icon { opacity: 0; transform: translateX(-4px); }
  /* the highlighted band is a deliberate state, not a hover artefact */
  .price-table tbody tr.is-match:hover {
    background: color-mix(in srgb, var(--olive-500) 14%, transparent);
    box-shadow: inset 3px 0 0 var(--accent);
  }
  .price-table tbody tr.is-match:hover .price-table__link { color: var(--accent); }
  .price-table tbody tr.is-match:hover .icon { opacity: 1; transform: none; }

  .detail:hover .price-table__link { color: var(--text); }
  .detail:hover .price-table__link .icon { opacity: 0.55; transform: none; }

  .link-arrow:hover .icon,
  .card--ask:hover .card__cta .icon { transform: none; }
  .footer__links a:hover { color: inherit; }
  .btn:hover { transform: none; }
}
