/* ==========================================================================
   Catrigg Retrievers
   One hand-written stylesheet. Mobile-first. No framework, no preprocessor.

   Proportions, colours and type sizes come from the approved design pass at
   ...\CatRigg Homepage Design\Catrigg Retrievers.dc.html. That file styles
   everything with inline attributes and a JS breakpoint at 1080px; the values
   are carried across here as real tokens and real media queries.

   Two rules from the design that are easy to break by accident:
     - There is NO box-shadow anywhere on this site. Depth comes from a 1px
       hairline and white-on-sand contrast. Adding one will look wrong.
     - There are exactly three radii: 999px, 16px, 10px.
   ========================================================================== */

/* --- Tokens -------------------------------------------------------------- */

:root {
  /* The palette. Five values plus white, and no sixth. */
  --sand:     #F5EFE6;   /* warm bone, page background */
  --forest:   #1F3A2E;   /* deep forest green, footer and dark bands */
  --fox:      #AB6238;   /* fox red, from the dogs' coats. Accent only */
  --fox-dark: #8D4E2C;   /* hover and active on --fox only */
  --ink:      #1A1A1A;   /* near-black, body text */
  --white:    #FFFFFF;

  /* --fox measures 4.63:1 ON WHITE — but the page background is --sand, where it
     is only 4.05:1 and FAILS AA for normal text. That mattered: this comment
     previously claimed it passed, having been measured against the wrong
     backdrop, and every inline link on the site inherited the mistake.
     So --fox is for LARGE text, white-on-fox buttons and decoration; inline
     links use --fox-dark, which is 5.64:1 on sand.
     #B0663A, which looks almost identical, is worse again. Do not "correct" it. */

  /* Text on sand. The opacities are contrast-critical, not taste:
     0.61 is the lowest that clears 4.5:1 on sand (0.55 gave 3.75). */
  --ink-body:    rgba(26, 26, 26, 0.82);
  --ink-muted:   rgba(26, 26, 26, 0.70);
  --ink-caption: rgba(26, 26, 26, 0.61);

  /* Text on forest */
  --sand-body:  rgba(245, 239, 230, 0.82);
  --sand-muted: rgba(245, 239, 230, 0.62);
  /* 0.56 is the lowest that clears 4.5:1 on forest (0.50 gave 3.94). */
  --sand-label: rgba(245, 239, 230, 0.56);
  --sand-link:  rgba(245, 239, 230, 0.88);

  /* Hairlines */
  --rule:        rgba(31, 58, 46, 0.14);
  --rule-strong: rgba(31, 58, 46, 0.25);
  --rule-card:   rgba(31, 58, 46, 0.10);
  --rule-ondark: rgba(245, 239, 230, 0.16);
  --rule-onhero: rgba(255, 255, 255, 0.22);

  /* Layout */
  /* Raised from 1280px on 24 August 2026. On a 1850px screen the old value left
     about 290px of empty sand down each side while the hero photograph behind it
     ran edge to edge, so the page read as a narrow column laid on a wide picture.

     NOT full-bleed, and deliberately. The hero headline and the prose pages need
     a line length a person can read; `.wrap.prose > *` caps copy at 68ch so the
     long documents are unaffected either way, but the hero subline and the card
     grids take their width from here directly.

     One thing depends on this number rather than merely responding to it: the
     seven checklist cards resolved to 4 then 3 purely because the container was
     1232px wide. See `.pillars--steps` below, which now pins that. */
  --wrap: 1600px;
  --gutter: 20px;
  --section-y: 64px;

  /* Radii — these three and no others */
  --radius-pill: 999px;
  --radius-card: 16px;
  --radius-tile: 10px;

  /* Type */
  --display: 'Fraunces', Georgia, 'Times New Roman', serif;
  --body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;

  /* Reviews carousel: how many cards fill the track at this width. One on a
     phone — two side by side at 390px is about 175px a card, which turns a
     hundred-word review into a tall thin column. JS reads this same variable,
     so the breakpoints live here and nowhere else. */
  --per-page: 1;
}

@media (min-width: 40em) {   /* 640px */
  :root { --gutter: 24px; --section-y: 80px; --per-page: 2; }
}

@media (min-width: 56.25em) { /* 900px */
  :root { --section-y: 88px; }
}

@media (min-width: 67.5em) {  /* 1080px */
  :root { --section-y: 96px; }
}

/* --- Reset --------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Anchored dog blocks must clear the sticky header. */
  scroll-padding-top: 84px;
  overflow-x: clip;
}

body {
  margin: 0;
  font-family: var(--body);
  font-size: 1.0625rem;
  line-height: 1.65;
  color: var(--ink);
  background: var(--sand);
  -webkit-font-smoothing: antialiased;
  /* clip, NEVER hidden. `overflow-x: hidden` on html/body/main silently forces
     overflow-y:auto, which creates a nested scroll container and makes mobile
     scrolling sticky and janky. House standard 2. */
  overflow-x: clip;
}

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

h1, h2, h3, h4 {
  font-family: var(--display);
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.015em;
  margin: 0 0 0.5em;
  text-wrap: balance;
}

p { margin: 0 0 1.15em; }
p:last-child { margin-bottom: 0; }

/* --fox-dark, not --fox: inline links sit on --sand, where --fox is 4.05:1 and
   fails AA. --fox-dark is 5.64:1 and is already in the palette, so no new colour
   enters the site. Hover goes to --ink because --fox-dark is now the resting
   colour and hover still has to change visibly. */
a { color: var(--fox-dark); text-decoration: none; }
@media (hover: hover) and (pointer: fine) {
  a:hover { color: var(--ink); }
}

ul, ol { margin: 0 0 1.15em; padding-left: 1.25em; }
li { margin-bottom: 0.4em; }

/* Focus is visible on every interactive element, everywhere, always. */
:focus-visible {
  outline: 2px solid var(--fox);
  outline-offset: 3px;
  border-radius: 2px;
}

/* Reversed ring on the dark bands, where fox red has too little contrast. */
.band-forest :focus-visible,
.site-footer :focus-visible,
.site-header:not(.is-scrolled) :focus-visible,
.hero :focus-visible {
  outline-color: var(--sand);
}

/* Utilities.
   These exist so that no page needs a style="" attribute. The Content-Security-
   Policy is `style-src 'self'` with no 'unsafe-inline', which blocks inline
   style attributes outright — a page that uses one loses that styling silently
   in production while looking fine in a local preview without the header. */
.pt-0        { padding-top: 0; }
.mb-0        { margin-bottom: 0; }
.mt-1        { margin-top: 8px; }
.mt-3        { margin-top: 20px; }
.mt-4        { margin-top: 28px; }
.mt-5        { margin-top: 36px; }
.measure     { max-width: 68ch; }
.measure-md  { max-width: 56rem; }
.on-forest   { color: var(--sand-body); }
.on-forest a { color: var(--sand); text-decoration: underline; }

/* ANY link on a dark band, not just those inside .on-forest. Moving links to
   --fox-dark improved them on sand and made them WORSE on forest — 1.91:1 for
   the terms link in the homepage steps. A link should not have to be wrapped in
   a utility class to be readable on the band it is standing on. */
.band-forest a:not(.btn),
.site-footer a:not(.btn),
.mobile-menu a:not(.btn) { color: var(--sand); }
@media (hover: hover) and (pointer: fine) {
  .band-forest a:not(.btn):hover,
  .site-footer a:not(.btn):hover,
  .mobile-menu a:not(.btn):hover { color: var(--white); }
}

/* …except inside a LIGHT card that happens to sit on the dark band. The rule
   above turned the phone number in the enquiry form sand-on-white, 1.14:1 —
   caught by re-running the audit after the fix rather than assuming it worked.
   Changing a container's colour reassigns everything inside it, in both
   directions; this is the third time that has bitten on this build. */
.band-forest .panel a:not(.btn),
.band-forest .form-card a:not(.btn),
.band-forest .pillar a:not(.btn) { color: var(--fox-dark); }
@media (hover: hover) and (pointer: fine) {
  .band-forest .panel a:not(.btn):hover,
  .band-forest .form-card a:not(.btn):hover,
  .band-forest .pillar a:not(.btn):hover { color: var(--ink); }
}

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

.skip-link {
  position: absolute; left: 12px; top: -60px; z-index: 200;
  background: var(--fox); color: var(--white);
  padding: 12px 18px; border-radius: var(--radius-tile);
  font-weight: 600; transition: top 160ms ease;
}
.skip-link:focus { top: 12px; color: var(--white); }

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

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

.section { padding-block: var(--section-y); }
.section--tight { padding-block: calc(var(--section-y) * 0.7); }

.band-forest {
  background: var(--forest);
  color: var(--sand-body);
}
.band-forest h2, .band-forest h3 { color: var(--sand); }

.section-head { max-width: 44rem; margin-bottom: 2.5rem; }
.section-head p { color: var(--ink-muted); margin-bottom: 0; }
.band-forest .section-head p { color: var(--sand-body); }

/* --- Type scale ---------------------------------------------------------- */

/* Sized for the current headline, which is 54 characters — "Fox Red Golden
   Retriever Puppies Raised for Family Life". The previous settings were built
   for "Puppies Made Perfect" at 20 characters: a 16ch measure and a 5rem
   ceiling would stack this one into five lines and swamp the photograph. */
.h-hero {
  font-size: clamp(2.25rem, 4.6vw, 3.75rem);
  line-height: 1.05;
  letter-spacing: -0.02em;
  max-width: 22ch;
}
h1, .h1 { font-size: clamp(2.25rem, 4.5vw, 3.5rem); }
h2, .h2 { font-size: clamp(1.75rem, 3vw, 2.5rem); }
h3, .h3 { font-size: 1.375rem; }

.lede {
  font-size: 1.1875rem;
  color: var(--ink-body);
  max-width: 60ch;
}

.caption, .meta {
  font-family: var(--body);
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  color: var(--ink-caption);
}
.band-forest .meta { color: var(--sand-label); }

.prose { max-width: 68ch; }
.prose p, .prose li { color: var(--ink-body); }
/* `.on-forest` is (0,1,0) and LOSES to `.prose p` at (0,1,1), so a
   <p class="on-forest"> inside .prose rendered dark ink on the forest band —
   1.34:1, found by the contrast audit on the five-star-licence guide. These
   selectors out-specify it. Same failure as the form labels and the checklist
   hints: a light-background colour reaching into a dark band. */
.prose .on-forest,
.prose .on-forest p,
.prose .on-forest li { color: var(--sand-body); }
.prose h2 { margin-top: 2em; }
.prose h3 { margin-top: 1.6em; }
.prose > :first-child { margin-top: 0; }

