/* ---------------------------------------------------------------------------
   FONTS - SELF-HOSTED. Do not put these back on fonts.googleapis.com.
   Two reasons, both load-bearing:
   1. PERF. Google served these off two extra origins (googleapis + gstatic), so a
      phone paid DNS + TLS twice before a single glyph moved. They were the last
      two requests to finish on the homepage. Same bytes, one origin, no handshake.
   2. PRIVACY. A Google-hosted font sends every visitor's IP to Google on page load,
      with no consent and no way for the cookie banner to gate it. Self-hosting is
      what COMPLIANCE.md asks for, and it is why this is not just a speed tweak.
   font-weight is a RANGE, not a number: these are VARIABLE fonts and one file
   carries the whole axis. Google's own CSS says 400 on every face because it emits
   one per weight you asked for - copy that verbatim and every 600 heading on the
   site quietly becomes synthetic bold.
   latin-ext is declared but has a unicode-range no English page touches, so a
   browser never downloads it. It costs nothing and covers a name like Renée.
   --------------------------------------------------------------------------- */
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/fraunces-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
 
}
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/fraunces-latin.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: 'Inter';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/inter-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
 
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/inter-latin.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;
 
}

/* ===== M. Thorne Carpentry — brand system ===== */
:root{
  --espresso:#211B16; --walnut:#3A291D; --walnut-2:#4A3526;
  --oak:#B77D45; --oak-dark:#8B5E32; --oak-soft:#C79A5B;
  --cream:#F3ECE0; --cream-2:#EADFCD; --paper:#FBF7F0;
  --ink:#241C15; --muted:#6E5C4B; --line:#E1D4C0;
  --wrap:1180px; --r:16px; --sh:0 18px 46px -22px rgba(33,20,10,.5);
  --serif:"Fraunces",Georgia,serif; --sans:"Inter",system-ui,sans-serif;
  --ease:cubic-bezier(.22,.61,.36,1);
  --ease-out-quart:cubic-bezier(.25,1,.5,1);
  /* A FIXED z-index scale. Arbitrary values are how a cookie modal ends up sitting
     invisibly over a whole site swallowing every click - use only these four. */
  --z-callbar:100; --z-header:200; --z-drawer:300; --z-modal:400;
  /* the fixed header, so anchored sections can clear it when you jump to them */
  --header-h:74px;
}
*{box-sizing:border-box}
[hidden]{display:none!important}
html{scroll-behavior:smooth}
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:17px;line-height:1.62;-webkit-font-smoothing:antialiased}
/* ⛔ height:auto IS LOAD-BEARING, and it was missing.
   [2026-08-29] Adding width/height ATTRIBUTES to fix CLS turned Matt's portrait
   into a 464x1400 slab. A dimension attribute is a presentational hint for the
   height PROPERTY, so with both axes definite the `aspect-ratio:4/5` on
   .about-img img was simply ignored - his own photo, on his own About section,
   1608px tall at 1440 with a dead column beside it. Every gate passed; only
   looking at it caught it.
   ⛔ So the CLS fix and this line are ONE change. Never add the attributes
   without it. Rules that set an explicit height (.shot img, .foot-brand img)
   are more specific and still win. */
img{max-width:100%;height:auto;display:block}
a{color:inherit}
/* [2026-08-29] Line-height by SIZE, not one number for every heading. 1.06 is
   right for the 86px h1 and cramped on a wrapping 20px h3 - which is most of the
   card titles on this site. h3/h4 now breathe; h1/h2 are unchanged. */