/* `.prose` caps the reading measure. It must NEVER also behave as a page
   container. Put both classes on one element and it inherits `margin-inline:
   auto` from `.wrap` while `.prose` shrinks the width to 68ch — so the text
   column centres itself in the viewport, 320px adrift of the `<h1>` sitting in
   a plain `.wrap` above it. That is what "the title is offset and the text is
   centered" was: block-level centring, not `text-align`.

   Restore the container width and cap the children instead, so the copy sits
   flush left under its own heading. Every other narrow container on the site
   (`.lede`, `.section-head`, `.faq details > div`) caps width WITHOUT auto
   margins, which is why only `wrap prose` looked wrong.

   scripts/check-links.mjs fails the build if the combination reappears. */
.wrap.prose { max-width: var(--wrap); }
.wrap.prose > * { max-width: 68ch; }

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

.btn {
  display: inline-block;
  font-family: var(--body);
  font-size: 1rem;
  font-weight: 600;
  line-height: 1;
  padding: 16px 30px;
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  cursor: pointer;
  transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease;
}

.btn--primary { background: var(--fox); color: var(--white); }
@media (hover: hover) and (pointer: fine) {
  .btn--primary:hover { background: var(--fox-dark); color: var(--white); }
}

/* Dark-band ghost: sand text on a sand hairline. Correct on the hero and on
   .band-forest, INVISIBLE on the sand page background — hence the light
   variant below, which the collage uses. */
.btn--ghost {
  background: transparent;
  color: var(--sand);
  border-color: rgba(245, 239, 230, 0.45);
}
@media (hover: hover) and (pointer: fine) {
  .btn--ghost:hover { background: rgba(245, 239, 230, 0.12); color: var(--sand); }
}

/* Same button on the sand page background. Ink on a forest hairline. */
.btn--outline {
  background: transparent;
  color: var(--ink);
  border-color: var(--rule-strong);
}
@media (hover: hover) and (pointer: fine) {
  .btn--outline:hover { background: rgba(31, 58, 46, 0.06); color: var(--ink); }
}

.btn--sm { padding: 12px 22px; font-size: 0.9375rem; }

/* A text link with a rule under it, used to end a section. */
/* --fox-dark for the same reason as `a`: --fox is 4.05:1 on sand. This rule set
   its own colour, so it did not inherit the link fix and had to be changed too —
   the audit caught it still failing after the first pass. */
/* --- The five buyer guides, listed ---------------------------------------- *

   4 Oct 2026, from the SEO review: all five guides were unindexed and neither the
   homepage nor /availability linked to any of them except the word "Guides" in
   the footer, which pointed at one of the five.

   A hairline list rather than cards. Cards would need a sentence each to not look
   empty, and the brief was explicit that no new copy gets written beyond the
   heading: the anchor text is each guide's own H1, which is the whole point.

   Rows are 56px tall at the default font size, comfortably past the 44px target
   size this build holds itself to, and the whole row is the hit area rather than
   just the words. */
.guide-links {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr;
  border-top: 1px solid var(--rule-strong);
}
@media (min-width: 56.25em) {
  .guide-links { grid-template-columns: 1fr 1fr; column-gap: 48px; }
}
.guide-links a {
  display: block;
  padding: 17px 0;
  border-bottom: 1px solid var(--rule);
  color: var(--ink);
  font-size: 1.0625rem;
  text-decoration: none;
}
/* The arrow is a pseudo-element so it is not read out as a character, and it is
   the only decoration on the row. */
.guide-links a::after {
  /* A no-break space, not a plain one. With a plain space the arrow wrapped onto
     a line of its own under "puppies in the UK" at 390px. */
  content: '\00a0\2192';
  color: var(--fox-dark);
}
@media (hover: hover) and (pointer: fine) {
  .guide-links a:hover { color: var(--fox-dark); }
}

.link-more {
  display: inline-block;
  position: relative;
  font-weight: 500;
  color: var(--fox-dark);
  border-bottom: 1px solid rgba(141, 78, 44, 0.45);
  padding-bottom: 3px;
}
/* 32px tall on its own. The underline is a border hugging the text, so padding
   would push it away — this grows the TAP AREA instead and leaves the type
   exactly where it was. */
.link-more::after {
  content: '';
  position: absolute;
  left: 0; right: 0;
  top: 50%; transform: translateY(-50%);
  height: 44px;
}
@media (hover: hover) and (pointer: fine) {
  .link-more:hover { color: var(--ink); border-bottom-color: var(--ink); }
}

/* --- Header -------------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  transition: background-color 300ms ease, box-shadow 300ms ease;
  background: linear-gradient(to bottom, rgba(20, 26, 22, 0.55), rgba(20, 26, 22, 0));
}
/* Once past the hero the bar becomes solid. Pages without a hero start solid. */
.site-header.is-scrolled,
.site-header--solid {
  background: var(--forest);
  box-shadow: 0 1px 0 rgba(245, 239, 230, 0.12);
}

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 16 on a phone, 24 from 900 up. The narrow value buys 8px for the wordmark,
     which is what keeps "Catrigg Retrievers" on one line now that the Instagram
     mark shares the right-hand end with the Menu button. */
  gap: 16px;
  padding-block: 16px;
}
@media (min-width: 56.25em) {
  .site-header__inner { gap: 24px; }
}

.wordmark { display: block; color: var(--sand); }
@media (hover: hover) and (pointer: fine) {
  .wordmark:hover { color: var(--sand); }
}
.wordmark__name {
  font-family: var(--display);
  font-weight: 600;
  font-size: 1.25rem;
  letter-spacing: -0.01em;
  line-height: 1.1;
}
.wordmark__sub {
  display: block;
  font-size: 0.6875rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  opacity: 0.7;
  margin-top: 2px;
}

.site-nav { display: none; }

/* :not(.btn) matters. Without it this rule out-specifies .btn--primary and is
   later in the file, so the header's own "Enquire for a puppy" button rendered
   sand at 88% on fox — 3.52:1, failing AA. It only shows at desktop widths,
   which is why it survived: the nav is hidden on phones. */
.site-nav a:not(.btn) {
  font-size: 0.9375rem;
  font-weight: 500;
  color: var(--sand-link);
}
.site-nav a:not(.btn)[aria-current='page'] { color: var(--sand); }
@media (hover: hover) and (pointer: fine) {
  .site-nav a:not(.btn):hover { color: var(--sand); }
}

.nav-toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: transparent;
  border: 1px solid rgba(245, 239, 230, 0.35);
  color: var(--sand);
  font-family: var(--body);
  font-size: 0.9375rem;
  font-weight: 500;
  /* 12px, not 9px: this is the only way into the menu on a phone and it
     measured 75x39. 44 is the minimum comfortable tap target. */
  padding: 12px 16px;
  border-radius: var(--radius-pill);
  cursor: pointer;
}

/* Full-screen overlay menu on mobile. */
.mobile-menu {
  position: fixed;
  inset: 0;
  z-index: 100;
  background: var(--forest);
  /* env() resolves to 0 unless the page opts in with viewport-fit=cover, which
     build-pages.mjs now writes into every viewport meta. Without that this
     would look like a fix and do nothing. */
  padding: calc(24px + env(safe-area-inset-top)) calc(24px + env(safe-area-inset-right))
           calc(24px + env(safe-area-inset-bottom)) calc(24px + env(safe-area-inset-left));
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}
.mobile-menu[hidden] { display: none; }
.mobile-menu__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 40px;
}
.mobile-menu nav { display: flex; flex-direction: column; gap: 4px; }
.mobile-menu nav a {
  font-family: var(--display);
  font-size: 1.75rem;
  font-weight: 600;
  color: var(--sand);
  padding-block: 10px;
  border-bottom: 1px solid var(--rule-ondark);
}
.mobile-menu .btn { margin-top: 32px; text-align: center; }

/* The phone header's right-hand end: Instagram, then Menu. 12px between them,
   not the header's own 24px gap — at 24 they read as two separate things at
   opposite ends of a group rather than a pair, and 12 still keeps a clear gap
   between two 44px targets, which is what WCAG 2.5.8's spacing exception wants.
   Instagram sits to the LEFT so the Menu button keeps the screen edge. */
.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 0 0 auto;
}

/* THE MARK IS OFF BELOW 375px, AND THAT IS ARITHMETIC, NOT TASTE.
   Measured: the wordmark needs 173px on one line, this group is 131px, and the
   header's content box at 320px is 280px. 173 + 16 + 131 = 320, so the three of
   them cannot share a 320px screen however the gaps are tuned — adding the icon
   there wrapped "Catrigg Retrievers" onto two lines and took the header from
   48px to 94px tall.

   The brand wins that argument, so below 375 the header stays as it was and the
   icon is one tap away in the menu, plus 64px of it in the footer. 375 is the
   narrowest current iPhone; at that width this leaves 19px of slack.

   If the 360px Androids ever need it, the room is in the Menu button — an
   icon-only toggle frees 31px and brings the floor to about 329. That is a
   design decision, so it has not been taken here.

   THE BOUNDARY IS 368px AND IT IS DELIBERATELY NOT 375. Written as
   `max-width: 23.4374em` — one notch under 375 — Chrome reported BOTH
   `(max-width: 374.99px)` and `(min-width: 375px)` as true at a 375px viewport,
   and the icon stayed hidden on the width it was meant to appear at. A boundary
   that lands on a fraction next to a common device width is a coin toss. 368 is
   clear of 360 and 375 both, so neither can land on it. */
@media (max-width: 23em) {
  .header-actions .nav-ig--bar { display: none; }
}

@media (min-width: 56.25em) {
  .site-nav { display: flex; align-items: center; gap: 22px; }
  /* The whole group goes, not just the button. Leaving an empty wrapper behind
     would keep a third flex child in a space-between row and pull the nav off
     the right-hand edge. */
  .header-actions { display: none; }
}

/* --- Hero ---------------------------------------------------------------- */

.hero {
  position: relative;
  min-height: 90vh;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  /* The sticky header sits over the hero, so pull the hero up under it. */
  margin-top: -84px;
  isolation: isolate;
}

.hero__media {
  position: absolute;
  inset: 0;
  z-index: -2;
  /* Guard. Nothing inside a full-bleed wrapper may ever spill out of it, even
     if someone later changes how the image is sized. This is NOT the house
     standard 2 problem: these are absolutely-positioned decorative wrappers,
     not html/body/main, so they cannot create a nested scroll container. */
  overflow: hidden;
}

/* <picture> is display:block with an AUTO height. `height: 100%` on the <img>
   inside it is a percentage, and a percentage against an auto height does not
   resolve — so the image fell back to its intrinsic aspect ratio and
   object-fit:cover had no box to crop into.
   The hero photograph rendered 1093px tall inside an 810px section and spilled
   283px past the proof row; the closing band spilled 1360px, past the footer.
   Giving the picture a definite height is what makes object-fit work at all. */
.hero__media picture,
.cta-band__media picture {
  display: block;
  width: 100%;
  height: 100%;
}

.hero__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Bias the crop low, so what gets cut is the empty sky above the treeline
     rather than the dogs' feet.

     MEASURED 27 Aug 2026, AND THE RESULT IS COUNTER-INTUITIVE: this number only
     does anything at all above roughly 1780px wide. The photograph is 16:9, and
     `cover` scales to whichever axis needs more. A viewport wider than 16:9
     scales to the width and crops vertically — that is the only case this
     controls. Every narrower viewport is TALLER than 16:9 relative to its width,
     so it scales to the height and crops HORIZONTALLY, and the vertical position
     is inert. Sweeping 45/65/85/100% moved not one reading at 1440 and below.

     So the VERTICAL half is a one-width decision, and at 1850 it is worth 6.05 on
     the headline against 5.40 for the 45% the plan had assumed. There is nothing
     to gain by tuning the vertical number for phones; it does nothing there.

     THE HORIZONTAL HALF IS THE OPPOSITE, and that is what 68% is for — 28 Aug.
     Because every viewport below about 1780px crops horizontally, X is the only
     half that does anything on a phone, and at the default 50% a phone was
     showing a narrow strip out of the MIDDLE of an eight-dog group shot:

         320px   16% of the frame's width, 26% of the dogs
         390px   20% of the frame's width, 38% of the dogs

     68% slides that strip onto the cluster instead of the gap. Counted off the
     coat mask at every measured hero box:

         320  26% -> 41%      600  69% -> 88%
         390  38% -> 54%      768  72% -> 93%
         430  45% -> 57%      900  88% -> 100%

     WHAT THIS CANNOT DO, so nobody spends a day on it: `cover` on a 0.36-aspect
     box shows 413px of source width and no more, whatever is served to it. A
     bespoke portrait rendition was measured and gains nothing over this — the
     ceiling is the box shape, not the file. The only real lever left is a
     shorter hero on a phone, which is a layout decision, not an image one. */
  object-position: 68% 85%;
}

/* The scrim, and the numbers behind it.

   MEASURED 24 AUGUST 2026 by sampling the actual photograph under every line of
   type, at six viewport widths. That method is the point: a contrast audit that
   walks the DOM cannot see this at all, because the backdrop is a photograph
   rather than a background-color and the veil is a pseudo-element. The site's
   AA sweep of 16 August reported zero failures having SKIPPED the hero for
   exactly that reason. It was excluded, not passed.

   What the old four-stop gradient actually measured, white text, worst pixel:

       390px   eyebrow 2.78   headline 1.63   subline 2.45
       768px   eyebrow 2.32   headline 1.64   subline 2.05
      1100px   eyebrow 2.97   headline 2.54   subline 2.89
      1850px   eyebrow 3.05   headline 1.64   subline 3.91

   Against 4.5 for the small text and 3 for the headline, every one of those
   fails, and the worst of them are on a phone. The mid-frame stop was 0.14 —
   nearly clear — and it sat exactly where the headline lands.

   Below 1200px the type runs the full width of the page, so there is nowhere to
   move darkness TO. The veil simply has to be heavier. This costs the
   photograph on a phone and there is no version of this that does not: white
   type over sunlit grass is unreadable until the grass is dimmed.

   RE-MEASURED against the new photograph, 27 Aug 2026, and LIGHTENING was tested
   and rejected: 0.86/0.74/0.50/0.62 put the eyebrow at 4.31 at 768px and 4.04 at
   1100px, both under 4.5. There is no headroom in that direction.

   DARKENED 0.78->0.80 and 0.58->0.64 on 28 Aug, and only because the horizontal
   crop moved. Sliding `object-position` to 68% to get the dogs into frame on a
   phone (see the note on the image above) puts their coats — which are lighter
   than the grass — behind the type. At the old numbers that took the standfirst
   at 320px to 4.51 and the eyebrow at 768px to 4.52, both against 4.5, which is
   not a margin. 56%, 60% and 64% were worse still: all three read 4.49 at 320,
   an actual fail.

   These two stops buy the margin back and then some. Worst reading across
   320/390/768/829-landscape/1024/1100 is now 5.32 on the headline and 5.46 on
   the eyebrow, and the landscape-phone standfirst IMPROVES from 4.90 to 5.86.
   Mean veil pays 0.723 -> 0.752 for it, which is what buys 38% -> 54% of the
   dogs on a 390px screen. */
.hero::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(
    to top,
    rgba(20, 26, 22, 0.88) 0%,
    rgba(20, 26, 22, 0.80) 38%,
    rgba(20, 26, 22, 0.64) 66%,
    rgba(20, 26, 22, 0.72) 100%
  );
}

/* On a wide screen there IS somewhere to move it to, so the darkness is
   redistributed rather than piled on: concentrated into the column where every
   line of type sits, and taken off the right, which is open grass and three of
   the dogs.

   The horizontal ramp is in PIXELS, not percentages, and that is the whole
   trick. A percentage ramp tracks the viewport while the type tracks its own
   column, so at 1200px the headline ran past the ramp's tail and measured 2.69
   while the same ramp passed comfortably at 1850. Pixels pin the dark edge to
   the text instead.

   THE TAIL IS SET BY THE GOOGLE RATING, NOT BY THE HEADLINE. That is the trap
   this ramp sets for whoever edits it next. With the headline now capped at
   520px it is tempting to pull the tail in behind it — 820px was tried, and it
   read 2.30 on the rating and 2.94 on the standfirst, because both of those run
   further right than the headline does: the rating sits at x650–957 and the
   standfirst's longest line reaches x686. The tail has to clear the RIGHTMOST
   line of type in the hero, whichever element that belongs to.

   1240 → 1120 is what the measurements actually allowed, and it is worth having:
   mean veil over the right-hand 40% of the frame drops 0.406 → 0.345 at 1440 and
   0.576 → 0.510 at 1200, while the tightest reading, the rating at 1850, moves
   7.43 → 6.07 against a 4.5 threshold.

   The two layers composite source-over, so effective opacity is 1-(1-a1)(1-a2).
   Change either and all five elements move; re-measure on the rendered page. */
@media (min-width: 75em) {   /* 1200px */
  /* 27 Aug 2026: THE TWO RULES THAT USED TO LIVE HERE ARE GONE, and their
     absence is the point.

     `.hero__content { margin-bottom: 0 }` and
     `.hero__content .hero__sub { margin-top: 160px }` were a matched pair —
     140px taken off the bottom margin and added to the standfirst's top margin,
     which left the headline where it was and slid everything below it down. Both
     existed for one reason: the old photograph had eight dogs spread across the
     FULL width of the frame at exactly text height, so the only way to get the
     type off their faces was to move the type.

     The replacement photograph groups the dogs into the right two-thirds and
     leaves the left third open grass. There is nothing under the text column to
     dodge, so the compensation comes out and the stack sits where the base rules
     put it. Deleting them also moves the type DOWN onto the hillside and off the
     pale sky at the top of the frame, which is where the contrast is won — see
     the scrim note below.

     If a future photograph ever needs the lift back, it is a bottom-anchored
     flex column: `content top = hero bottom − margin-bottom − content height`.
     Trade the two numbers against each other and the headline does not move. And
     put the margin on `.hero__content`, never padding on `.hero` — padding lifts
     the trust bar with it, and measured that way the bar left the heavy bottom
     band and landed on bright grass at 1.62:1. */

  /* 520px, not 860. The dogs begin at hero x673 at 1850 and the column starts at
     x142, so 520 is the widest headline that ends clear of them — measured, and
     the reason head coverage by the headline is zero rather than the 9,964px²
     that 860 would have cost on this photograph. The cost is four wrapped lines
     instead of two, which is accepted. This number is paired with the horizontal
     ramp below: the ramp's tail must sit past the column's right edge. */
  /* Back to 50% above the breakpoint, and this is a RESTORE rather than a tweak.
     From 1200px up the whole dog band is in frame at any X — the sweep reads 100%
     at 50, 60, 68 and 75 — so 68% buys nothing here. What it would do is slide
     the dogs about 46px LEFT at 1200, towards a text column whose 520px cap and
     ramp tail were measured against the dogs sitting where 50% puts them (§9p).
     So the phone gets 68% and the desktop keeps the framing that was measured. */
  .hero__media img { object-position: 50% 85%; }

  .hero__content h1 { max-width: 520px; }

  /* The vertical layer has a KNEE at 12%, and it is the reason the dogs are
     visible at all. The proof row occupies roughly the bottom 12% of the hero
     and needs a dark ground under it; without the knee, the ramp that serves
     that row goes on climbing all the way up through the middle of the frame,
     which is where the dogs stand. The knee pays for the row and then gets out
     of the way — it drops to 0.08 by 30%, where the first version was still at
     0.30. That single change took 61% of the veil off the right-hand third of
     the photograph while every measurement stayed well clear of its threshold,
     the tightest being the subline at 5.69 against 4.5. */
  .hero::after {
    background:
      linear-gradient(
        to right,
        rgba(20, 26, 22, 0.68) 0px,
        rgba(20, 26, 22, 0.58) 900px,
        rgba(20, 26, 22, 0) 1120px
      ),
      linear-gradient(
        to top,
        rgba(20, 26, 22, 0.78) 0%,
        rgba(20, 26, 22, 0.68) 12%,
        rgba(20, 26, 22, 0.08) 30%,
        rgba(20, 26, 22, 0.02) 66%,
        rgba(20, 26, 22, 0.26) 100%
      );
  }
}

.hero__content { padding-block: 160px 32px; color: var(--white); }
.hero__content .h-hero { color: var(--white); margin-bottom: 0; }

/* Small locating line above the headline. On the hero it sits on the scrim, so
   it takes the sand tint rather than full white. */
.eyebrow {
  font-family: var(--body);
  font-size: 0.875rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-caption);
  margin: 0 0 14px;
}
/* Full white, not 72%. Sampling the actual hero photograph THROUGH the scrim
   put this at 4.37:1 at its worst point — the eyebrow sits in the gradient's
   thinnest zone (alpha 0.27), so it gets the least help from it. Hierarchy here
   comes from size and letter-spacing, which do not cost contrast. */