h1,h2,h3,h4{font-family:var(--serif);font-weight:600;line-height:1.06;letter-spacing:-.015em;margin:0}
h3,h4{line-height:1.18;letter-spacing:-.008em}
h2{font-size:1.9rem;font-size:clamp(1.9rem,4.2vw,3rem)}
h3{font-size:1.28rem;letter-spacing:-.01em}
p{margin:0 0 1rem}
.wrap{max-width:var(--wrap);margin:0 auto;padding:0 22px}
.kicker{font-family:var(--sans);font-weight:600;font-size:.76rem;letter-spacing:.16em;text-transform:uppercase;color:var(--oak-dark);margin:0 0 .6rem}
.sec-head{max-width:640px;margin:0 auto 2rem;margin:0 auto clamp(2rem,4vw,3.2rem);text-align:center}
.sec-sub{color:var(--muted);font-size:1.06rem;margin-top:.7rem}
.skip{position:absolute;left:-999px}.skip:focus{left:12px;top:12px;background:#fff;padding:10px 16px;z-index:calc(var(--z-modal) + 10);border-radius:8px}
section{position:relative}

/* buttons */
.btn{--bg:var(--walnut);--fg:var(--cream);display:inline-flex;align-items:center;justify-content:center;gap:.5em;
  background:var(--bg);color:var(--fg);font-family:var(--sans);font-weight:600;font-size:.98rem;text-decoration:none;
  padding:.82em 1.5em;border-radius:999px;border:1.5px solid var(--bg);cursor:pointer;transition:transform .25s var(--ease),box-shadow .25s,background .25s}
.btn:hover{transform:translateY(-2px);box-shadow:0 14px 26px -12px rgba(58,41,29,.55)}
.btn-lg{padding:1em 1.9em;font-size:1.05rem}
.btn-sm{padding:.6em 1.15em;font-size:.9rem;line-height:1.35}
.btn-block{width:100%}
.btn-ghost{--bg:transparent;--fg:var(--cream);border-color:rgba(243,236,224,.5);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}
.btn-ghost:hover{background:rgba(243,236,224,.12)}

/* ===== NAV ===== */
.nav{position:fixed;top:0;right:0;bottom:auto;left:0;inset:0 0 auto;z-index:var(--z-header);transition:background .3s,box-shadow .3s,padding .3s;padding:.5rem 0}
.nav-in{display:flex;align-items:center;gap:1.5rem}
.brand{display:flex;align-items:center;gap:12px;height:52px;text-decoration:none}
.brand-mark{height:40px;width:auto;flex:none}
.brand-txt{display:flex;flex-direction:column;line-height:1}
.bn{font-family:var(--serif);font-weight:600;font-size:1.24rem;letter-spacing:.005em;color:var(--cream);transition:color .3s}
/* .68rem, not .6rem. At .6rem this rendered 9.6px - it is the only text on the
   site under 12px, it appears in the header AND the footer of all nine pages, and
   on a phone at arm's length it was a grey smudge rather than the trade he does.
   Uppercase at .26em tracking carries the extra size without unbalancing the
   lockup; "M. THORNE" above it is unchanged, so the hierarchy holds. */
.bs{font-family:var(--sans);font-weight:600;font-size:.68rem;letter-spacing:.24em;text-transform:uppercase;color:var(--oak-soft);margin-top:4px;transition:color .3s}
.links{margin-left:auto;display:flex;gap:1.7rem}
.links a{color:var(--cream);text-decoration:none;font-weight:500;font-size:.98rem;opacity:.92;position:relative}
.links a::after{content:"";position:absolute;left:0;bottom:-5px;width:0;height:2px;background:var(--oak);transition:width .28s var(--ease)}
.links a:hover::after{width:100%}
.nav .btn{margin-left:.3rem}
/* [2026-09-05] .nav--solid is .nav.scrolled's appearance, held permanently.
   The legal pages used to carry their own .lgnav bar, which is why they were three
   separate copies of a header. Now they share the one header partial - but that header
   is styled light-on-dark because on every other page it sits over the hero, so on a
   cream legal page the nav links rendered at near-zero contrast. .scrolled cannot be
   hardcoded: main-v4.js runs classList.toggle('scrolled', lastY > 40) and strips it at
   the top of the page. Hence a second class the JS never touches.
   Additive and unreachable by anything that existed before it: no other element in this
   site carries .nav--solid. */
.nav.scrolled,.nav--solid{background:var(--cream);box-shadow:0 8px 30px -18px rgba(33,20,10,.4);padding:.35rem 0}
.nav.scrolled .links a,.nav--solid .links a{color:var(--ink)}
.nav.scrolled .bn,.nav--solid .bn{color:var(--ink)}
/* [3 Oct 2026] the number in the desktop header (the blind eye: a desktop visitor had to scroll to the
   footer to find it). Hidden <=960px, where the burger menu's Call Matt and the sticky pill carry it. */
.nav-tel{color:var(--cream);text-decoration:none;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap;opacity:.95}
.nav-tel:hover{color:var(--oak)}
.nav.scrolled .nav-tel,.nav--solid .nav-tel{color:var(--ink)}
@media(max-width:1120px){.links{gap:1.15rem}.nav-in{gap:1.1rem}}
/* [2026-08-29] The legal pages put .brand on a CREAM .lgnav bar and never get
   .nav.scrolled, so .bn stayed cream-on-cream: 1.00:1, his name invisible on all
   three. Same pair the scrolled nav already uses. */
.lgnav .bn{color:var(--ink)}
.lgnav .bs{color:var(--oak-dark)}
.nav.scrolled .bs,.nav--solid .bs{color:var(--oak-dark)}
/* 44x44 because it is the only way into the menu on a phone and it measured
   42x32 - under the target size on the one control that has no alternative.
   The bars stay 26px wide; the box around them grows, not the icon. */
.burger{display:none;margin-left:auto;background:none;border:0;flex-direction:column;
  align-items:center;justify-content:center;gap:5px;cursor:pointer;padding:8px;
  min-width:44px;min-height:44px}
.burger span{width:26px;height:2px;background:var(--cream);transition:.3s}
.nav.scrolled .burger span,.nav--solid .burger span{background:var(--ink)}
.mobmenu{display:none}

/* ===== HERO ===== */
/* background-color as the floor under the poster image and veil. The hero had
   none, so its cream text and ghost button had nothing to sit on if the poster
   fails to load or is blocked - cream on the near-white body, measured 1.1:1.
   Same one-line fix as .phead, same reason: never let light text depend on an
   image having arrived. */
.hero{min-height:100vh;min-height:100svh;display:flex;align-items:center;overflow:hidden;
  background-color:var(--espresso);color:var(--cream);isolation:isolate}
.hero-bg{position:absolute;top:0;right:0;bottom:0;left:0;inset:0;background-size:cover;background-position:center;z-index:-3;transform:scale(1.06);animation:kenburns 18s ease-out forwards}
@keyframes kenburns{to{transform:scale(1)}}
.hero-veil{position:absolute;top:0;right:0;bottom:0;left:0;inset:0;z-index:-2;background:
  linear-gradient(103deg,rgba(18,12,7,.94) 0%,rgba(20,13,8,.86) 46%,rgba(26,17,10,.6) 80%,rgba(30,20,12,.46) 100%),
  linear-gradient(0deg,rgba(15,10,6,.85),rgba(15,10,6,0) 55%)}
.grain{position:absolute;top:0;right:0;bottom:0;left:0;inset:0;z-index:-1;opacity:.5;pointer-events:none;mix-blend-mode:overlay;
  background-image: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='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.55'/%3E%3C/svg%3E")}
.hero-in{padding:120px 22px 90px;max-width:900px}
/* ---- Cinematic video hero ---- */
.hero-poster,.hero-video{position:absolute;top:0;right:0;bottom:0;left:0;inset:0;width:100%;height:100%;object-fit:cover}
.hero-poster{z-index:-4}
.hero-video{z-index:-3;opacity:0;transition:opacity .9s ease}
.hero-video.on{opacity:1}
.hero-veil2{position:absolute;top:0;right:0;bottom:0;left:0;inset:0;z-index:-2;background:
  linear-gradient(94deg,rgba(13,8,4,.86) 0%,rgba(13,8,4,.56) 42%,rgba(13,8,4,.32) 100%),
  linear-gradient(0deg,rgba(13,8,4,.7),rgba(13,8,4,0) 42%)}
.hero-in2{display:grid;grid-template-columns:1fr;max-width:960px;align-items:center;padding:120px 22px 92px;width:100%}
.hero-copy{max-width:640px}
.hero h1{font-size:2.4rem;font-size:clamp(2.4rem,5.2vw,4.3rem);color:var(--cream);text-shadow:0 2px 20px rgba(0,0,0,.5)}
.hero h1 em{font-style:italic;color:#E7CE9C}
.eyebrow{font-weight:600;font-size:.82rem;letter-spacing:.18em;text-transform:uppercase;color:var(--oak-soft);margin-bottom:1.1rem}
.hero h1{font-size:2.7rem;font-size:clamp(2.7rem,7.4vw,5.4rem);font-weight:600;letter-spacing:-.025em;margin-bottom:1.1rem}
/* ---- planed-reveal: the wordmark carved into oak as a plane passes ---- */
.carve{position:relative;display:inline-block}
.carve-raw{color:#7a5f3d;-webkit-text-fill-color:#7a5f3d;opacity:.4}
.carve-fin,.sheen{
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='600' height='220'%3E%3Cfilter id='w'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.009 0.13' numOctaves='4' seed='11'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%236b4a28' filter='url(%23w)'/%3E%3C/svg%3E"),
    linear-gradient(176deg,#F6E7C6,#E7CE9C 50%,#D2AE74);
  background-size:cover,cover;
  -webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;
  text-shadow:0 1px 0 rgba(255,249,232,.42),0 -1px 1px rgba(40,20,8,.65),0 2px 12px rgba(0,0,0,.6),0 4px 22px rgba(0,0,0,.4);
}
.carve-fin{position:absolute;left:0;top:0;width:100%}
.intro .carve-fin{clip-path:inset(-8% 100% -8% -3%)}
.carve.carving .carve-fin{animation:carve-open var(--cd,1.7s) var(--ease) forwards}
@keyframes carve-open{from{clip-path:inset(-8% 100% -8% -3%)}to{clip-path:inset(-8% -3% -8% -3%)}}
.carve-tool{position:absolute;top:38%;left:0;transform:translate(-46px,-50%);opacity:0;pointer-events:none;filter:drop-shadow(0 8px 10px rgba(0,0,0,.5))}
.carve.carving .carve-tool{animation:plane-move var(--cd,1.7s) var(--ease) forwards}
@keyframes plane-move{0%{left:-3%;opacity:0}9%{opacity:1}88%{opacity:1}100%{left:103%;opacity:0}}
.dust{position:absolute;right:18px;top:14px}
.dust i{position:absolute;width:7px;height:5px;border-radius:60% 40% 55% 45%;background:#E9D2A8;opacity:0}
.carve.carving .dust i{animation:dust 1s var(--ease) infinite}
.dust i:nth-child(1){animation-delay:0s;--dx:-22px;--dy:-24px}
.dust i:nth-child(2){animation-delay:.09s;--dx:-30px;--dy:-14px}
.dust i:nth-child(3){animation-delay:.18s;--dx:-16px;--dy:-30px}
.dust i:nth-child(4){animation-delay:.27s;--dx:-34px;--dy:-20px}
.dust i:nth-child(5){animation-delay:.36s;--dx:-20px;--dy:-12px}
.dust i:nth-child(6){animation-delay:.12s;--dx:-26px;--dy:-32px}
.dust i:nth-child(7){animation-delay:.22s;--dx:-38px;--dy:-8px}
.dust i:nth-child(8){animation-delay:.31s;--dx:-14px;--dy:-22px}
.dust i:nth-child(9){animation-delay:.05s;--dx:-30px;--dy:-26px}
.dust i:nth-child(10){animation-delay:.4s;--dx:-24px;--dy:-16px}
@keyframes dust{0%{opacity:0;transform:translate(0,0) scale(.5) rotate(0)}18%{opacity:.9}100%{opacity:0;transform:translate(var(--dx),var(--dy)) scale(1) rotate(140deg)}}
.lede{font-size:1.08rem;font-size:clamp(1.08rem,2vw,1.32rem);max-width:600px;color:#EDE2D2;margin-bottom:1.9rem}
.hero-cta{display:flex;gap:.9rem;flex-wrap:wrap;margin-bottom:2.4rem}
.hero-trust{list-style:none;display:flex;gap:2.2rem;padding:0;margin:0;flex-wrap:wrap}
.hero-trust li{font-size:.96rem;color:#E7DAC7}
.hero-trust strong{font-family:var(--serif);font-size:1.5rem;color:#fff;font-weight:600;margin-right:.15em}
.hero-trust li+li{padding-left:2.2rem;border-left:1px solid rgba(243,236,224,.2);margin-left:-.6rem}
.scroll-cue{position:absolute;bottom:26px;left:50%;transform:translateX(-50%);width:26px;height:44px;border:2px solid rgba(243,236,224,.5);border-radius:16px}  /* [21 Sep] 44 tall: the one tappable thing on the first screen under the tap floor */
.scroll-cue::after{content:"";position:absolute;left:50%;top:8px;width:4px;height:8px;background:var(--cream);border-radius:2px;transform:translateX(-50%);animation:cue 1.6s infinite}
@keyframes cue{0%{opacity:0;transform:translate(-50%,0)}40%{opacity:1}100%{opacity:0;transform:translate(-50%,14px)}}

/* ===== TRUST ===== */
.trust{background:var(--walnut);color:var(--cream)}
.trust-in{padding:2.6rem 22px;display:flex;align-items:center;gap:1.6rem;flex-wrap:wrap;justify-content:center;text-align:center}
.trust .stars{color:var(--oak-soft);font-size:1.4rem;letter-spacing:2px}
.trust p{max-width:620px;margin:0;font-size:1.04rem;color:#EFE5D6}
.trust strong{color:#fff}
.badges{list-style:none;display:flex;gap:.7rem;padding:0;margin:0;flex-wrap:wrap;justify-content:center}
.badges li{font-size:.78rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;padding:.5em 1em;border:1px solid rgba(243,236,224,.28);border-radius:999px;color:#E7DAC7}

/* ===== SERVICES ===== */
.services{padding:4rem 0;padding:clamp(4rem,8vw,6.5rem) 0;background:var(--paper)}
.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.4rem}
.card{background:#fff;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:0 2px 0 rgba(0,0,0,.01);transition:transform .3s var(--ease),box-shadow .3s}
.card:hover{transform:translateY(-6px);box-shadow:var(--sh)}
.card-img{background-size:cover;background-position:center;transition:transform .5s var(--ease)}
.card:hover .card-img{transform:scale(1.05)}
.card-b{padding:1.35rem 1.4rem 1.6rem}
.card-b h3{margin-bottom:.4rem}
.card-b p{color:var(--muted);font-size:.98rem;margin:0}

/* ===== WORK ===== */
.work{padding:4rem 0;padding:clamp(4rem,8vw,6.5rem) 0;background:var(--cream)}
.filters{display:flex;gap:.6rem;justify-content:center;flex-wrap:wrap;margin-bottom:2rem}
.chip{font-family:var(--sans);font-weight:600;font-size:.92rem;padding:.55em 1.2em;border-radius:999px;border:1.5px solid var(--line);background:#fff;color:var(--muted);cursor:pointer;transition:.25s}
.chip:hover{border-color:var(--oak)}
.chip.is-on{background:var(--walnut);color:var(--cream);border-color:var(--walnut)}
.gallery{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:200px;gap:14px}
.shot{overflow:hidden;border-radius:12px;position:relative}
.shot:nth-child(1),.shot:nth-child(6){grid-row:span 2}
.shot img{width:100%;height:100%;object-fit:cover;transition:transform .5s var(--ease)}
.shot::after{content:"";position:absolute;top:0;right:0;bottom:0;left:0;inset:0;background:linear-gradient(0deg,rgba(33,20,10,.28),transparent 45%);opacity:0;transition:.3s}
.shot:hover img{transform:scale(1.06)}
.shot:hover::after{opacity:1}
.shot.hide{display:none}

/* ===== WHY ===== */
.why{padding:4rem 0;padding:clamp(4rem,8vw,6.5rem) 0;background:var(--espresso);color:var(--cream)}
.why .kicker{color:var(--oak-soft)}.why h2{color:#fff}.why .sec-sub{color:#C9B9A5}
.why-grid{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(4,1fr);gap:1.6rem;align-items:stretch}
/* [2026-08-29] These grids went to align-items:start earlier today, then back to
   stretch the same day. Both readings were right about a real defect and wrong about
   the fix, so the reasoning is worth keeping:

   START was to stop a one-line quote card being stretched to match a five-line one,
   which left ~290px of empty white on reviews.html. STRETCH is because these are
   VISIBLE BOXES - white fill, 1px border, radius - and four bordered boxes in a row
   ending at four different heights reads as broken layout, not as breathing room.
   Measured ragged: .why-grid card 01 at 250px against 226px, .quotes middle card at
   203px against 229px.

   The actual fix is neither: stretch the box, then anchor the attribution to the
   BOTTOM of it (margin-top:auto below). A short quote now gives a full-height card
   with the name sitting on the bottom edge, which reads as deliberate. The stranded
   whitespace that justified `start` only existed because the caption floated in the
   middle with nothing holding it down. */
.quotes{align-items:stretch}

/* ⛔ reviews.html ONLY, and it is a different problem from the homepage's three quotes.
   That page now carries all five reviews IN FULL, verbatim, because the page's promise
   is that nothing was cut - and full length means a 5-word review ("great work and very
   clean") sits in the same row as a 90-word one. In an equal-height grid that gave GE H
   a card with 354px of empty white inside it: measured, and worse than the ~290px that
   sent me to align-items:start this morning.

   No row-based alignment can fix that, because the premise of a row is that the cards
   are comparable. A column flow drops the premise: each card is its own natural height
   and they pack down the columns, so a short review is simply a short card. The
   homepage keeps the grid, where three trimmed quotes genuinely are comparable
   (measured 232/232/232). */
.quotes.quotes-flow{display:block;column-count:3;column-gap:1.4rem}
.quotes.quotes-flow .qcard{break-inside:avoid;-webkit-column-break-inside:avoid;display:block;margin:0 0 1.4rem}
.quotes.quotes-flow .qcard figcaption{margin-top:1rem}
@media(max-width:900px){.quotes.quotes-flow{column-count:2}}
@media(max-width:600px){.quotes.quotes-flow{column-count:1}}
.why-grid li{padding:1.6rem 1.4rem;border:1px solid rgba(243,236,224,.14);border-radius:var(--r);background:rgba(243,236,224,.03)}
.wn{font-family:var(--serif);font-size:1.1rem;color:var(--oak);display:block;margin-bottom:.7rem}
.why-grid h3{color:#fff;margin-bottom:.5rem}
.why-grid p{color:#C4B4A0;font-size:.97rem;margin:0}

/* ===== REVIEWS ===== */
.reviews{padding:4rem 0;padding:clamp(4rem,8vw,6.5rem) 0;background:var(--paper)}
.quotes{display:grid;grid-template-columns:repeat(3,1fr);gap:1.4rem}
.qcard{margin:0;background:#fff;border:1px solid var(--line);border-radius:var(--r);padding:1.8rem 1.6rem;position:relative;display:flex;flex-direction:column}
.qcard::before{content:"\201C";position:absolute;top:.1em;left:.25em;font-family:var(--serif);font-size:4rem;color:var(--oak);opacity:.28;line-height:1}
/* [29 Aug 2026, review] The decorative opening quote is 4rem and absolutely placed at
   the top of the card, and the text began at the card's own 1.8rem padding - measured
   28.8px, while the glyph runs to roughly 32px. So the first line was crowding, and on
   a phone, where the cards stack, every card read tight. Clear the glyph. */
.qcard blockquote{margin:0 0 1rem;padding-top:1.15rem;font-size:1.05rem;line-height:1.55;position:relative}
.qcard figcaption{color:var(--oak-dark);font-weight:600;font-size:.9rem;margin-top:auto;padding-top:.2rem}
.reviews-cta{text-align:center;margin-top:2rem}
.reviews-cta a{color:var(--walnut);font-weight:600;text-decoration:none;border-bottom:2px solid var(--oak)}
.reviews-cta--google{margin-top:.9rem;color:var(--muted)}

/* ===== AREAS ===== */
.areas{padding:4rem 0;padding:clamp(4rem,8vw,6.5rem) 0;background:var(--cream)}
.areas-in{display:grid;grid-template-columns:1.1fr .9fr;gap:2.5rem;align-items:center}
.areas-t .sec-sub{margin-bottom:1.4rem}
.patch{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:1fr 1fr;gap:.7rem}
.patch li{background:#fff;border:1px solid var(--line);border-radius:10px;padding:.9rem 1.1rem;font-weight:600;position:relative;padding-left:2.4rem}
.patch li::before{content:"";position:absolute;left:1rem;top:50%;transform:translateY(-50%);width:9px;height:9px;border-radius:50%;background:var(--oak)}

/* ===== ABOUT ===== */
.about{padding:4rem 0;padding:clamp(4rem,8vw,6.5rem) 0;background:var(--paper)}
.about-in{display:grid;grid-template-columns:.85fr 1.15fr;gap:2.8rem;align-items:center}
.about-img img{border-radius:var(--r);box-shadow:var(--sh);width:100%;object-fit:cover;object-position:center top}
.about-t h2{margin-bottom:.9rem}
.creds{list-style:none;padding:0;margin:1.4rem 0 0;display:flex;gap:.6rem;flex-wrap:wrap}
.creds li{background:var(--cream);border:1px solid var(--line);border-radius:999px;padding:.5em 1.1em;font-weight:600;font-size:.85rem}

/* ===== QUOTE ===== */
.quote{padding:4rem 0;padding:clamp(4rem,8vw,6.5rem) 0;background:var(--walnut);color:var(--cream)}
.quote-in{display:grid;grid-template-columns:1fr 1.05fr;gap:3rem;align-items:center}
.quote .kicker{color:var(--oak-soft)}.quote h2{color:#fff}.quote .sec-sub{color:#D9CBB8}
.quote-t .sec-head{text-align:left}
.contact-list{list-style:none;padding:0;margin:1.6rem 0 0;display:grid;gap:.7rem}
.contact-list li{display:flex;gap:1rem;align-items:baseline;font-size:1.02rem;color:#EFE5D6}
.contact-list span{width:56px;color:var(--oak-soft);font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.08em;flex:none}
.contact-list a{color:#fff;text-decoration:none;border-bottom:1px solid rgba(255,255,255,.3)}
.quote-f{background:var(--paper);color:var(--ink);border-radius:var(--r);padding:1.9rem;box-shadow:var(--sh)}
.quote-f .row{margin-bottom:1rem}
.quote-f label{display:block;font-weight:600;font-size:.9rem;color:var(--walnut)}
.quote-f input,.quote-f select,.quote-f textarea{width:100%;margin-top:.4rem;padding:.8em .9em;border:1.5px solid var(--line);border-radius:10px;font:inherit;font-size:1rem;background:#fff;color:var(--ink)}
.quote-f input:focus,.quote-f select:focus,.quote-f textarea:focus{outline:none;border-color:var(--oak);box-shadow:0 0 0 3px rgba(183,125,69,.18)}
.form-note{font-size:.85rem;color:var(--muted);margin:.9rem 0 0;text-align:center}
.form-ok{background:#eaf5ea;border:1px solid #b9dcb9;color:#2c6b2c;padding:1rem;border-radius:10px;text-align:center;font-weight:500}

/* ===== FOOTER ===== */
.foot{background:var(--espresso);color:#C4B4A0;padding:3.4rem 0 1.6rem;font-size:.94rem}
.foot-in{display:grid;grid-template-columns:1.6fr 1fr 1fr 1.2fr;gap:2rem;padding-bottom:2.2rem;border-bottom:1px solid rgba(243,236,224,.12)}
.foot-brand img{height:46px;margin-bottom:.9rem}
/* the strapline was crowding the logo lockup - give it room to breathe */
.foot-brand .brand{margin-bottom:1.35rem}
.foot-brand p{max-width:280px;margin:0;line-height:1.6}
/* [2026-08-29] h4 -> h3.fc-h. The footer column titles skipped a level (axe
   heading-order); the look is unchanged, only the level. */
.foot-col h3.fc-h{font-family:var(--sans);font-size:.78rem;letter-spacing:.12em;text-transform:uppercase;color:var(--oak-soft);margin:0 0 .9rem;line-height:1.18}
.foot-col a{display:block;color:#C4B4A0;text-decoration:none;padding:.28rem 0;transition:color .2s}
.foot-col a:hover{color:#fff}
/* [2026-08-30, review] The credit, second attempt. The first put it on its own centred
   line above this bar, and he was right that it looked bolted on: on a phone every other
   line in the footer is left-aligned and hard against the gutter, so a centred line
   floating above them reads as something added later. "like it was there from day one."

   So it is not its own row any more - it is the last item of the small-print line that
   already existed, after the legal links and behind the same separator they use. That
   line is where a site's housekeeping lives, which is exactly what a build credit is.
   On desktop it sits at the end of that group on the right; on a phone the whole bar
   stacks left-aligned on one gutter with an even rhythm, so nothing floats. */
.foot-btm{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem 1.5rem;flex-wrap:wrap;padding-top:1.4rem;font-size:.85rem;color:#A2917E}
.foot-btm > p{margin:0}
.foot-btm .legal{display:flex;flex-wrap:wrap;align-items:baseline;gap:.4rem}
/* [21 Sep 2026] 44px tap targets on the small links (layout_audit): the HIT AREA grows by padding and the
   negative margin gives the space back, so nothing moves on screen. Breadcrumb 'Home' and the legal links. */
.foot-btm .legal a{display:inline-block;padding:.75rem .25rem;margin:-.75rem -.25rem}
.foot-dot{opacity:.45}
/* ⛔ .foot-btm .foot-by a, not .foot-by a. `.foot-btm a{color:#A2917E}` sits further
   down this file at the SAME specificity, so it won by source order and the credit link
   came out the same colour as the text around it. Measured, not guessed: the link
   contrast read 5.59 (the body colour) instead of 6.65 (the gold). */
.foot-btm .foot-by a{color:var(--oak-soft);text-decoration:none;border-bottom:1px solid rgba(199,154,91,.35);padding-bottom:1px;transition:color .2s,border-color .2s}
.foot-btm .foot-by a:hover,.foot-btm .foot-by a:focus-visible{color:#fff;border-bottom-color:rgba(255,255,255,.6)}
@media(max-width:700px){
  /* ⛔ One gutter, one rhythm. His screenshot showed the fault: three blocks at three
     different vertical gaps with the credit centred against two left-aligned lines. */
  .foot-btm{flex-direction:column;align-items:flex-start;gap:.75rem;text-align:left}
  .foot-btm .legal{gap:.35rem}
}
.foot-btm a{color:#A2917E;text-decoration:none}.foot-btm a:hover{color:#fff}

/* ===== CALLBAR / LIGHTBOX / COOKIE ===== */
.callbar{display:none;position:fixed;left:50%;bottom:calc(16px + env(safe-area-inset-bottom));transform:translateX(-50%) translateY(140%);z-index:var(--z-callbar);background:var(--oak-dark);color:#fff;text-align:center;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:.01em;padding:.85rem 1.6rem;border-radius:999px;text-decoration:none;white-space:nowrap;box-shadow:0 2px 6px rgba(33,20,10,.18),0 14px 34px -10px rgba(33,20,10,.55);opacity:0;pointer-events:none;transition:transform .42s var(--ease-out-quart),opacity .3s ease}
/* only once the hero is behind you - JS adds .show */
/* [2026-08-29] Honeypot. Off-screen rather than display:none - a bot that reads
   CSS skips hidden fields, and a screen reader is told to leave it. */
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
/* the form saying it did NOT send. It had no styling because nothing ever failed. */
.form-err{margin:.9rem 0 0;padding:.75rem .9rem;border-radius:10px;font-size:.92rem;
  color:#5a2d2d;background:#F7E9E4;border:1px solid #E0BDB2}
.form-err a{color:#5a2d2d;font-weight:600}
.callbar.show{transform:translateX(-50%) translateY(0);opacity:1;pointer-events:auto}
.lightbox{position:fixed;top:0;right:0;bottom:0;left:0;inset:0;z-index:var(--z-modal);background:rgba(20,13,7,.94);display:flex;align-items:center;justify-content:center}
.lightbox img{max-width:90vw;max-height:86vh;border-radius:8px}
.lightbox button{position:absolute;background:rgba(255,255,255,.12);color:#fff;border:0;width:52px;height:52px;border-radius:50%;font-size:1.6rem;cursor:pointer}
.lb-close{top:20px;right:20px}.lb-prev{left:20px;top:50%;transform:translateY(-50%)}.lb-next{right:20px;top:50%;transform:translateY(-50%)}
/* ⛔ RIGHT, not left, on desktop. Bottom-left sits directly over the hero proof strip
   - measured on first load at 1440: banner x16-406 y726-884 against "9.95/10 rated"
   at x262-379 y727-766, and elementFromPoint on the stat returned the banner. The
   whole right half of the hero is empty, so this costs nothing and needs no change to
   the consent copy. The phone case is different and handled in the <=600 query: there
   the banner is full-width above the sticky call bar, and it does still overlap the
   stats, which is a known trade rather than an oversight. */
.cookie{position:fixed;right:16px;left:auto;bottom:calc(16px + env(safe-area-inset-bottom));z-index:var(--z-drawer);max-width:360px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:1.1rem 1.2rem;box-shadow:var(--sh);display:flex;gap:1rem;align-items:center}
.cookie p{margin:0;font-size:.88rem;color:var(--muted)}.cookie a{color:var(--oak-dark)}
/* On a phone the banner had no rule of its own, so it ran 360px wide with the two
   buttons stacked and swallowed the bottom half of the hero - the "Get a free quote"
   and "Call Matt" buttons were both behind it (a review screenshot, 28 Aug).
   A consent banner has to be seen, but it must not sit on top of the only two things
   the page is asking the visitor to do. Stack the text over a row of buttons, keep it
   tight, and clear the sticky call rail underneath it. */
@media(max-width:600px){
  .cookie{left:10px;right:10px;max-width:none;flex-direction:column;align-items:stretch;
    gap:.5rem;padding:.75rem .95rem;border-radius:12px;
    bottom:calc(10px + env(safe-area-inset-bottom))}
  .cookie p{font-size:.79rem;line-height:1.4}
  /* The last 25px. Dropping the banner to the floor and tightening it cleared the first
     two stats, but "£1m public liability" wraps to a second line on a 390 phone and its
     bottom edge still ran under the banner. So the hero gives the band back the few
     pixels it needs - and it does it from the class the inline <head> script sets BEFORE
     first paint, from localStorage, so there is no shift to see. The hero is centred, so
     bottom padding lifts the content by about half of what you add.
     ⛔ Only while consent is undecided. Once they answer, the class goes and the hero is
     exactly as designed - which is why this is padding on a conditional class and not a
     permanent change to the hero. */
  /* ⛔ THE BANNER MUST NOT COVER THE PROOF. Worked out from real numbers at 390x844
     rather than by nudging: the banner is 115px tall and its floor is 834, so its top
     edge is 719. The hero's proof strip ended at 731. Something had to give 20px.

     Reducing the hero's TOP padding was tried first and barely moved it - the hero
     content already slightly overflows this viewport, so it is not being centred and
     76px of padding-bottom bought 7px of lift. What actually pays is the two gaps in
     the tail of the hero: 38.4px between the buttons and the stats, and a 19.2px row
     gap inside a strip that wraps to two lines on a phone. Tightening those moves the
     strip up ~23px and clears the banner with room to spare.

     ⛔ Scoped to consent-pending on purpose. Most page views have no banner, and the
     hero as designed should not be permanently tightened to accommodate one. */
  /* ⛔ ONE STRUCTURAL RESERVATION, not a per-phone nudge. .hero is min-height:100svh
     with border-box sizing and its content centred, so padding-bottom keeps the hero
     exactly full-height while shrinking the box the content centres inside. The content
     lifts clear of the banner's band on ANY viewport height, automatically.
     I arrived here after tuning padding by hand against 390x844 and calling it fixed -
     then testing 320/360/375 and finding the banner over "Call Matt". Pixel targets
     fitted to one screen are not a fix, they are a coincidence with good manners. */
  html.consent-pending .hero{padding-bottom:132px}
  /* and give the reservation somewhere to come FROM. The hero's 120px top padding
     plus the reserved band exceeds the viewport, at which point the content stops
     being centred and top-anchors, and the reservation buys nothing. 84px still
     clears the 68px fixed nav. */
  html.consent-pending .hero-in2{padding-top:84px}
  .cookie-row{display:flex;gap:.5rem}
  .cookie-row .btn{flex:1;justify-content:center;white-space:nowrap}
}

/* ===== REVEAL ===== */
.js .reveal{opacity:0;transform:translateY(22px);transition:opacity .7s var(--ease),transform .7s var(--ease)}
.js .reveal.in{opacity:1;transform:none}

/* Hero copy travels in from the left. Each line is a beat behind the one above, so
   the block arrives in order rather than all at once.

   ⛔ [29 Aug 2026, review] IT USED TO DROP AS WELL, AND THAT IS GONE. The animation
   echoed the board in the hero film - across from the left while held high, then down
   into place - so the last 42% of every line was a 16px fall. the client, watching it on
   his phone: "they all start to move down a little as well and I don't like the
   downwards motion". He is right, and the reason is worth keeping: five lines each
   dropping on their own delay reads as the page settling AFTER it has loaded, which
   looks like something failed to finish. The sideways travel he liked is untouched.

   Duration came down from .92s with it. The horizontal move used to finish at 58% of
   .92s, about .53s, and the rest was the drop - so keeping .92s for the glide alone
   would have made the entrance noticeably slower than the one he just approved. */
.js .hero-copy .reveal{transition:none;transform:translateX(-42px)}
.js .hero-copy .reveal.in{animation:board-in .62s var(--ease) both}
.js .hero-copy .eyebrow.in{animation-delay:.05s}
.js .hero-copy h1.in{animation-delay:.17s}
.js .hero-copy .lede.in{animation-delay:.29s}
.js .hero-copy .hero-cta.in{animation-delay:.41s}
.js .hero-copy .hero-trust.in{animation-delay:.5s}
@keyframes board-in{
  0%   {opacity:0; transform:translateX(-42px)}        /* out to the left */
  18%  {opacity:1}
  100% {opacity:1; transform:translateX(0)}            /* arrives across. No drop. */
}

/* ===== RESPONSIVE ===== */
@media(max-width:960px){
  .hero-in2{grid-template-columns:1fr;gap:1.2rem;padding-top:104px}
  .cards,.quotes,.why-grid{grid-template-columns:1fr 1fr}
  .gallery{grid-template-columns:repeat(2,1fr)}
  .areas-in,.about-in,.quote-in{grid-template-columns:1fr;gap:2rem}
  /* ⛔ 320px: the page scrolled sideways by 13px on index, areas, contact and
     reviews. Measured, not guessed - .quote-t and .quote-f both rendered 311px
     wide inside a 276px track.
     A grid item is min-width:auto by default, which means it flatly refuses to
     shrink below its own min-content and simply bursts out of its track. Two
     different things set that floor: in .quote-t it was the email address, one
     unbreakable 28-character token; in .quote-f it was 1.9rem of padding either
     side of the fields. Same bug, two disguises.
     min-width:0 lets the track win. The wrap rule handles the email. Nothing
     here changes any layout above 600px. */
  .quote-in > *,.areas-in > *,.about-in > *,.trust-in > *{min-width:0}
  .contact-list a,.foot-col a,.quote-t a{overflow-wrap:anywhere}
  .about-img{max-width:420px;margin:0 auto}
  .links{display:none}.nav .btn{display:none}.nav-tel{display:none}.burger{display:flex}
  .mobmenu{display:none}.mobmenu.open{display:flex;flex-direction:column;gap:.2rem;background:var(--cream);padding:1rem 22px 1.4rem}
  .mobmenu a{padding:.7rem 0;color:var(--ink);text-decoration:none;font-weight:600;border-bottom:1px solid var(--line)}
  .mobmenu .btn{display:flex;width:100%;margin-top:.9rem;border-bottom:0;background:var(--walnut);color:var(--cream);border-color:var(--walnut);font-size:1.02rem;padding:.9em 1.5em}
  /* the button inherited .mobmenu a's dark-on-cream link colour, so cream text
     sat on a cream background and all but vanished. Pin both explicitly. */
  .mobmenu .mob-call{display:flex;align-items:center;justify-content:center;gap:.5rem;
    margin-top:.85rem;padding:.5rem 0 .1rem;border-bottom:0;font-size:1.12rem;
    font-weight:700;color:var(--oak-dark);font-variant-numeric:tabular-nums}
  .mobmenu .mob-call small{display:block;font-size:.72rem;font-weight:600;
    letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}  /* .nav .btn{display:none} above hides the DESKTOP header CTA on mobile, but the
     mobile menu lives inside <header class="nav"> so it was swallowing this one too -
     the menu opened with its primary call to action rendered 0x0. */
  .callbar{display:block}
}
@media(max-width:600px){
  .cards,.quotes,.why-grid,.gallery,.patch{grid-template-columns:1fr}
  .shot:nth-child(1),.shot:nth-child(6){grid-row:span 1}
  .gallery{grid-auto-rows:230px}
  /* ⛔ The separator has to go once this wraps. `li+li` gives every item but the first
     a left border and a left indent - which is correct on ONE line, and wrong the moment
     the list wraps, because the item that starts line 2 gets an indent and a vertical
     rule against nothing. Measured at 390: the first two stats sat on the hero's 22px
     gutter and "£1m public liability" wrapped to 12px, hanging 10px outside the text
     column that every other hero element lines up on, with a stray tick beside it.
     gap already does the separating on a wrapped list. */
  .hero-trust{gap:1.2rem}
  .hero-trust li+li{padding-left:0;border-left:0;margin-left:0}
  .foot-in{grid-template-columns:1fr 1fr}
}
@media(prefers-reduced-motion:reduce){
  *{animation:none!important;scroll-behavior:auto}
  .reveal{opacity:1;transform:none}
  /* .js .hero-copy .reveal outranks the line above on specificity, so without this
     the hero copy sits frozen 42px off to the left with its animation disabled -
     i.e. the one group of users who cannot see the effect get broken text. */
  .js .hero-copy .reveal,.js .hero-copy .reveal.in{opacity:1;transform:none}
  .carve-raw{display:none}
}


/* ===== CRAFT PASS (15 Aug 2026) =====================================
   The details nobody asks for and everybody feels. Each of these was
   missing and each is named in the elite-web-craft standard. */

/* Anchored sections were landing UNDER the fixed header - every nav link
   hid the heading you had just clicked to. */
/* [2026-09-06] h2[id] added. The list below was hand-maintained, so the fourteen
   service headings that gained ids never got the offset and every footer service
   link landed UNDER the sticky header - clicking Doors showed Flooring, clicking
   Kitchens showed Timber wall partitions. A hand-kept selector list is a rule
   somebody has to remember; h2[id] cannot fall behind. */
#trust,#services,#work,#reviews,#areas,#about,#quote,#why,#top,
h2[id]{
  scroll-margin-top:calc(var(--header-h) + 18px)}

/* A real focus ring, in brand, visible on the dark hero as well as on paper.
   There was none at all: keyboard users had nothing to follow. */
:where(a,button,input,textarea,select,summary,[tabindex]):focus-visible{
  outline:2px solid var(--oak);
  outline-offset:3px;
  border-radius:6px}
.hero :where(a,button):focus-visible,.callbar:focus-visible,.nav :where(a,button):focus-visible{
  outline-color:var(--oak-soft)}

::selection{background:var(--oak);color:var(--cream)}

/* Kills orphans and ragged headings for free. */
h1,h2,h3,h4,.sec-head p,.lede{text-wrap:balance}
p,li{text-wrap:pretty}

/* Figures that stack should line up on the digit, not wobble. */
.hero-trust strong,.stars,.rev-score,.foot-in a[href^="tel"],a[href^="tel"]{
  font-variant-numeric:tabular-nums}

/* Body measure. Long lines are the loudest amateur tell on a wide screen. */
.lede,.sec-sub,.why-grid p,.about-in p{max-width:65ch}


/* ===== ARRIVAL STAGGER =============================================
   Every grid was crossing the observer threshold at once, so six cards
   popped in together. The standard wants 60-120ms between them: rows
   should land one after another, the way a joint goes together. */
.js .cards .reveal,.js .quotes .reveal,.js .why-grid .reveal,
.js .cards.reveal > *,.js .quotes.reveal > *,.js .why-grid.reveal > *{
  transition-delay:calc(var(--i,0) * 90ms)}
.js .cards > *:nth-child(1),.js .quotes > *:nth-child(1),.js .why-grid > *:nth-child(1){--i:0}
.js .cards > *:nth-child(2),.js .quotes > *:nth-child(2),.js .why-grid > *:nth-child(2){--i:1}
.js .cards > *:nth-child(3),.js .quotes > *:nth-child(3),.js .why-grid > *:nth-child(3){--i:2}
.js .cards > *:nth-child(4),.js .why-grid > *:nth-child(4){--i:3}
.js .cards > *:nth-child(5){--i:4}
.js .cards > *:nth-child(6){--i:5}

/* the proof numbers count up on arrival - the one bit of motion that
   argues for the business rather than decorating it */
.hero-trust strong{font-variant-numeric:tabular-nums}

@media(prefers-reduced-motion:reduce){
  .js .cards > *,.js .quotes > *,.js .why-grid > *{transition-delay:0s!important}
}


/* Below 960px the sticky call bar appears and the banner has to sit clear above it.
   ⛔ But it only has to do that WHILE THE PILL IS ACTUALLY THERE, and on first load it
   is not - the pill only appears once you have scrolled the hero away. Reserving its
   4.6rem unconditionally pushed the banner up onto the hero's proof stats, so the very
   first thing a phone visitor saw had 9.95/10, 215 reviews and the £1m cover sitting
   behind a white box. main-v4.js now mirrors the pill's state onto <html>, so the
   banner takes that room only when there is something to clear. Measured at 390:
   banner bottom edge 834 against stats ending at 744. */
@media(max-width:960px){
  .cookie{
    left:12px; right:12px; max-width:none;
    bottom:calc(10px + env(safe-area-inset-bottom));
    transition:bottom .25s var(--ease);
  }
  html.callbar-up .cookie{ bottom:calc(4.6rem + env(safe-area-inset-bottom)); }
}
@media(prefers-reduced-motion:reduce){ .cookie{transition:none} }

/* ============================================================================
   SHORT PHONES. Not a cookie-banner rule - a hero rule that the banner exposed.
   ⛔ On a 375x667 handset (every iPhone SE, and any older 4.7") the hero overflowed
   the fold by 54px WITH NO BANNER AT ALL: the proof strip ended at 721 in a 667px
   viewport. It had been that way all along, and it was only found by testing six
   viewport sizes instead of the one I had been tuning on - at 390x844 everything
   looked fine, so "fixed" meant "fitted to my own screen".
   Sizing here is by HEIGHT, not width, because that is the axis that actually runs
   out. Type comes down a step, the gaps close up, and the hero fits with the
   consent banner still over it. Nothing is hidden and nothing is removed.
   ========================================================================== */
@media(max-width:600px) and (max-height:800px){
  .hero-in2{padding-top:82px;padding-bottom:56px}
  .hero .eyebrow{font-size:.74rem;letter-spacing:.14em;margin-bottom:.6rem}
  .hero h1{font-size:1.95rem;line-height:1.08}
  .hero .lede{font-size:.98rem;margin-bottom:1.05rem}
  .hero .btn-lg{padding:.82em 1.5em;font-size:.98rem}
  .hero-cta{gap:.6rem;margin-bottom:1rem}
  .hero-trust{gap:.9rem;row-gap:.45rem}
  .hero-trust li{font-size:.88rem}
  .hero-trust strong{font-size:1.3rem}
}

/* Second tier for the genuinely small ones - 375x667 and anything shorter. A 667px
   viewport minus a 68px nav minus a 114px consent banner leaves 485px for a headline,
   a sub, two buttons and three stats, so the type has to come down another step.
   ⛔ padding-top stays at/above ~76px at every tier: below that the eyebrow slides
   under the fixed nav, which is a worse defect than the one being fixed. */
/* The 4-inch floor (320x568 - iPhone 5/SE first gen). 568px minus a 68px nav minus
   the 132px consent band leaves 368px, which will not hold a headline, a sub, two
   buttons AND three stats however small the type gets. So while the banner is up, the
   proof strip stands down rather than being half-covered by it - and it comes straight
   back the moment they answer, because the rule is on consent-pending. Nothing is
   permanently removed, and the same three figures sit in the trust strip just below
   the hero either way. A visible half-covered stat is worse than one that waits. */
@media(max-width:600px) and (max-height:600px){
  /* the credentials line wraps to two lines at 320px wide, so it costs 48px here -
     more than the 26px still needed once the stats stand down. Same deal: it is back
     the instant they answer. */
  html.consent-pending .hero-trust,
  html.consent-pending .hero .eyebrow{display:none}
}

@media(max-width:600px) and (max-height:700px){
  .hero h1{font-size:1.72rem}
  .hero .lede{font-size:.92rem;margin-bottom:.75rem}
  .hero-cta{margin-bottom:.6rem}
  .hero-trust li{font-size:.84rem}
  .hero-trust strong{font-size:1.2rem}
}

/* ============================================================================
   SUB-PAGE FURNITURE + THE AREAS PAGE          added 28 Aug 2026
   Everything below is additive: new class names only, nothing above is
   redefined, so index.html renders byte-identically to before.
   ========================================================================== */

/* --- page header, the sub-page stand-in for the homepage hero --- */
/* ⛔ Solid colour FIRST, gradient over it. The gradient alone left background-color
   transparent, so white h1 text resolved against the near-white body: measured
   1.07:1 by build_function_test, and genuinely invisible anywhere the gradient does
   not paint (print, forced-colors, an image-blocking client). The solid base is the
   real fix, not a way to quiet the gate. */
.phead{background-color:var(--espresso);
  background-image:linear-gradient(160deg,var(--espresso),var(--walnut) 62%,var(--walnut-2));
  color:#fff;padding:calc(var(--header-h) + 2.6rem) 0 2.6rem;padding:calc(var(--header-h) + clamp(2.6rem,6vw,4.6rem)) 0 clamp(2.6rem,6vw,4.4rem)}
.phead-in{max-width:760px}
.phead h1{font-size:2.1rem;font-size:clamp(2.1rem,5.4vw,3.5rem);color:#fff;margin:.2rem 0 0}
.phead h1 em{font-style:normal;color:var(--oak-soft);display:block}
.phead .eyebrow{font-family:var(--sans);font-weight:600;font-size:.76rem;letter-spacing:.16em;
  text-transform:uppercase;color:var(--oak-soft);margin:0}
.phead .lede{color:rgba(255,255,255,.82);font-size:1.02rem;font-size:clamp(1.02rem,2vw,1.2rem);max-width:56ch;margin:1rem 0 0}
.phead .hero-cta{margin-top:1.7rem;display:flex;gap:.8rem;flex-wrap:wrap}
.crumb{font-size:.84rem;color:rgba(255,255,255,.62);margin-bottom:1.1rem}
/* inline-block + vertical padding so the tap target clears 24px (WCAG 2.2 SC
   2.5.8). It measured 38x17 - the only genuinely too-small thing you can tap on
   the site, and it is the "back to the homepage" link on every inner page. */
.crumb a{color:rgba(255,255,255,.86);text-decoration:none;border-bottom:1px solid rgba(255,255,255,.3);
  display:inline-block;padding:.8rem .25rem;margin:-.8rem -.25rem}  /* [21 Sep] 44px tap area; the negative margin keeps the crumb where it was */
.crumb a:hover{color:#fff;border-bottom-color:var(--oak-soft)}
.crumb span{margin:0 .35rem}

/* --- the patch: map beside the checker --- */
.patch-sec{padding:3.4rem 0;padding:clamp(3.4rem,7vw,5.6rem) 0;background:var(--cream)}
.patch-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:1.6rem;gap:clamp(1.6rem,3vw,3rem);align-items:start}
.patch-mapwrap{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);
  padding:.9rem;padding:clamp(.9rem,2vw,1.5rem);box-shadow:var(--sh)}
.patch-map{width:100%;height:auto;display:block;font-family:var(--sans)}

.patch-area{fill:rgba(183,125,69,.10);stroke:var(--oak);stroke-width:2.4;stroke-dasharray:10 7;
  stroke-linejoin:round;opacity:.85}
.patch-hit{fill:transparent;cursor:pointer}
.patch-dot{fill:var(--oak);stroke:var(--paper);stroke-width:2.5;pointer-events:none;
  transition:r .18s var(--ease),fill .18s var(--ease)}
.patch-lbl{font-size:17px;font-weight:500;fill:var(--ink);pointer-events:none}
.patch-leader{stroke:var(--line);stroke-width:1.5;pointer-events:none}
.patch-pin.is-home .patch-dot{fill:var(--espresso)}
.patch-pin.is-home .patch-lbl{font-weight:700}
.patch-pin.is-out .patch-dot{fill:#B9AA97}
.patch-pin.is-out .patch-lbl{fill:var(--muted)}
.patch-x{stroke:#9A3B2E;stroke-width:2.6;stroke-linecap:round;pointer-events:none}
.patch-pin:hover .patch-dot,.patch-pin:focus-visible .patch-dot{r:9.5;fill:var(--oak-dark)}
.patch-pin.is-sel .patch-dot{r:11;fill:var(--espresso)}
.patch-pin.is-sel .patch-lbl{font-weight:700}
.patch-pin:focus{outline:none}
.patch-pin:focus-visible .patch-hit{stroke:var(--espresso);stroke-width:2.5;fill:rgba(183,125,69,.12)}

/* ---- THE LAND AND WATER UNDER THE PINS  [2026-09-07, Matt asked for a real map]
   Drawn in the map's OWN projection, fitted by least squares from the 19 town pins
   already on it. The fit produced 19.02px per mile; the scale bar drawn on the map
   says 19.04. Two independent numbers agreeing is why the coast registers with the
   towns. ⛔ If any pin cx/cy is ever edited, this coastline no longer lines up -
   refit rather than nudging it by eye.
   ⛔ pointer-events:none on ALL of it. The pins' 24px hit circles sit above this and
   must stay clickable; a filled background path would swallow them. */
.patch-sea{fill:rgba(33,27,22,.055);pointer-events:none}
.patch-coast{fill:none;stroke:rgba(33,27,22,.22);stroke-width:1.6;
  stroke-linejoin:round;stroke-linecap:round;pointer-events:none}
.patch-river{fill:none;stroke:rgba(33,27,22,.16);stroke-width:1.4;
  stroke-linejoin:round;stroke-linecap:round;pointer-events:none}
.patch-road{fill:none;stroke:rgba(183,125,69,.32);stroke-width:1.6;
  stroke-linejoin:round;stroke-linecap:round;pointer-events:none}
.patch-sea-t{font-size:13px;letter-spacing:.14em;text-transform:uppercase;
  fill:rgba(33,27,22,.32);pointer-events:none}
.patch-road-t{font-size:12px;letter-spacing:.06em;font-weight:500;
  fill:rgba(140,95,52,.85);pointer-events:none}
.patch-scale{stroke:var(--muted);stroke-width:2}
.patch-scale-t{font-size:15px;fill:var(--muted)}

.patch-key{display:flex;flex-wrap:wrap;gap:.5rem 1.2rem;align-items:center;margin:1rem 0 0;
  font-size:.88rem;color:var(--muted)}
.patch-key .k{width:11px;height:11px;border-radius:50%;display:inline-block;margin-right:.3rem;
  vertical-align:-1px}
.k-home{background:var(--espresso)}.k-on{background:var(--oak)}.k-off{background:#B9AA97}

/* --- "are you in the patch?" --- */
.patch-check{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);
  padding:1.3rem;padding:clamp(1.3rem,2.6vw,1.9rem);position:sticky;top:calc(var(--header-h) + 16px)}
.patch-check h3{font-size:1.42rem;margin-bottom:.4rem}
.patch-check>p{color:var(--muted);font-size:.97rem}
.check-row{display:flex;gap:.55rem}
.check-row input{flex:1;min-width:0;font:inherit;font-size:1rem;padding:.82rem .95rem;
  border:1px solid var(--line);border-radius:11px;background:#fff;color:var(--ink)}
.check-row input:focus{outline:none;border-color:var(--oak);box-shadow:0 0 0 3px rgba(183,125,69,.18)}
.check-out{margin-top:1rem}
.check-out:empty{margin:0}
.check-out strong{display:block;font-family:var(--serif);font-size:1.18rem;line-height:1.2;margin-bottom:.35rem}
.check-out p{font-size:.96rem;color:var(--muted);margin-bottom:.8rem}
.check-out.is-yes,.check-out.is-no,.check-out.is-ask{border-radius:12px;padding:1rem 1.1rem;border:1px solid}
.check-out.is-yes{background:rgba(183,125,69,.09);border-color:rgba(183,125,69,.4)}
.check-out.is-no{background:rgba(154,59,46,.07);border-color:rgba(154,59,46,.28)}
.check-out.is-ask{background:var(--cream-2);border-color:var(--line)}
.check-note{font-size:.84rem;color:var(--muted);margin:.9rem 0 0}
/* No JS, no working search - so offer the phone instead of a dead box. */
html:not(.js) .check-f,html:not(.js) .check-out{display:none}

/* --- town by town --- */
.towns{padding:3.4rem 0;padding:clamp(3.4rem,7vw,5.6rem) 0}
.town-groups{display:grid;grid-template-columns:repeat(2,1fr);gap:1.2rem;gap:clamp(1.2rem,2.4vw,2rem)}
.tgroup{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);
  padding:1.3rem;padding:clamp(1.3rem,2.4vw,1.8rem);display:flex;flex-direction:column}
.tgroup h3{font-size:1.3rem;margin-bottom:.9rem}
.tlist{list-style:none;padding:0;margin:0 0 .9rem}
.tlist li{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;
  padding:.5rem 0;border-bottom:1px solid var(--line)}
.tlist li:last-child{border-bottom:0}
.tn{font-weight:600}
.tm{font-size:.85rem;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.tnote{font-size:.92rem;color:var(--muted);margin:0 0 1rem}
.tcall{margin-top:auto;align-self:flex-start;font-weight:600;color:var(--oak-dark);
  text-decoration:none;border-bottom:2px solid rgba(183,125,69,.35);padding-bottom:2px}
.tcall:hover{border-bottom-color:var(--oak)}
.tout{margin-top:1.4rem;margin-top:clamp(1.4rem,3vw,2.2rem);background:var(--cream);border:1px solid var(--line);
  border-left:4px solid #9A3B2E;border-radius:var(--r);padding:1.2rem;padding:clamp(1.2rem,2.4vw,1.7rem)}
.tout h3{margin-bottom:.5rem}
.tout p{margin:0;color:var(--muted)}

@media(max-width:960px){
  .patch-grid,.town-groups{grid-template-columns:1fr}
  .patch-check{position:static}
  .patch-mapwrap{order:2}
}
@media(max-width:600px){
  .phead{padding-top:calc(var(--header-h) + 2rem)}
  .phead .hero-cta .btn,
  .hero .hero-cta .btn{flex:1 1 100%;text-align:center}
  .check-row{flex-wrap:wrap}
  .check-row input,.check-row .btn{flex:1 1 100%}
  /* The map carries 19 labels; below this it is easier to read a bit taller. */
  .patch-map{min-height:340px}
}

/* --- service cards with no photo -----------------------------------------
   Three of Matt's eight services have no photograph of that work in the whole
   harvested set - flooring, skirting/architrave, and anything outside. The old
   homepage solved that by putting a media wall photo on the Flooring card, which
   is a picture of the wrong job. These are deliberately text-only instead, and
   styled to look chosen rather than broken: tinted panel, oak rule, content
   centred so they sit level with the taller photo cards beside them.
   When Matt sends photos (asked 28 Aug), give them a .card-img and drop
   .card-text. ------------------------------------------------------------ */
.card-text{background:var(--cream);display:flex;align-items:center}
.card-text .card-b{padding:1.7rem 1.4rem}
.card-text h3{position:relative;padding-top:1rem}
.card-text h3::before{content:"";position:absolute;top:0;left:0;width:34px;height:3px;
  background:var(--oak);border-radius:2px}

/* --- cookie banner: a CHOICE, not a notice ------------------------------- 28 Aug
   Was one "Got it" button beside the text, which is a dismiss notice. Once
   analytics is switched on that is not lawful consent under PECR, so it is now
   Accept / Reject.
   ⛔ THE TWO BUTTONS MUST STAY EQUALLY EASY. Same size, same row, both real
   buttons. Making Reject a faint text link while Accept is a solid pill is exactly
   the pattern the ICO calls out, and it is the tempting "tidy up" here. The text
   also got longer - it has to say what the analytics cookies actually do - so the
   layout stacks instead of sitting on one line. -------------------------------- */
.cookie{flex-direction:column;align-items:stretch;gap:.85rem;max-width:390px}
.cookie-row{display:flex;gap:.6rem}
.cookie-row .btn{flex:1 1 0;white-space:nowrap}
.btn-line{--bg:transparent;--fg:var(--walnut);border-color:var(--walnut)}
.btn-line:hover{background:rgba(58,41,29,.08)}

/* --- contact page: the three ways to reach him -------------------- 28 Aug ---
   Two of the three are anchors (tel:, mailto:) and the third is a plain div,
   because "hours and area" is not something you can tap to do. They are styled
   identically so the row reads as one set, but only the real actions get hover
   lift and a pointer - a card that looks tappable and is not is worse than one
   that looks static. ------------------------------------------------------- */
.ways{padding:3rem 0;padding:clamp(3rem,6vw,4.6rem) 0;background:var(--cream)}
.way-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1.3rem}
.way{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);
  padding:1.4rem;padding:clamp(1.4rem,2.6vw,1.9rem);text-decoration:none;color:inherit;display:block;
  transition:transform .3s var(--ease),box-shadow .3s}
a.way:hover{transform:translateY(-6px);box-shadow:var(--sh)}
.way-static{cursor:default}
/* .78rem = 12.5px. At .72rem these rendered 11.5px, and unlike the logo lockup
   these are real content - the "Quickest" / "When I'm about" labels telling a
   customer how to get hold of Matt. They only showed up once the 135 logo hits
   stopped drowning the report out. */
.way-k{font-family:var(--sans);font-weight:600;font-size:.78rem;letter-spacing:.15em;
  text-transform:uppercase;color:var(--oak-dark);display:block;margin-bottom:.5rem}
.way h2{font-size:1.35rem;margin-bottom:.6rem}
.way-big{font-family:var(--serif);font-size:1.5rem;line-height:1.15;color:var(--ink);
  margin:0 0 .7rem;font-variant-numeric:tabular-nums}
/* The email is long enough to overflow a third-width card at 1440. */
.way-em{font-size:1.02rem;word-break:break-word;font-variant-numeric:normal}
.way p:last-child{color:var(--muted);font-size:.95rem;margin:0}
.way a{color:var(--oak-dark)}

/* --- what happens next --- */
.steps{list-style:none;counter-reset:s;padding:0;margin:0 0 1.6rem}
.steps li{counter-increment:s;position:relative;padding-left:2.8rem;margin-bottom:1rem;
  font-size:.99rem}
.steps li strong{display:block}
/* ⛔ .steps lives inside .quote, which is a DARK walnut section. The first version
   set colour:var(--muted) and strong:var(--ink) - both dark - so the whole list was
   near-invisible on the background it actually sits on. It read fine in isolation.
   These match .contact-list and .sec-sub, which are the neighbours in that section.
   build_function_test only measures H1 contrast, so it passed; the screenshot did
   not. Look at new copy on the background it lands on, not on white. */
.quote .steps li{color:#D9CBB8}
.quote .steps li strong{color:#fff}
.steps li::before{content:counter(s);position:absolute;left:0;top:-2px;width:1.9rem;height:1.9rem;
  border-radius:50%;background:var(--oak);color:#fff;font-family:var(--serif);font-weight:600;
  display:grid;place-items:center;font-size:.95rem}

/* --- reviews page: the score --- */
/* ⛔ SECTION RHYTHM. .score and .reviews were BOTH on the paper background with
   nothing between them, so the gap where one ended and the next began read as dead
   space rather than a break. Down the reviews page it is now
   dark -> cream -> paper -> cream -> dark, which gives every section an edge. */
.score{padding:3rem 0;padding:clamp(3rem,6vw,4.6rem) 0;background:var(--cream)}
.score-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:1.6rem;gap:clamp(1.6rem,4vw,3.4rem);align-items:center}
.score-fig{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);
  padding:1.6rem;padding:clamp(1.6rem,3vw,2.4rem);text-align:center}
.score-n{display:block;font-family:var(--serif);font-weight:600;font-size:3.4rem;font-size:clamp(3.4rem,8vw,5rem);
  line-height:1;letter-spacing:-.03em;color:var(--ink)}
.score-o{display:block;color:var(--muted);font-size:.95rem;margin-top:.2rem}
.score-fig .stars{color:var(--oak);font-size:1.3rem;letter-spacing:.14em;margin:.7rem 0 .3rem}
.score-c{color:var(--muted);font-size:.92rem;margin:0}
.score-t h2{margin-bottom:.8rem}
.score-t p{color:var(--muted)}

@media(max-width:960px){
  .way-grid{grid-template-columns:1fr}
  .score-grid{grid-template-columns:1fr}
}

/* --- FAQ ---------------------------------------------------------- 28 Aug ---
   <details>/<summary>, so it opens with no JS, is keyboard operable for free and
   is findable by the browser's own in-page search. The marker is replaced with a
   drawn chevron because the native triangle differs on every platform. --------- */
.faq{padding:3rem 0;padding:clamp(3rem,6vw,4.6rem) 0;background:var(--cream)}
.faq-list{max-width:760px;margin:0 auto;border-top:1px solid var(--line)}
.fq{border-bottom:1px solid var(--line)}
.fq summary{list-style:none;cursor:pointer;padding:1.15rem 2.6rem 1.15rem 0;position:relative;
  font-family:var(--serif);font-weight:600;font-size:1.12rem;letter-spacing:-.01em;color:var(--ink)}
.fq summary::-webkit-details-marker{display:none}
.fq summary::after{content:"";position:absolute;right:.55rem;top:1.55rem;width:9px;height:9px;
  border-right:2px solid var(--oak);border-bottom:2px solid var(--oak);
  transform:rotate(45deg);transition:transform .25s var(--ease)}
.fq[open] summary::after{transform:rotate(-135deg)}
.fq summary:hover{color:var(--oak-dark)}
.fq summary:focus-visible{outline:2px solid var(--oak);outline-offset:3px;border-radius:6px}
.fq p{margin:0 0 1.15rem;color:var(--muted);padding-right:2.6rem}
.fq a{color:var(--oak-dark)}

/* ⛔ SOLID BUTTONS ON THE DARK SECTIONS. One rule, because this went wrong twice.
   The default .btn is --bg:var(--walnut). On .quote (also walnut) the chip was
   INVISIBLE - "Get a free quote" rendered as bare text. On .phead the same button
   measured 1.23:1 against the header gradient. Neither showed up on the hero,
   whose buttons sit over a photograph.
   ⛔ AND THE FIRST FIX WAS ALSO WRONG: oak fill with white text measured 3.48:1,
   under the 4.5 floor. Fixing the chip broke the label. Cream fill with espresso
   text clears both - 12:1 chip, 14:1 label - and it is the same pairing the nav
   uses when it scrolls. Measured, not eyeballed. */
/* [2026-08-29] `.hero` added. The HOMEPAGE hero CTA was the one primary button on
   the site still using the default walnut fill, over a dark photograph, while the
   same button on all five inner pages is this cream pill. The most important call
   to action on the site was its least visible one. */
.phead .btn:not(.btn-ghost),
.hero .btn:not(.btn-ghost),
.quote .quote-t .btn{--bg:var(--paper);--fg:var(--espresso);border-color:var(--paper)}
.phead .btn:not(.btn-ghost):hover,
.hero .btn:not(.btn-ghost):hover,
.quote .quote-t .btn:hover{--bg:var(--cream-2);border-color:var(--cream-2)}

/* .tout carries a red left border because it was built for the Bristol exclusion -
   a "he won't come here" warning. Reused on the reviews page for "please leave Matt
   a review", the red reads as a problem. Same box, right signal. */
.tout-ok{border-left-color:var(--oak)}
.quote-f .opt{font-weight:400;color:var(--muted);font-size:.86rem}
.quote-f input[type=file]{font:inherit;font-size:.92rem;color:var(--ink);padding:.6rem 0}

/* ============================================================================
   MOTION PASS                                                    28 Aug 2026
   the client: "I don't want everything pinned to the page so nothing happens as the
   user scrolls down except them getting bored of looking at pictures and reading
   text."

   ⛔ HE WAS RIGHT, AND IT WAS MEASURABLE. Before this, the ONLY thing on the
   whole site that responded to scroll was .reveal - one fade-up, opacity plus
   22px, applied 13 to 33 times a page. Every section did the identical thing.
   The stagger rule above existed but only covered .cards, .quotes and .why-grid,
   so every grid added since (the towns, the three ways, the four steps, the FAQ)
   arrived in one lump. And the count-up only ever targeted .hero-trust, so the
   9.95 on the reviews page sat there dead.

   ⛔ RESTRAINT IS THE POINT. This is a Starter build for a carpenter, not a
   showreel - the client brief says "nothing too fancy". So: no parallax, no
   pinning (he explicitly does not want it), nothing that moves while you read.
   Things ARRIVE with intent, then stop.
   ========================================================================== */

/* --- 1. the stagger reaches every grid, not just the original three --------- */
.js .way-grid .reveal,.js .town-groups .reveal,.js .steps li,.js .tlist li,
.js .faq-list .fq,.js .patch li,.js .badges li,.js .gallery .shot{
  transition-delay:calc(var(--i,0) * 70ms)}
.js .steps li,.js .tlist li,.js .faq-list .fq,.js .patch li,.js .badges li{
  opacity:0;transform:translateY(14px);
  transition:opacity .55s var(--ease),transform .55s var(--ease)}
.js .reveal.in .steps li,.js .reveal.in .tlist li,.js .in .fq,
.js .in .patch li,.js .in .badges li,
.js .steps li.in,.js .tlist li.in,.js .fq.in,.js .patch li.in,.js .badges li.in{
  opacity:1;transform:none}
/* index for the first ten children of any staggered list */
.js :where(.way-grid,.town-groups,.steps,.tlist,.faq-list,.patch,.badges) > *:nth-child(1){--i:0}
.js :where(.way-grid,.town-groups,.steps,.tlist,.faq-list,.patch,.badges) > *:nth-child(2){--i:1}
.js :where(.way-grid,.town-groups,.steps,.tlist,.faq-list,.patch,.badges) > *:nth-child(3){--i:2}
.js :where(.way-grid,.town-groups,.steps,.tlist,.faq-list,.patch,.badges) > *:nth-child(4){--i:3}
.js :where(.steps,.tlist,.faq-list,.patch,.badges) > *:nth-child(5){--i:4}
.js :where(.steps,.tlist,.faq-list,.patch,.badges) > *:nth-child(6){--i:5}
.js :where(.tlist,.faq-list,.patch) > *:nth-child(7){--i:6}
.js :where(.tlist,.faq-list,.patch) > *:nth-child(8){--i:7}

/* --- 2. direction, so it is not all "rise 22px" ---------------------------- */
/* Used sparingly and always AWAY from the page centre, so a two-column section
   opens outward instead of both halves doing the same nod. */
.js .reveal-l{transform:translateX(-26px)}
.js .reveal-r{transform:translateX(26px)}
.js .reveal-l.in,.js .reveal-r.in{transform:none}
/* A section header earns a slightly longer, calmer entrance than the cards under it. */
.js .sec-head.reveal{transition-duration:.85s}

/* --- 3. THE MAP OPENS UP AROUND HIM ---------------------------------------- */
/* The one real moment on the areas page, and the only place a signature belongs:
   the patch boundary grows out from Bridgwater and the towns land one by one,
   nearest first. It is his round drawn in front of you. Runs once, then stops. */
.js .patch-map .patch-area{
  opacity:0;transform:scale(.82);transform-box:fill-box;transform-origin:34% 64%;
  transition:opacity .9s var(--ease-out-quart),transform 1.1s var(--ease-out-quart)}
.js .patch-mapwrap.in .patch-area{opacity:.85;transform:none}
.js .patch-map .patch-pin{opacity:0;transition:opacity .5s var(--ease)}
.js .patch-mapwrap.in .patch-pin{opacity:1;transition-delay:calc(.45s + var(--p,0) * 55ms)}
.js .patch-map .patch-scale,.js .patch-map .patch-scale-t{
  opacity:0;transition:opacity .6s var(--ease) 1.5s}
.js .patch-mapwrap.in .patch-scale,.js .patch-mapwrap.in .patch-scale-t{opacity:1}

/* --- 4. nothing moves for anyone who asked it not to ----------------------- */
@media(prefers-reduced-motion:reduce){
  .js .steps li,.js .tlist li,.js .faq-list .fq,.js .patch li,.js .badges li,
  .js .patch-map .patch-area,.js .patch-map .patch-pin,
  .js .patch-map .patch-scale,.js .patch-map .patch-scale-t{
    opacity:1!important;transform:none!important;transition:none!important}
  .js .reveal-l,.js .reveal-r{transform:none}
}

/* --- the recurring words, on the reviews page --------------------- 28 Aug ---
   Its own shape, because it is its own idea. The .why numbered grid it replaced
   was on three pages, which is the thing that makes a site read as a template -
   and its content repeated the homepage's. Here the WORD carries the weight and
   the review fragment underneath proves it, so the evidence is the design. */
.themes{padding:3.4rem 0;padding:clamp(3.4rem,7vw,5.6rem) 0;background:var(--cream)}
.theme-list{list-style:none;padding:0;margin:0;display:grid;
  grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1.4rem;gap:clamp(1.4rem,3vw,2.6rem)}
.theme-list li{position:relative;padding-top:1.5rem;border-top:2px solid var(--oak)}
.theme-w{display:block;font-family:var(--serif);font-weight:600;
  font-size:1.9rem;font-size:clamp(1.9rem,3.4vw,2.6rem);line-height:1;letter-spacing:-.02em;
  color:var(--ink);margin-bottom:.7rem}
.theme-list p{color:var(--muted);font-size:.97rem;margin:0}
@media(max-width:600px){ .theme-list{gap:1.5rem} }

/* --- SECTION RHYTHM, the two places it broke ---------------------- 28 Aug ---
   Written as ADJACENCY rules, not per-page classes, so each one only fires in the
   exact situation it was written for and self-documents which that is.

   ⛔ AREAS PAGE: towns(paper) -> why(dark) -> quote(dark). Two dark sections back
   to back made the bottom third one unbroken slab. The quote block has to stay
   dark, it is the closer, so the .why above it goes light instead. */
.towns + .why{background:var(--cream);color:var(--ink)}
.towns + .why .kicker{color:var(--oak-dark)}
.towns + .why h2{color:var(--ink)}
.towns + .why .sec-sub{color:var(--muted)}
.towns + .why .why-grid li{border-color:var(--line);background:var(--paper)}
.towns + .why .why-grid h3{color:var(--ink)}
.towns + .why .why-grid p{color:var(--muted)}
.towns + .why .wn{color:var(--oak-dark)}

/* ⛔ CONTACT PAGE: faq(cream) -> areas(cream) -> trust(cream). Three the same in a
   row, so the whole tail of the page read as one block with no edges. */
.faq + .areas{background:var(--paper)}
/* ⛔ AND THE TEXT WITH IT. Re-tinting .trust to cream and leaving its colours alone
   put cream text on a cream band - measured 1.06:1, and 1.17:1 for the <strong> and
   the badges. The strongest proof line on the site rendered as a blank strip, on the
   page most likely to convert, at both widths. THIRD time today I recoloured a
   background and forgot what sits on it (.steps, the buttons, now this). Recolour a
   background, re-measure every child. */
/* [2026-08-29] was three near-identical creams in a row, 8 points apart. One field. */
.faq + .areas,.faq + .areas + .trust{background:var(--cream);color:var(--ink)}
.faq + .areas + .trust p{color:var(--muted)}
.faq + .areas + .trust strong{color:var(--ink)}
.faq + .areas + .trust .badges li{color:var(--walnut);border-color:var(--line);
  background:var(--paper)}
/* oak on cream measured 2.96:1 and oak-dark 4.43 - both under the floor even for a
   decorative row. walnut clears it without changing the character. */
.faq + .areas + .trust .stars{color:var(--walnut)}
/* ⛔ HOMEPAGE: services(paper) -> work(paper). The gallery sat straight on the end
   of the service cards with no edge between them. Cream behind photographs is also
   simply better than white. */
.services + .work{background:var(--cream)}

/* The gallery was the one grid the stagger missed - eight photos arriving as a
   single block, on the most visual section of the site. */
.js .gallery .shot{opacity:0;transform:translateY(16px);
  transition:opacity .55s var(--ease),transform .55s var(--ease)}
.js .gallery.in .shot{opacity:1;transform:none}
.js .gallery > *:nth-child(1){--i:0}.js .gallery > *:nth-child(2){--i:1}
.js .gallery > *:nth-child(3){--i:2}.js .gallery > *:nth-child(4){--i:3}
.js .gallery > *:nth-child(5){--i:4}.js .gallery > *:nth-child(6){--i:5}
.js .gallery > *:nth-child(7){--i:6}.js .gallery > *:nth-child(8){--i:7}
@media(prefers-reduced-motion:reduce){
  .js .gallery .shot{opacity:1!important;transform:none!important;transition:none!important}}

/* #5 - the h2 and the first numbered step were flush at exactly 0px, so the "1"
   chip sat in the descender of "steps". Every other section head has air. */
.quote-t h2 + .steps{margin-top:1.5rem}

/* .tout is a light box that can sit inside a DARK section, so it has to state its
   own text colours rather than inherit the section's. Its h3 measured 1.0:1 -
   cream on cream - on reviews.html. */
.tout,.tout h3,.tout p{color:var(--ink)}
.tout p{color:var(--muted)}
.tout a{color:var(--oak-dark)}

/* Credentials row: public liability and the trade qualification as highlighted cards. */
/* ⛔ .trust-in is a CENTRED FLEX ROW, so as a flex child this grid sized itself to
   content and wrapped 3 + 1, leaving one card orphaned on its own line. It needs to
   claim a full row of its own, and four equal columns rather than auto-fit - four
   credentials should read as a set, not as "three and a spare". */
/* ⛔ These are CREDENTIALS, and they have to LOOK like credentials - a set of
   marks you can take in at a glance, not four paragraphs in boxes. the client, 28 Aug,
   on the live site: "the bit underneath listing his accreditations is just bullet
   points? Surely we can do better than that". He was right twice over: his phone
   was serving a stale stylesheet so they had no styling at all (see the ?v= note
   in index.html), and even styled they were four identical slabs of prose.
   So: a mark, a short label, ONE short line. Two-up on a phone, because a 2x2 of
   badges reads as a set and a 1-up column reads as a list. */
.creds-row{list-style:none;padding:0;margin:1.5rem 0 0;flex:1 0 100%;width:100%;
  display:grid;grid-template-columns:repeat(4,1fr);gap:.9rem;text-align:left}
@media(max-width:960px){ .creds-row{grid-template-columns:repeat(2,1fr)} }
.creds-row li{padding:1.15rem 1.1rem;border-radius:var(--r);
  border:1px solid rgba(243,236,224,.16);background:rgba(243,236,224,.045)}
.cred-i{display:block;width:26px;height:26px;margin-bottom:.75rem;
  fill:none;stroke:var(--oak);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
.creds-row strong{display:block;font-family:var(--serif);font-weight:600;
  font-size:1rem;line-height:1.2;margin-bottom:.3rem;color:inherit}
.creds-row span{display:block;font-size:.83rem;line-height:1.45;opacity:.75}
/* the light variant on contact */
.faq + .areas + .trust .creds-row li{border-color:var(--line);background:var(--paper)}
/* Two-up, tighter, on a phone - never one-up. */
/* The smallest phone still in real use. The form's own padding was part of what
   burst the grid track, so it comes in on the narrowest screens. */
@media(max-width:360px){
  .quote-f{padding:1.25rem}
  .creds-row{gap:.5rem}
  .creds-row li{padding:.8rem .75rem}
}
@media(max-width:600px){
  .creds-row{grid-template-columns:1fr 1fr;gap:.6rem;margin-top:1.25rem}
  .creds-row li{padding:.9rem .85rem;border-radius:var(--r)}
  .cred-i{width:22px;height:22px;margin-bottom:.55rem}.cred-badge{width:auto;max-width:100%;object-fit:contain;display:block;margin:0}
.cred-badge--cat{height:46px}
.cred-badge--cg{height:30px}
/* Mobile sizes Jordon signed off, and they must come AFTER the base rules: same
   specificity, so source order decides. Put inside the earlier max-width:600px block
   they lost to the base and the phone silently rendered the desktop size. */
@media(max-width:600px){.cred-badge--cat{height:68px}.cred-badge--cg{height:42px}}
/* The two real logos sit on PURE WHITE, not the cream the other cards use.
   Both marks are drawn for white: on #FBF7F0 the Checkatrade navy reads almost
   black and the City & Guilds wordmark goes muddy, which is exactly what was
   wrong with them. Centred, no caption - a logo that needs labelling is not
   doing its job. Same height for both so the row reads as a set despite one
   being square and the other landscape. */
.creds-row li.cred-logo{display:flex;align-items:center;justify-content:flex-start;min-height:96px;padding:1rem .9rem;overflow:hidden}
  .creds-row strong{font-size:.9rem}
  .creds-row span{font-size:.76rem;line-height:1.4}
}
/* [2026-09-13] MOBILE: centre all four credential cards - icon, text and logo.
   Jordon: the two badges sit on a second row under the two icon cards, and left-aligned
   they read as four separate things rather than one block. Centred, the four line up.
   ⛔ Placed AFTER the base rules deliberately. Same specificity, so source order decides,
   and earlier today a mobile override written into a block further up the file lost to
   the base and silently never applied. Desktop keeps its left alignment. */
@media(max-width:600px){
  .creds-row li{text-align:center}
  .creds-row li .cred-i{margin-left:auto;margin-right:auto}
  .creds-row li.cred-logo{justify-content:center}
}
/* old-phone floor fallbacks (css_floor_fix.py) */
@supports (aspect-ratio: 1){.card-img{aspect-ratio:16/11}.about-img img{aspect-ratio:4/5}}
@supports not (aspect-ratio: 1){.card-img{height:0;padding-top:68.75%}.about-img img{height:0;padding-top:125%}}
@supports not (inset: 0){/* buttons */ .btn > * + *{margin-left:.5em}.nav-in > * + *{margin-left:1.5rem}.brand > * + *{margin-left:12px}.links > * + *{margin-left:1.7rem}.hero-cta > *{margin:0 .9rem .9rem 0}.hero-trust > *{margin:0 2.2rem 2.2rem 0}.trust-in > *{margin:0 1.6rem 1.6rem 0}.badges > *{margin:0 .7rem .7rem 0}.filters > *{margin:0 .6rem .6rem 0}.creds > *{margin:0 .6rem .6rem 0}.contact-list li > * + *{margin-left:1rem}/* [2026-08-30, review] The credit, second attempt. The first put it on its own centred line above this bar, and he was right that it looked bolted on: on a phone every other line in the footer is left-aligned and hard against the gutter, so a centred line floating above them reads as something added later. "like it was there from day one." So it is not its own row any more - it is the last item of the small-print line that already existed, after the legal links and behind the same separator they use. That line is where a site's housekeeping lives, which is exactly what a build credit is. On desktop it sits at the end of that group on the right; on a phone the whole bar stacks left-aligned on one gutter with an even rhythm, so nothing floats. */ .foot-btm > *{margin:0 1.5rem .5rem 0}.foot-btm .legal > *{margin:0 .4rem .4rem 0}/* ⛔ RIGHT, not left, on desktop. Bottom-left sits directly over the hero proof strip - measured on first load at 1440: banner x16-406 y726-884 against "9.95/10 rated" at x262-379 y727-766, and elementFromPoint on the stat returned the banner. The whole right half of the hero is empty, so this costs nothing and needs no change to the consent copy. The phone case is different and handled in the <=600 query: there the banner is full-width above the sticky call bar, and it does still overlap the stats, which is a known trade rather than an oversight. */ .cookie > * + *{margin-left:1rem}.cookie-row > * + *{margin-left:.5rem}.mobmenu.open > * + *{margin-top:.2rem}/* the button inherited .mobmenu a's dark-on-cream link colour, so cream text sat on a cream background and all but vanished. Pin both explicitly. */ .mobmenu .mob-call > * + *{margin-left:.5rem}.phead .hero-cta > *{margin:0 .8rem .8rem 0}.patch-key > *{margin:0 1.2rem .5rem 0}.check-row > * + *{margin-left:.55rem}.tlist li > * + *{margin-left:1rem}.cookie-row > * + *{margin-left:.6rem}}