.hero__content .eyebrow { color: var(--white); }
.band-forest .eyebrow { color: var(--sand-label); }

/* THE HERO STANDFIRST IS A PAIR. This one lives in the hero and shows from 900px
   up; `.intro-mobile` below the hero carries the same words under 900px. Exactly
   one of the two is ever displayed. If you change the copy, change both — the
   markup says so at each end as well.

   Hidden here by default rather than shown, because the stylesheet is mobile
   first and on a phone this paragraph is what was burying the photograph: 216px
   of a 1087px hero. */
.hero__sub {
  display: none;
  margin: 20px 0 0;
  max-width: 52ch;
  font-size: 1.0625rem;
  color: rgba(255, 255, 255, 0.88);
}
@media (min-width: 56.25em) {
  .hero__sub { display: block; }
}

/* The other half of that pair. Mobile-first, so it is visible by default and the
   desktop switches it off. */
.intro-mobile .lede { color: var(--ink-body); }
@media (min-width: 56.25em) {
  .intro-mobile { display: none; }
}

/* The four proof points, lifted off the photograph on a phone. Same mobile-first
   shape as `.intro-mobile` above, and the same pairing: `.hero__proof` is the
   other copy and is hidden below 900px.

   The recolouring is the whole reason this needs rules of its own. `.proof-grid`
   and `.proof-point` are written for white type on a dark photograph; on sand
   that is invisible. Scoped to `.proof-mobile` so the hero copy is untouched.

   The --fox icon strokes are deliberately NOT changed. --fox is 4.05:1 on sand,
   which fails the 4.5 needed for text but clears the 3.0 for a non-text graphic,
   and these are aria-hidden decorations beside their own label. */
.proof-mobile .proof-grid { border-top-color: var(--rule-strong); }
.proof-mobile .proof-point span { color: var(--ink-body); }
@media (min-width: 56.25em) {
  .proof-mobile { display: none; }
}

/* --- Ian's waiting-list notice ------------------------------------------- *

   2 Oct 2026. The list is full for twelve months and the site exists to generate
   enquiries, so the notice has to be impossible to miss without burying the
   photograph. Two renderings of one message, never both on screen:

     .notice__short  a précis, phones only, sitting directly above the buttons
     .notice__body   Ian's full statement, 900px and up, in a panel on the photo

   Its own ground rather than type on the photograph. The scrim out here was
   deliberately lightened in §9p to show the dogs, so there is nothing behind it
   to carry white text — the same reason .hero__social carries a background. An
   opaque panel also makes the contrast DOM-checkable instead of needing the
   compositing harness. */
.hero-notice {
  margin-top: 24px;
  padding: 18px 20px;
  border-radius: var(--radius-card);
  background: rgba(20, 26, 22, 0.82);
  border: 1px solid var(--rule-ondark);
}
.hero-notice__title {
  font-family: var(--body);
  font-size: 0.8125rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  margin-bottom: 8px;
}
.hero-notice .hero-notice__title { color: var(--sand); }
.hero-notice p { color: rgba(255, 255, 255, 0.92); margin-bottom: 10px; }
.hero-notice p:last-child { margin-bottom: 0; }
.hero-notice strong { color: var(--white); }
.hero-notice a { color: var(--white); text-decoration: underline; }

/* The hero carries the précis at EVERY width and links out. There is no second
   rendering to switch between any more — the full statement never appears on the
   photograph. The guard stays in case someone re-injects the partial here. */
.hero-notice .notice__body { display: none; }

/* The same markup on /availability, on sand rather than on the photograph, so it
   takes ink colours and a plain rule. */
.notice-card {
  padding: 24px;
  border-radius: var(--radius-card);
  border: 1px solid var(--rule-strong);
  background: var(--white);
}
.notice-card .hero-notice__title { color: var(--fox-dark); }
.notice-card p { color: var(--ink-body); margin-bottom: 12px; }
.notice-card p:last-child { margin-bottom: 0; }
.notice__sign { font-style: italic; }

/* The earliest-collection date, at the top of the enquiry form itself. A tinted
   band rather than bold text in a paragraph, because it has to survive being
   skimmed: someone arriving from the "Enquire about a puppy" button lands at
   #enquire with the notice scrolled off the top, and this is their only warning.

   --fox-dark, not --fox: this sits on a near-white card where --fox is 4.05:1 and
   fails for text. The left rule carries the colour instead. */
.form-notice {
  margin-bottom: 20px;
  padding: 14px 16px;
  border-left: 4px solid var(--fox);
  border-radius: 0 var(--radius-card) var(--radius-card) 0;
  background: rgba(171, 98, 56, 0.08);
  color: var(--ink-body);
}
.form-notice strong { color: var(--fox-dark); }

.hero__actions {
  margin-top: 28px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 20px;
}

/* The Google rating, sitting quietly under the call to action. */
.hero__rating {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.88);
  padding-block: 11px;          /* 23px tall on its own; this makes it 45 */
}
@media (hover: hover) and (pointer: fine) {
  .hero__rating:hover { color: var(--white); }
}
.hero__rating .stars { display: inline-flex; gap: 2px; }
.hero__rating svg { width: 14px; height: 14px; }

.hero__proof { padding-block: 0 48px; }

.proof-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 26px 20px;
  border-top: 1px solid var(--rule-onhero);
  padding-top: 26px;
}
@media (min-width: 56.25em) {
  .proof-grid { grid-template-columns: repeat(4, 1fr); gap: 32px; }
}

.proof-point {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
}
.proof-point svg {
  width: 30px;
  height: 30px;
  stroke: var(--fox);
  stroke-width: 2;
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.proof-point span {
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.92);
}

/* --- The phone hero, compacted ------------------------------------------- *

   27 Sep 2026. On a phone the hero measured 1087px tall at 390x844 and the four
   proof points sat at y910–1047 — roughly 340px BELOW a usable first screen of
   about 700–720px once browser chrome is gone. So the thing the page is selling,
   the dogs, was a strip behind a wall of type, and the four reasons to trust the
   business were invisible until you scrolled.

   This is the lever the 28 Aug note said was the only one left. `object-position`
   can choose WHICH 413px of the photograph a phone sees but never MORE: the
   ceiling is the hero's shape. Every pixel taken off the hero is a pixel of
   photograph given back.

   Nothing here is a font change. It is all space — padding, gaps, and one
   paragraph that moved out of the hero entirely (see `.intro-mobile`). The
   headline was left alone deliberately: it is the one change that would alter
   the hero's voice rather than its spacing, and the measurements did not need it.

   Everything below 900px, the breakpoint where the nav already collapses and the
   proof grid already drops to two columns. No new breakpoint was invented.

   THIS BLOCK MUST STAY BELOW `.proof-grid` AND `.proof-point`, and that is not
   tidiness. Written first time directly under `.hero__proof` — ABOVE those two —
   the `.hero__content` and `.hero__actions` overrides applied and the grid ones
   silently did not: same specificity, base rule later in the file, base wins. A
   MEDIA QUERY ADDS NO SPECIFICITY. It measured as 26px gaps and 26px padding
   while the source plainly said 16 and 18. Third time on this stylesheet — see
   `.pt-0` (§9k), `.hero__sub` (§9o) and `.ig-tile` (§9q). Check the computed
   value, never the source. */
@media (max-width: 56.24em) {
  /* THE LAST 82px, AND THE ONE THAT WAS HIDING. With everything below trimmed,
     the hero STILL measured 760 and the proof points still sat at y744 — because
     `min-height: 90vh` is 759.6px at 844 and the content had shrunk under it. The
     hero had stopped being content-driven and nothing more could move until this
     changed. Natural content height is 678.

     `auto` rather than a smaller vh: a fixed fraction is the thing that just went
     stale, and on a phone letting the content set the height also leaves the next
     section peeking above the fold, which reads as "there is more below" instead
     of a screen that looks like the whole page. */
  .hero { min-height: auto; }

  /* 27 Sep 2026, and this REVERSES the line above for browsers that can do it.
     Sam wants the Google rating to sit at the bottom of the hero without ever
     going behind Safari's bottom toolbar, which means the hero has to be exactly
     one screen rather than content-height.

     `svh` is the SMALL viewport — the height with the browser's chrome showing,
     which is what you get on first paint. `100vh` is the LARGE viewport and
     would put the bottom of the hero underneath the toolbar, which is precisely
     the bug being fixed, so it is not used as a fallback.

     @supports, not a bare declaration: svh is unsupported below iOS 15.4, and
     there the guard leaves the hero on the content-driven `auto` above, which
     works. First use of svh on this site. */
  @supports (min-height: 100svh) {
    .hero { min-height: 100svh; }
  }

  /* 110, not 160. The header is 80px tall, so this still clears it by 30.

     THE BOTTOM VALUE IS A CONSTANT, AND MUST STAY ONE. It was
     `calc(28px + env(safe-area-inset-bottom))` for one day, and that is what made
     the rating visibly jump while scrolling: on iOS with viewport-fit=cover,
     safe-area-inset-bottom is 0 while Safari's toolbar covers that strip and
     becomes ~34px the moment the toolbar retracts. Any layout value built on it
     therefore CHANGES MID-SCROLL, and everything anchored to the bottom of the
     hero moves with it.

     40px flat instead. `min-height: 100svh` already ends the hero above the
     toolbar — svh is the viewport WITH the chrome showing — so the home
     indicator never needed a second allowance, and paying for it in a value that
     animates was a bad trade. */
  .hero__content { padding-block: 110px 40px; }

  /* THE SPLIT. The eyebrow and headline go to the TOP of the hero, the buttons
     and rating stay at the BOTTOM, and the space between them is left as clear
     photograph — which is where the dogs' faces are. Sam drew the box.

     Before this, all three sat in one bottom-anchored block and the eyebrow
     started at y463 at 390x844 while the faces occupy roughly y370–505, so the
     type cut across them while 383px of photograph sat empty under the header.

     `margin-bottom: auto` on the headline is the whole mechanism: in a flex
     column it absorbs every spare pixel, so everything after it is pushed to the
     bottom. The visible children here are exactly three — .hero__sub and
     .hero__social are already display:none below 900px — so this splits the block
     in two without a wrapper div, which would otherwise have to be unwound on
     desktop.

     `flex: 1 1 auto` is load-bearing: .hero__content must FILL the 100svh hero or
     there is no spare space for `auto` to absorb and nothing moves. */
  .hero__content { display: flex; flex-direction: column; flex: 1 1 auto; }
  .hero__content .h-hero { margin-bottom: auto; }

  /* 30px, down from the 36px floor of the clamp. This is the ONE change here
     that touches type rather than space, and it was held back until the
     measurements said it was needed: with everything else trimmed the last proof
     point still sat at y744, and an iPhone shows about 664–745px on first paint.
     Three lines instead of four.

     Scoped `.hero__content .h-hero` (0,2,0) so it wins on SPECIFICITY rather than
     source order — see the note above about what happened when it did not. */
  .hero__content .h-hero { font-size: 1.875rem; }

  .hero__actions { margin-top: 20px; gap: 16px; }

  /* Tighter on a phone. The notice costs 211px at 390 and the hero only has one
     screen to spend: at 375x667 it pushes the hero 69px past the fold and at
     320x568 by 228px. This buys 16 of those back without touching Ian's words.
     The rest is reported in BUILD-NOTES §10c rather than hidden — on a short
     phone the notice and the buttons genuinely do not both fit. */
  .hero-notice { margin-top: 16px; padding: 14px 16px; }

  /* Off the photograph entirely. The copy below the hero, `.proof-mobile`,
     shows instead — the two are never both visible. With this gone the rating
     becomes the last thing in the hero on its own, which is why the generated
     RATING markers did not have to move. */
  .hero__proof { display: none; }

  /* The grid's own rhythm, tightened. Applies to `.proof-mobile` in practice,
     since the hero copy is display:none at this width. 44px touch targets are
     untouched — these are gaps between static text and icons, not between
     anything tappable. */
  .proof-grid { gap: 16px 20px; padding-top: 18px; }
  .proof-point { gap: 8px; }
  .proof-point svg { width: 24px; height: 24px; }
}

/* --- The circle row ------------------------------------------------------ */

.family-row-scroll {
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
  /* Bleed to the screen edge on mobile so the row reads as scrollable. */
  margin-inline: calc(var(--gutter) * -1);
}
.family-row-scroll::-webkit-scrollbar { display: none; }

.family-row {
  display: flex;
  gap: 20px;
  width: max-content;
  padding: 8px var(--gutter) 12px;
  margin: 0;
  list-style: none;
}

@media (min-width: 67.5em) {
  .family-row-scroll { margin-inline: 0; overflow-x: visible; }
  .family-row {
    width: auto;
    justify-content: space-between;
    gap: clamp(16px, 2.2vw, 32px);
    padding-inline: 0;
  }
}

.family-row li { margin: 0; }

.dog-circle {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  color: var(--ink);
  transition: transform 240ms ease;
}
@media (hover: hover) and (pointer: fine) {
  .dog-circle:hover { color: var(--ink); transform: translateY(-4px); }
}

.dog-circle__img {
  width: clamp(88px, 11vw, 128px);
  height: clamp(88px, 11vw, 128px);
  border-radius: var(--radius-pill);
  overflow: hidden;
  box-shadow: 0 0 0 0 rgba(171, 98, 56, 0);
  transition: box-shadow 240ms ease;
}
.dog-circle__img img { width: 100%; height: 100%; object-fit: cover; }

/* The ring draws in on hover, and is always on for keyboard focus. */
@media (hover: hover) and (pointer: fine) {
  .dog-circle:hover .dog-circle__img { box-shadow: 0 0 0 2px var(--fox); }
}
.dog-circle:focus-visible { outline: none; }
.dog-circle:focus-visible .dog-circle__img { box-shadow: 0 0 0 2px var(--fox); }
.dog-circle:focus-visible .dog-circle__name { text-decoration: underline; }

.dog-circle__name {
  font-family: var(--display);
  font-weight: 600;
  font-size: 1.0625rem;
  letter-spacing: -0.01em;
}

.family-more { margin-top: 36px; }

/* --- Three-column proof block -------------------------------------------- */

.pillars {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 40px;
}
@media (min-width: 56.25em) { .pillars { gap: 48px; } }

.pillar h3 { margin: 0 0 14px; }
/* Dark text, which is only ever correct on a light backdrop. Four of the five
   .pillars blocks sit on sand, so this is right for them. The fifth — the
   collection checklist — is on .band-forest, where this rendered at 1.34:1 and
   was effectively invisible. See .pillars--steps below: the fix is that those
   pillars are white CARDS, so the text is on white rather than on green.
   Do not "correct" this to a light colour; that would break the other four. */
.pillar p { color: var(--ink-body); margin-bottom: 0; }

/* --- Numbered cards, for a checklist on a dark band ----------------------- */

/* Built from a reference Sam supplied: numbered panels in a row, each with a
   coloured number tab above its title. Its STRUCTURE, not its styling — that one
   is five hues with a drop shadow under every panel, and this site has five
   colours plus white and no shadows anywhere. So: one accent, hairlines.

   Seven categories rather than the reference's five, because data/checklist.json
   has seven and it also generates the downloadable PDF. Merging them to hit five
   would have rewritten the PDF too. The auto-fit grid resolved to four columns at
   1232px, so they wrapped 4 then 3 on their own with no grid changes. */
.pillars--steps { counter-reset: step; }

/* That 4-then-3 wrap was a property of the container being 1232px wide, not a
   decision the grid was holding. When --wrap went to 1600px on 24 August the same
   `minmax(260px, 1fr)` fitted FIVE columns and the seven cards silently rewrapped
   to 5 then 2 — a lopsided row nobody chose. Pinned, so the arrangement survives
   the container instead of being re-derived from it every time the width moves. */
@media (min-width: 78em) {   /* 1248px, where the fifth column starts to fit */
  .pillars--steps { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.pillars--steps .pillar {
  counter-increment: step;
  background: var(--white);
  border: 1px solid var(--rule-card);
  border-radius: var(--radius-card);
  padding: 0 24px 28px;
  /* The tab bleeds to the card's left edge, so it has to be clipped to the
     corner radius. `clip`, never `hidden` — house rule 2. `hidden` silently
     forces overflow-y: auto and makes this a nested scroll container. */
  overflow: clip;
}

/* The number tab. Generated rather than typed into the markup, so an eighth
   category renumbers the rest on its own — same approach as .timeline__num.
   Deliberately NOT aria-hidden: these are not an ordered list and the number is
   decorative, and generated content is not reliably announced, which here is
   the behaviour we want rather than a compromise. */
.pillars--steps .pillar::before {
  content: counter(step, decimal-leading-zero);
  display: block;
  width: fit-content;
  margin: 0 0 18px -24px;
  padding: 10px 20px;
  background: var(--fox);
  color: var(--white);
  font-family: var(--display);
  font-weight: 700;
  font-size: 1.5rem;
  line-height: 1;
  /* Square where it meets the card edge, rounded on the two outer corners —
     the tab shape from the reference, using the existing radius token. */
  border-radius: 0 var(--radius-card) var(--radius-card) 0;
}

.pillars--steps .pillar h3 { font-size: 1.125rem; }
.pillars--steps .pillar ul { margin-bottom: 14px; }
.pillars--steps .pillar > :last-child { margin-bottom: 0; }

@media (min-width: 40em) {
  .pillars--steps .pillar { padding: 0 28px 32px; }
  .pillars--steps .pillar::before { margin-left: -28px; }
}

.divider {
  height: 1px;
  background: var(--rule);
  border: 0;
  margin: 0;
}

/* --- How it works -------------------------------------------------------- */

.steps {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 40px;
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: step;
}
@media (min-width: 56.25em) { .steps { gap: 44px; } }

.step { margin: 0; }
/* Sand, not fox. These numerals only ever appear on the forest band, where
   --fox measures 2.66:1 — under the 3:1 that even large text needs. Sand is
   10.79:1. It costs the accent on five numerals; there is no way to keep fox
   here without either lightening the band or adding a sixth colour. */
.step__num {
  display: block;
  font-family: var(--display);
  font-weight: 700;
  font-size: 2.25rem;
  line-height: 1;
  color: var(--sand);
}
.step h3 { margin: 16px 0 10px; }
.step p { margin: 0; color: var(--sand-body); }

/* --- Reviews carousel ---------------------------------------------------- */

.reviews-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 20px;
  margin-bottom: 36px;
}

.rating-summary {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 0.9375rem;
  color: var(--ink-body);
}
.rating-summary .stars { display: inline-flex; gap: 3px; }
.rating-summary svg { width: 16px; height: 16px; }
.rating-summary__score { font-weight: 600; color: var(--ink); }

/* Arrows either side of the cards. Below 40em they wrap underneath and centre,
   so a phone keeps its full width for the card rather than losing 100px to two
   buttons. `min-width: 0` on the viewport is what lets it shrink inside flex. */
.reviews-carousel {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 16px;
}
.reviews-carousel .reviews-viewport { flex: 1 1 100%; min-width: 0; order: 0; }
.reviews-carousel .carousel-btn { order: 1; }

@media (min-width: 40em) {
  .reviews-carousel { flex-wrap: nowrap; gap: 18px; }
  .reviews-carousel .reviews-viewport { flex: 1 1 auto; }
  .reviews-carousel .carousel-btn:first-child { order: -1; }
  .reviews-carousel .carousel-btn { flex: 0 0 auto; }
}

.reviews-viewport { overflow: hidden; }

/* pan-y: the swipe handler only ever acts on horizontal movement, and this tells
   the browser it can keep owning vertical scrolling without waiting to see
   whether JS calls preventDefault. Without it a phone feels stuck on the
   carousel. */
.reviews-track {
  touch-action: pan-y;
  --gap: 20px;
  --card: calc((100% - (var(--per-page) - 1) * var(--gap)) / var(--per-page));

  /* Two different steps, and mixing them up is easy. A PAGE advances by the
     whole viewport; a CARD is one column of it. Multiplying the page index by
     the card step would advance one card per press at two-up. */
  --page-step: calc(100% + var(--gap));
  --card-step: calc(var(--card) + var(--gap));

  /* How far the track can travel before its right edge reaches the viewport.
     Without this the last page overshoots: five reviews at two per page is
     three pages, and page 2 would slide card five to the left with a
     card-shaped hole beside it. Clamped, the last step shows cards four and
     five instead. `--cards` is set once by main.js — a card COUNT does not
     change with the viewport, so unlike card sizing it is safe to pass in. */
  --max: max(0px, calc(var(--cards, 1) * var(--card-step) - var(--gap) - 100%));

  display: flex;
  gap: var(--gap);
  transition: transform 420ms cubic-bezier(.4, 0, .2, 1);
  transform: translateX(calc(-1 * min(var(--page, 0) * var(--page-step), var(--max))));
}

.review-card {
  /* Grow, but never shrink.
     The Places API returns at most five review bodies against a row that is
     six wide on desktop, so the row will usually be short. `flex-grow: 1` lets
     the cards that did arrive share the spare room and fill the width, instead
     of leaving an empty sixth slot that reads as a broken layout.
     When there are MORE reviews than columns there is no spare room, nothing
     grows, each card is exactly one column wide, and paging works. */
  flex: 1 0 calc((100% - (var(--per-page) - 1) * 20px) / var(--per-page));  /* matches --card on the track */
  display: flex;
  flex-direction: column;
  background: var(--white);
  border: 1px solid var(--rule-card);
  border-radius: var(--radius-card);
  padding: 26px 24px;
}

.review-card .stars { display: flex; gap: 3px; margin-bottom: 14px; }
.review-card svg { width: 15px; height: 15px; }

.review-card blockquote {
  margin: 0;
  font-size: 0.9375rem;
  line-height: 1.6;
  color: var(--ink-body);
}

.review-card__by {
  margin-top: auto;
  padding-top: 22px;
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  color: var(--ink-caption);
}
.review-card__who { font-weight: 600; color: var(--ink); }

.carousel-controls { display: flex; gap: 10px; }
.carousel-btn {
  width: 44px;
  height: 44px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--rule-strong);
  background: transparent;
  color: var(--forest);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background-color 200ms ease, color 200ms ease;
}
@media (hover: hover) and (pointer: fine) {
  .carousel-btn:hover:not(:disabled) { background: var(--forest); color: var(--sand); }
}
.carousel-btn:disabled { opacity: 0.35; cursor: default; }

/* Where you are in the set. Five reviews was three pages and needed nothing;
   once the owner API is connected this can be a dozen or more, and arrow-only
   paging through that is bottomless with no position shown. Hidden at one page,
   the same rule the arrows already follow. */
.reviews-count {
  margin: 18px 0 0;
  text-align: center;
  font-size: 0.875rem;
  color: var(--ink-caption);
}
.carousel-btn svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; }

/* --- Closing call to action ---------------------------------------------- */

.cta-band {
  position: relative;
  min-height: 60vh;
  display: flex;
  align-items: center;
  isolation: isolate;
}
/* The picture sizing for this wrapper lives with the hero rules above, so the
   two full-bleed blocks cannot drift apart. Same guard applies here — and it
   mattered more here, because these are PORTRAIT photographs in a 540px band. */
.cta-band__media { position: absolute; inset: 0; z-index: -2; overflow: hidden; }
.cta-band__media img { width: 100%; height: 100%; object-fit: cover; }
.cta-band::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(
    to right,
    rgba(20, 26, 22, 0.88) 0%,
    rgba(20, 26, 22, 0.68) 55%,
    rgba(20, 26, 22, 0.35) 100%
  );
}
.cta-band__inner { padding-block: 72px; }
.cta-band h2 {
  color: var(--white);
  max-width: 20ch;
  line-height: 1.15;
  text-wrap: pretty;
}
.cta-band p { color: rgba(255, 255, 255, 0.88); max-width: 46ch; }
.cta-band .btn { margin-top: 8px; }

/* --- The retriever collage ------------------------------------------------
   Photographs composited into the outline of a golden retriever, closing most
   pages. It replaces the old full-width photo band.

   The mosaic and the silhouette mask are baked into finished images at build
   time by scripts/build-collage.mjs, so there is no CSS mask here and no grid
   of thirty tiles to keep in shape. A 6x5 composition serves wide screens and
   a 4x4 serves phones, art-directed through <picture media>. */

.collage__inner {
  display: grid;
  gap: 40px;
  align-items: center;
}
@media (min-width: 56.25em) {
  .collage__inner { grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 56px; }
}

.collage__text h2 { margin-bottom: 16px; }
.collage__text p { color: var(--ink-body); max-width: 40ch; }

/* <picture> is display:block per the reset, so the image inside it sizes
   normally. No height:100% anywhere near this — that is what broke the hero. */
.collage__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.collage__figure { display: block; }
.collage__figure img { width: 100%; height: auto; }

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

.site-footer {
  background: var(--forest);
  color: var(--sand-body);
  padding-block: 64px 0;
}

.footer-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 40px;
}
@media (min-width: 56.25em) { .footer-grid { gap: 48px; } }

.footer-col h2 {
  font-family: var(--body);
  font-size: 0.875rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--sand-label);
  margin-bottom: 16px;
}
.footer-col ul { list-style: none; padding: 0; margin: 0; }
/* Footer links measured 20px tall. They are standalone navigation, not links
   inside a sentence, so they need a real tap target. The li margin drops as the
   padding takes over, keeping the list the same overall height. */
.footer-col li { margin-bottom: 0; }
.footer-col a { color: var(--sand-link); display: inline-block; padding-block: 12px; }
@media (hover: hover) and (pointer: fine) {
  .footer-col a:hover { color: var(--sand); }
}
.footer-contact { line-height: 1.8; font-style: normal; }

.footer-brand .wordmark { margin-bottom: 14px; }
.footer-brand p { color: var(--sand-body); max-width: 32ch; }

/* The Instagram mark carries its own brand gradient, so this tile deliberately
   sets no `fill`, `stroke` or `color` — a `fill: none` here would beat the SVG's
   own presentation attributes and knock the gradient straight back out. */
/* 64px, matching `.hero__social-icon`, from 27 Aug 2026.

   THE SELECTOR IS `.footer-col a.ig-tile` AND THAT IS LOAD-BEARING. Written as a
   bare `.ig-tile` — which is how it was until now — it is (0,1,0) and loses to
   `.footer-col a` at (0,1,1) on line 1195. Both of that rule's declarations were
   winning:

     display: inline-block   so the flex centring never ran, and
     padding-block: 12px     which, with border-box, ate 24px of the height.

   So the mark was never the size it said it was: a "44px" tile rendered 44−24 =
   **20px of actual artwork**, which is why it read as small next to the 34px nav
   mark and the 64px hero one. Raising the box to 64 alone would have produced a
   40px icon and looked like the change had half worked. The padding is zeroed
   here rather than removed from `.footer-col a`, which every other footer link
   needs for its touch target.

   Third instance of this same failure on this stylesheet — see `.pt-0`
   (BUILD-NOTES §9k) and the `.hero__sub` media-query override (§9o). On this
   file, check the COMPUTED value, never the source. */
.footer-col a.ig-tile {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  height: 64px;
  padding-block: 0;
  border-radius: var(--radius-tile);
  margin-top: 18px;
  transition: transform 160ms ease, box-shadow 160ms ease;
}
.ig-tile:focus-visible {
  transform: translateY(-2px);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.30);
}
@media (hover: hover) and (pointer: fine) {
  .ig-tile:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.30);
  }
}
.ig-tile svg { display: block; width: 100%; height: 100%; }

.footer-bottom {
  margin-top: 56px;
  border-top: 1px solid var(--rule-ondark);
  padding-block: 22px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px 24px;
  justify-content: space-between;
  font-size: 0.8125rem;
  color: var(--sand-label);
}

/* --- Meet Our Family blocks ---------------------------------------------- */

.dog-block {
  display: grid;
  gap: 28px;
  padding-block: 48px;
  border-bottom: 1px solid var(--rule);
  align-items: center;
}
.dog-block:last-child { border-bottom: 0; }

@media (min-width: 56.25em) {
  .dog-block { grid-template-columns: 1fr 1fr; gap: 56px; padding-block: 64px; }
  /* Alternate the image side so the eye moves down the page. */
  .dog-block:nth-child(even) .dog-block__media { order: 2; }
}

.dog-block__media img {
  width: 100%;
  border-radius: var(--radius-card);
}
.dog-block h2 { font-size: 1.5rem; margin-bottom: 6px; }
.dog-block .meta { display: block; margin-bottom: 16px; }
.dog-block p { color: var(--ink-body); margin-bottom: 0; }

/* --- FAQ ----------------------------------------------------------------- */

.faq { border-top: 1px solid var(--rule); }
.faq details { border-bottom: 1px solid var(--rule); }
.faq summary {
  cursor: pointer;
  list-style: none;
  padding: 22px 40px 22px 0;
  position: relative;
  font-family: var(--display);
  font-weight: 600;
  font-size: 1.1875rem;
  color: var(--ink);
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
  content: '';
  position: absolute;
  right: 8px;
  top: 50%;
  width: 10px;
  height: 10px;
  border-right: 2px solid var(--fox);
  border-bottom: 2px solid var(--fox);
  transform: translateY(-70%) rotate(45deg);
  transition: transform 200ms ease;
}
.faq details[open] summary::after { transform: translateY(-30%) rotate(-135deg); }
.faq details > div { padding-bottom: 24px; max-width: 68ch; color: var(--ink-body); }

/* --- Forms --------------------------------------------------------------- */

/* One box, two jobs. `.form-card` was already exactly the panel this site
   needed for boxed copy, so it becomes the shared base rather than a second
   near-identical rule that would drift away from it. Hairline and white on
   sand — no shadow, per the rule at the top of this file. */
.panel,
.form-card {
  background: var(--white);
  border: 1px solid var(--rule-card);
  border-radius: var(--radius-card);
  padding: 28px 24px;
}
@media (min-width: 40em) {
  .panel,
  .form-card { padding: 36px 32px; }
}

/* Both of these are LIGHT cards that are allowed to sit inside .band-forest,
   and the enquiry form on /availability does exactly that. The band sets
   `color: var(--sand-body)` for its dark background; a white card inside it
   inherits that and nothing resets it, so every label rendered near-white on
   white — about 1.1:1. The inputs escaped only because `.field input` sets a
   colour explicitly, which is why the fault looked like "the labels are the
   wrong colour" rather than what it was.

   Reset at the card, so any future card dropped into any dark band is safe.

   ORDER MATTERS. `.form-card h2` and `.band-forest h2` have identical
   specificity (0,1,1) — these rules win only because they come later in the
   file. Moving this block above the .band-forest rules at ~line 190 silently
   brings the bug back. */
/* `.pillars--steps .pillar` is in here for the same reason, and it is worth
   saying why plainly. Turning those pillars into white cards fixed the hint
   text — it went from 1.34:1 to 9.86:1 — and immediately broke the heading and
   the list items, which had been inheriting the dark band's SAND and were
   suddenly sand-on-white at 1.12:1. A light card inside a dark band must reset
   its own text colour. One recipe, listed here, rather than three. */
.panel,
.form-card,
.pillars--steps .pillar { color: var(--ink-body); }

/* Headings take full --ink. Setting a colour on the card above means headings
   inside it now inherit --ink-body (82%) rather than the body's --ink, which
   would quietly lighten every panel heading on the site. State it.

   `.pillars--steps .pillar h3` is (0,2,1) and beats `.band-forest h3` at (0,1,1)
   on specificity, so unlike the rules above it does not depend on source order. */
.panel h2, .panel h3,
.form-card h2, .form-card h3,
.pillars--steps .pillar h3 { color: var(--ink); }

.panel > :first-child { margin-top: 0; }
.panel > :last-child { margin-bottom: 0; }
.panel h2 { font-size: 1.375rem; }
.panel h3 { font-size: 1.125rem; margin-bottom: 14px; }
.panel p { color: var(--ink-body); }

/* Label left, value right, hairline between. For the concrete numbers a
   skim-reader wants — scores, grades, timings — that otherwise sit buried
   mid-paragraph. */
.panel__facts { list-style: none; margin: 0; padding: 0; }
.panel__facts li {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 16px;
  padding-block: 11px;
  border-top: 1px solid var(--rule-card);
}
/* These rows carry the jump links on /terms, which measured 20px tall. */
.panel__facts a { display: inline-block; padding-block: 12px; }
.panel__facts li:first-child { border-top: 0; padding-top: 0; }
.panel__facts li:last-child { padding-bottom: 0; }
.panel__facts dt,
.panel__facts .panel__label { color: var(--ink-muted); }
.panel__facts dd,
.panel__facts .panel__value {
  margin: 0;
  font-weight: 600;
  text-align: right;
  margin-left: auto;
}

/* --- Timeline ------------------------------------------------------------ */

/* The only genuinely new component in the copy rebuild. `.steps` could not do
   this job: it is a horizontal grid hard-coded for dark bands, and the first
   eight weeks is a sequence of seven stages with real paragraphs under each.
   A rail with a marker per stage reads as a sequence at any length.

   Built from the same three tokens as everything else — hairline rules, one
   accent, no shadow. */
.timeline {
  list-style: none;
  margin: 0;
  padding: 0;
  position: relative;
  counter-reset: stage;
}
/* The rail. Stops at the last marker rather than running past it, which is
   what `::before` on the list plus a background on the last item would do. */
.timeline::before {
  content: "";
  position: absolute;
  left: 7px;
  top: 10px;
  bottom: 10px;
  width: 1px;
  background: var(--rule-strong);
}
.timeline__item {
  position: relative;
  padding: 0 0 34px 38px;
  counter-increment: stage;
}
.timeline__item:last-child { padding-bottom: 0; }
.timeline__item::before {
  content: "";
  position: absolute;
  left: 0;
  top: 9px;
  width: 15px;
  height: 15px;
  border-radius: var(--radius-pill);
  background: var(--sand);
  border: 2px solid var(--fox);
}
.timeline__item h3 { font-size: 1.25rem; margin-bottom: 10px; }
.timeline__item p,
.timeline__item li { color: var(--ink-body); }
.timeline__item > :last-child { margin-bottom: 0; }
.timeline__when {
  display: block;
  font-family: var(--body);
  font-size: 0.8125rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-caption);
  margin-bottom: 4px;
}

/* The stage numeral. Generated by a counter rather than typed into the markup,
   so adding or reordering a stage renumbers everything on its own. It is
   aria-hidden: the <ol> already carries the sequence, and without that a screen
   reader announces "01 From day one". Fraunces in fox red matches .step__num on
   the homepage, which is the existing precedent for a large numeral. */
.timeline__num {
  display: block;
  font-family: var(--display);
  font-weight: 700;
  font-size: 2rem;
  line-height: 1;
  color: var(--fox);
  margin-bottom: 6px;
}
.timeline__num::before { content: counter(stage, decimal-leading-zero); }

/* The stage photograph. 4:3 because every source image on this site is a 3:4
   portrait phone shot — there is not one landscape original — and 4:3 is the
   shallowest landscape crop, so it discards the least of a tall photo.

   aspect-ratio with height:auto, never height:100%: a <picture> parent has no
   definite height for a percentage to resolve against, which is what made every
   full-bleed image on the site overflow on 14 Aug. */
.timeline__media { margin: 18px 0 0; }
.timeline__media img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: var(--radius-card);
}
.timeline__media--low img { object-position: 50% 70%; }
.timeline__media--high img { object-position: 50% 30%; }

/* Placeholder for a stage whose photograph does not exist yet. Deliberately
   loud: it doubles as the brief for Ian, and it must never be mistaken for a
   finished box. check-links lists these as warnings; verify-build.mjs REFUSES
   a deployed URL that still contains one, so it cannot reach handover. */
.timeline__media--todo {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 4 / 3;
  padding: 24px;
  border: 2px dashed var(--fox);
  border-radius: var(--radius-card);
  text-align: center;
}
.timeline__media--todo p {
  margin: 0;
  color: var(--fox-dark);   /* --fox is 4.05:1 on sand */
  font-weight: 600;
  font-size: 0.9375rem;
  max-width: 30ch;
}

/* --- The flow, at desktop ------------------------------------------------ */

/* Four zones per row: stage label, the rail, numeral and title, then the
   detail. The detail used to sit in a 68ch column, which left the right-hand
   half of a 1232px row empty at every width — that emptiness is the whole
   reason for this layout.

   Taken from a reference Sam supplied. Its STRUCTURE, not its styling: that was
   dark, neon and monospaced, and this site is sand, forest and fox red with
   hairlines, no shadows and three radii. */
@media (min-width: 56.25em) {
  .timeline {
    /* Centre of the rail column, measured from the left edge of the list:
       150px label + 28px gap + half of the 28px rail column. */
    --rail-x: 192px;
  }
  .timeline::before { left: var(--rail-x); }

  .timeline__item {
    display: grid;
    grid-template-columns: 150px 28px minmax(0, 1fr) minmax(0, 1.05fr);
    column-gap: 28px;
    align-items: start;
    padding: 0 0 48px;
  }
  .timeline__item::before { left: calc(var(--rail-x) - 7px); top: 6px; }

  .timeline__when {
    grid-column: 1;
    text-align: right;
    margin: 0;
    padding-top: 4px;
  }
  /* Two rows now: title above its photograph in column 3, with the panel
     spanning both in column 4 and hugging the top, so it stays level with the
     numeral exactly as it did before the photographs arrived. A stage with no
     media simply leaves row 2 empty and the row collapses. */
  .timeline__head { grid-column: 3; grid-row: 1; position: relative; }
  .timeline__media { grid-column: 3; grid-row: 2; }
  .timeline__detail { grid-column: 4; grid-row: 1 / 3; align-self: start; }

  /* The elbow. One box drawing two of its borders: the bottom edge runs right
     from the title column into the gap, then the right edge turns up to meet
     the panel at its first line. No extra markup, and nothing to keep in sync
     if the row grows taller. */
  .timeline__head::after {
    content: "";
    position: absolute;
    right: -28px;
    top: 8px;
    width: 28px;
    height: 26px;
    border-bottom: 1px solid var(--rule-strong);
    border-right: 1px solid var(--rule-strong);
  }
}

/* --- Text paired with a photograph --------------------------------------- */

/* Generalised from `.dog-block` below, deliberately sharing its geometry so a
   split on a guide and a dog block on Meet Our Family read as one system. */
.split {
  display: grid;
  gap: 28px;
  align-items: center;
}
@media (min-width: 56.25em) {
  .split { grid-template-columns: 1fr 1fr; gap: 56px; }
  /* Alternate which side the photograph sits on, so a run of splits does not
     march down the page in one column. */
  .split--reverse .split__media { order: 2; }
}

/* `<picture>` is display:inline by default, and an inline box with auto height
   is precisely what broke the hero and the closing band earlier in this build.
   Declare it. */
.split__media picture { display: block; }

/* Every photograph in the library is a 3:4 portrait. Dropped into a half-width
   column that is a ~760px image beside a ~200px paragraph, which ADDS dead
   space rather than filling it — the opposite of the point. So the split crops
   to landscape.

   `aspect-ratio` with `height: auto` gives the box a definite height derived
   from its own width, so `object-fit` has something to crop into. That is the
   safe way to do it: the earlier full-bleed bug came from `height: 100%`, a
   percentage that could not resolve against an auto-height parent.

   The dogs' faces sit high in these frames, so the crop is biased above centre.
   A dead-centre crop clips the top of the head. */
.split__media img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  object-position: 50% 35%;
  border-radius: var(--radius-card);
}
/* For frames where the subject is low or the whole body matters. */
.split__media--tall img { aspect-ratio: 1 / 1; }
.split__media--centre img { object-position: 50% 50%; }
.split__media--low img { object-position: 50% 72%; }
.split__text > :first-child { margin-top: 0; }
.split__text > :last-child { margin-bottom: 0; }
.split__text p { color: var(--ink-body); }

.field { margin-bottom: 20px; }
/* Full --ink, not --ink-body: a label is the instruction you read while typing,
   not prose. 17.4:1 on the white card. */
.field label {
  display: block;
  font-size: 0.9375rem;
  font-weight: 600;
  color: var(--ink);
  margin-bottom: 6px;
}
.field .hint { font-size: 0.875rem; color: var(--ink-caption); font-weight: 400; }

.field input,
.field select,
.field textarea {
  width: 100%;
  font-family: var(--body);
  font-size: 1rem;
  color: var(--ink);
  background: var(--sand);
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius-tile);
  padding: 13px 14px;
}
.field textarea { min-height: 120px; resize: vertical; }
.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible { background: var(--white); }
.field input[aria-invalid='true'],
.field select[aria-invalid='true'] { border-color: #B3261E; }

.field-error { display: block; margin-top: 6px; font-size: 0.875rem; color: #B3261E; }

.field--check { display: flex; gap: 12px; align-items: flex-start; }
/* 24x24, up from the browser default 13x13. The label is clickable too, so the
   real target is larger again, but the box itself has to be findable. */
.field--check input { width: 24px; height: 24px; margin-top: 2px; flex: 0 0 auto; }
.field--check label { font-weight: 400; }

/* The honeypot. Never seen by a person, so anything in it is a bot. */
.hp {
  position: absolute !important;
  left: -9999px !important;
  width: 1px; height: 1px;
  overflow: hidden;
}

.alert {
  border-radius: var(--radius-tile);
  padding: 18px 20px;
  margin-bottom: 24px;
  border: 1px solid transparent;
}
.alert h2 { font-size: 1.125rem; margin-bottom: 6px; }
.alert p:last-child, .alert ul:last-child { margin-bottom: 0; }
.alert--ok { background: #EAF3EC; border-color: #BFD9C6; }
.alert--error { background: #FBEAE8; border-color: #E7BDB8; }
.alert--error h2 { color: #8C1D18; }

.form-note { font-size: 0.875rem; color: var(--ink-caption); margin-top: 18px; }

@media (min-width: 40em) {
  .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0 20px; }
}

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

@keyframes catrigg-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes catrigg-rise {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: none; }
}

.hero__media img { animation: catrigg-fade 600ms ease both; }
.hero__content .h-hero { animation: catrigg-rise 400ms ease 600ms both; }
.hero__sub              { animation: catrigg-rise 400ms ease 680ms both; }
.hero__actions          { animation: catrigg-rise 400ms ease 760ms both; }
.hero__proof            { animation: catrigg-rise 400ms ease 840ms both; }

/* Scroll reveal.
   Content is visible by DEFAULT and only hidden once JavaScript has confirmed
   it can reveal it again — hence the .js-reveal class on <html>, which main.js
   adds just before it starts observing.
   The obvious way round (hide in CSS, reveal with JS) means that if the script
   never runs, or the IntersectionObserver callback never fires, four homepage
   sections stay at opacity 0 with no way back. Content should not depend on a
   script to become visible. */
[data-reveal] { transition: opacity 400ms ease, transform 400ms ease; }
.js-reveal [data-reveal] { opacity: 0; transform: translateY(16px); }
.js-reveal [data-reveal].is-in { opacity: 1; transform: none; }

/* Off, not reduced. Someone who asks for no motion gets no motion. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
  }
  .js-reveal [data-reveal] { opacity: 1; transform: none; }
  .reviews-track { transition: none !important; }
}

/* --- Consent banner ------------------------------------------------------
   Lifted from the Catrigg Air Conditioning build, 20 August 2026, and remapped
   onto this site's tokens: --paper became --sand, the spacing scale became
   literal pixels, and the two radii come from the three this site allows. No
   box-shadow, per the rule at the top of this file.

   The block is namespaced `cb-` and that is not decoration. On Aircon it
   shipped as `.consent` and collided with the enquiry form's own
   <div class="consent"> around its consent checkbox: position: fixed, inset: 0
   and pointer-events: none landed on the form field, which jumped over the
   masthead and could not be ticked at all. `consent` is the obvious class name
   for a consent CHECKBOX on any build, so the banner must never claim it. This
   site has no `.consent` today — checked before copying — and it must stay that
   way while this block exists.
   -------------------------------------------------------------------------- */

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

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

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

.cb-consent__panel {
  position: relative;
  pointer-events: auto;
  width: 100%;
  max-width: 760px;
  background: var(--forest);
  color: var(--sand);
  border-radius: var(--radius-card);
  padding: 28px 28px 24px;
  display: grid;
  gap: 20px;
}

.cb-consent__title {
  font-family: var(--display);
  font-size: 1.375rem;
  line-height: 1.2;
  margin: 0 0 12px;
  color: var(--sand);
}

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

/* Inline links default to --fox-dark, which is 1.91:1 on forest — the same
   pairing that had to be fixed across the site in the August contrast sweep.
   Sand, underlined, on a dark ground: the treatment the footer links take. */
.cb-consent__body a {
  color: var(--sand);
  text-decoration: underline;
  text-underline-offset: 3px;
}
@media (hover: hover) and (pointer: fine) {
  .cb-consent__body a:hover { color: var(--white); }
}

.cb-consent__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

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

@media (hover: hover) and (pointer: fine) {
  .cb-consent__choice:hover { background: var(--white); border-color: var(--white); }
}
.cb-consent__choice:active { background: var(--white); border-color: var(--white); }

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

@media (hover: hover) and (pointer: fine) {
  .cb-consent__close:hover { background: var(--rule-ondark); }
}
.cb-consent__close:active { background: rgba(245, 239, 230, 0.24); }

/* The dark-ground focus ring. The banner is appended to <body> rather than
   living inside .band-forest or .site-footer, so it is not covered by the
   reversed-ring rule further up this file and needs its own. */
.cb-consent :focus-visible { outline-color: var(--sand); }

/* Change your cookie choice, in the footer on every page. A <button>, so the
   `.site-footer a` colour rule does not reach it — the colour is set here.
   Sized to sit with the licence line rather than shout over it. */
.cb-consent-reopen {
  /* 44px tall, like every other touch target on this site. Measured at 390 it
     was 168x16, which clears WCAG 2.5.8 only by the spacing exception — the
     nearest other target is 195px away. Passing by exception is not the same as
     being easy to hit with a thumb, and the house standard is 44. */
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-family: var(--body);
  font-size: inherit;
  color: var(--sand);
  background: none;
  border: 0;
  padding: 0;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
  .cb-consent-reopen:hover { color: var(--white); }
}

/* --- Instagram: nav, phone menu, and the hero corner -----------------------
   The icon itself is inline SVG with a <radialGradient>, because the CSP is
   `style-src 'self'` and a CSS gradient in a style attribute would be blocked
   silently. Each instance carries its own gradient id — see partials/header.html.
   -------------------------------------------------------------------------- */

/* Specificity note: `.site-nav a:not(.btn)` further up this file sets a colour
   on every non-button link in the nav, and it would reach this one. It does no
   harm — the artwork is a gradient fill and white strokes, not currentColor —
   but the sizing has to out-specify it, hence `.site-nav .nav-ig`. */
.site-nav .nav-ig {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 44px of TARGET around a 26px icon. The visible mark is smaller than the
     tappable area on purpose: it sits in a row of text links and a large
     coloured square would out-shout the enquiry button beside it, which is the
     one action this site actually wants. */
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  border-radius: var(--radius-tile);
  transition: transform 200ms ease;
}
/* 34px of ink inside the same 44px target. Was 26, which read as a smudge beside
   the enquiry button at desktop sizes. The target is unchanged, so nothing in the
   nav row reflows — this is only how much of it is drawn. */
.site-nav .nav-ig svg { display: block; width: 34px; height: 34px; }

@media (hover: hover) and (pointer: fine) {
  .site-nav .nav-ig:hover { transform: translateY(-2px); }
}

/* In the phone header bar, beside the Menu button. Same 44px target and 34px of
   ink as the nav mark, so the two instances of this icon are the same size as
   each other at the width each one appears at — they are never on screen
   together, and a mark that changed size across the breakpoint would look like a
   mistake to anyone resizing a window.

   No colour rule needed and none wanted: the artwork is a gradient fill with
   white strokes, not currentColor, so nothing in the header cascade reaches it. */
.nav-ig--bar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  border-radius: var(--radius-tile);
  transition: transform 200ms ease;
}
.nav-ig--bar svg { display: block; width: 34px; height: 34px; }
@media (hover: hover) and (pointer: fine) {
  .nav-ig--bar:hover { transform: translateY(-2px); }
}

/* In the phone menu it is a labelled row, not a bare icon. A lone glyph at the
   foot of a full-screen menu reads as decoration; the words make it a link. */
.mobile-menu .nav-ig--menu {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 44px;
  margin-top: 24px;
  font-family: var(--body);
  font-size: 1rem;
  color: var(--sand);
  text-decoration: none;
}
.mobile-menu .nav-ig--menu svg { display: block; width: 32px; height: 32px; flex: 0 0 auto; }

/* --- The hero corner block ------------------------------------------------ */

/* Hidden by default. It only exists at widths where the hero text does not run
   the full page — see the note in index.html. */
.hero__social { display: none; }

@media (min-width: 75em) {   /* 1200px */
  .hero__content { position: relative; }

  /* The notice fills the whole upper-right of the hero, from the end of the
     headline column to the right-hand edge of the content. Sam's call, 2 Oct
     2026, and it is what makes the full statement fit: in the narrower box he
     first drew it wrapped to eight lines and ran taller than the box itself.

     `left: 560px` is the 520px headline column plus a 40px channel, so the panel
     can never collide with the longest line of the h1.

     `top: 96px`, NOT the 160px that would line it up with the eyebrow. Ian's
     statement runs to 313px even in a 1016px-wide box, and starting it at the
     eyebrow pushed its bottom edge to y537 — straight through the dogs, undoing
     §10a and §10b. 96px starts it just under the 80px header, which is where Sam
     drew it, and buys all 64 of those pixels back off the animals. */
  .hero-notice {
    position: absolute;
    top: 96px;
    left: 560px;
    right: var(--gutter);
    margin-top: 0;
    padding: 22px 24px;
  }

  .hero__social {
    position: absolute;
    right: var(--gutter);
    /* TOP, not bottom, from 27 Aug 2026. It used to sit at `bottom: 20px`, which
       on the old photograph was empty hillside. On the new one that corner is a
       dog: measured on the rendered page, the panel covered 2,143px² of head and
       5,997px² of coat at 1850, and 6,762 / 10,466 at 1200 — and it is opaque, so
       what it covers is gone rather than dimmed.

       160px matches `.hero__content`'s own padding-top, which lines the panel's
       top edge up with the eyebrow at every width without a second number to keep
       in sync. It lands on the treeline, the darkest ground in the frame, and
       covers ZERO dog at 1200, 1280, 1440 and 1850. The text inside reads 13.17
       to 13.74 there, against 10.91 in the old corner.

       BACK TO THE BOTTOM, 2 Oct 2026 — the notice now occupies the top-right and
       there is nowhere else for this to go. Sam asked for it below the end dog.
       The 27 Aug measurement above is the thing to re-check, not to trust: it is
       a different corner now that the hero is a different height, so the dog
       coverage was re-measured rather than assumed. See BUILD-NOTES §10c for what
       it actually covers at each width. */
    top: auto;
    bottom: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    width: 236px;
    padding: 24px 20px;
    border-radius: var(--radius-card);
    /* Its own ground. The scrim behind this corner was lightened to show the
       dogs, so there is nothing out here to carry white text. Measured on the
       rendered page rather than assumed. */
    background: rgba(20, 26, 22, 0.78);
    border: 1px solid var(--rule-ondark);
    text-align: center;
    text-decoration: none;
    transition: background-color 200ms ease, transform 200ms ease;
  }

  .hero__social-icon { display: block; width: 64px; height: 64px; }
  .hero__social-icon svg { display: block; width: 100%; height: 100%; }

  .hero__social-text {
    font-family: var(--body);
    font-size: 1rem;
    font-weight: 600;
    line-height: 1.3;
    color: var(--sand);
  }

  @media (hover: hover) and (pointer: fine) {
    .hero__social:hover {
      background: rgba(20, 26, 22, 0.88);
      transform: translateY(-2px);
    }
  }

  .hero__social:focus-visible { outline-color: var(--sand); }
}
