@charset "utf-8";
/* ─────────────────────────────────────────────────────────────────────────────
   williamstewart.co.uk

   One hand-written stylesheet. No preprocessor, no build step, no framework.
   CSS nesting became Baseline Widely Available on 11 June 2026, so nesting +
   custom properties + @layer now covers most of what Sass was for.

   ── COLOUR, AND WHY THESE VALUES ───────────────────────────────────────────
   Measured across 36 live sites: 21 use a warm off-white ground, and the real
   high-end cluster sits at R−B +4 to +10. Above +15 is a block surface, never
   the page ground – that is the line between "warm" and the beige/Canva cliché.
   #faf7f2 is +8.

   The accent is the important one. Contrast was computed for every warm accent
   in the corpus and they overwhelmingly FAIL body text: coral 2.34:1, orange
   2.62:1, tan 2.84:1, terracotta 3.80:1, vermilion 4.00:1. Brick #8f392b is the
   only one that passes unmodified at 6.62:1. Sites using the others quietly ship
   a second darkened tone for links.

   So if this accent is ever changed, it needs TWO tones – a bright one for
   decoration and a darkened sibling (≈0.72–0.8×) for anything carrying text.
   Do not just swap the hex.
   ──────────────────────────────────────────────────────────────────────────── */

/* ── FONTS ─────────────────────────────────────────────────────────────────
   Two files, latin subset, axes pinned. 66.7KB total.

   Fraunces carries wght + WONK. WONK is free – measured at 36,596 bytes against
   36,600 for weight alone, i.e. four bytes CHEAPER for the wonky glyph
   substitution that does the "a human made this" work. The full five-axis
   Fraunces is 121KB; never request it.

   font-display: swap, deliberately. `fallback` and `block` now FAIL the current
   DevTools font insight – only swap and optional pass. And `optional` gives the
   font ~100ms before abandoning it for the whole page load, which on a throttled
   connection means rendering in Roboto much of the time: scores beautifully,
   looks wrong. */

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

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

/* Metric-matched fallbacks – this is the CLS fix, and CLS is 25% of the score.

   Safari has NEVER supported ascent-override / descent-override /
   line-gap-override, any version. size-adjust is Safari 17+. So size-adjust is
   the base (it fixes width, which is the dominant CLS term for headings that
   wrap) and the three overrides refine Chromium and Firefox.

   ascent/descent/line-gap below are measured from the real font tables.

   TODO: size-adjust values are approximated from the x-height ratio against
   Arial (51.9%). The correct input is the character-FREQUENCY-WEIGHTED mean
   advance width, NOT the OS/2 xAvgCharWidth field – using the field gives a
   value ~18 points too wide, which CREATES layout shift rather than fixing it.
   Worth computing properly with fonttools before launch. These are conservative
   and close to 100%, so the current risk is small either way.

   src: local() only, never url(). Two fallbacks listed because a browser that
   can't find the first skips the rule and falls through. */

@font-face {
  font-family: 'Fraunces-fb';
  src: local('Georgia'), local('Times New Roman');
  size-adjust: 99%;
  ascent-override: 97.80%;
  descent-override: 25.50%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'InstrumentSans-fb';
  src: local('Arial'), local('Helvetica');
  size-adjust: 98.3%;
  ascent-override: 97.00%;
  descent-override: 25.00%;
  line-gap-override: 0%;
}

/* ── TOKENS ────────────────────────────────────────────────────────────────
   Four colour tokens do most of the work, following the pattern measured on the
   tightest site in the corpus, which ships its entire palette in four custom
   properties. */

:root {
  --bg:        #faf7f2;
  --surface:   #f3ede4;
  --ink:       #1c1a17;   /* 16.25:1 */
  --ink-2:     #2b2724;   /* 13.85:1 */
  --muted:     #57504a;   /*  7.41:1 – do NOT lighten */
  --caption:   #6f665f;   /*  5.25:1 */
  --rule:      #e6e0d7;
  --accent:    #8f392b;   /*  6.62:1 – safe for body text */
  --accent-lo: #7a3025;
  --accent-wash: #f5ebe6;

  --serif: 'Fraunces', 'Fraunces-fb', Georgia, 'Times New Roman', serif;
  --sans:  'Instrument Sans', 'InstrumentSans-fb', Arial, Helvetica, sans-serif;

  /* Fluid type.

     ⚠ THE CLAMP ACCESSIBILITY TRAP: with vw units, zooming shrinks the viewport,
     which shrinks the font, which cancels the zoom – a WCAG 1.4.4 (AA) failure.
     Every clamp below keeps the rem term DOMINANT and the vw contribution small,
     which is what makes zoom still work. Do not "simplify" these to
     clamp(1rem, 5vw, 3rem). */
  --step--1: clamp(0.875rem, 0.855rem + 0.10vw, 0.9375rem);
  --step-0:  clamp(1.0625rem, 1.021rem + 0.21vw, 1.1875rem);   /* 17 → 19px */
  --step-1:  clamp(1.25rem, 1.19rem + 0.30vw, 1.4375rem);
  --step-2:  clamp(1.5rem, 1.39rem + 0.55vw, 1.875rem);
  --step-3:  clamp(1.875rem, 1.66rem + 1.07vw, 2.625rem);
  --step-4:  clamp(2.375rem, 1.95rem + 2.13vw, 3.875rem);
  --step-5:  clamp(2.875rem, 2.09rem + 3.91vw, 5.5rem);

  --measure: 34rem;
  --wrap: 74rem;
  --wrap-narrow: 46rem;

  --gap: clamp(1.25rem, 1rem + 1.2vw, 2.25rem);
  --sec-y: clamp(4rem, 3rem + 5vw, 8rem);

  /* easeOutExpo – the dominant "premium" easing in the measured corpus.
     Durations cluster hard at 0.3s and 0.5s, so those are the only two here. */
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --fast: 0.3s;
  --slow: 0.5s;

  /* Spring, via linear(). Real spring physics with zero JS – Baseline since
     Safari 17.2. This is what makes motion feel like a framework without
     shipping one. */
  --spring: linear(
    0, 0.006, 0.025 2.8%, 0.101 6.1%, 0.539 18.9%, 0.721 25.3%, 0.849 31.5%,
    0.937 38.1%, 0.968 41.8%, 0.991 45.7%, 1.006 50.1%, 1.015 55%, 1.017 63.9%,
    1.001 100%
  );
}

/* Cross-document view transitions.

   Four lines. Zero JavaScript. Works across static PHP pages with no router.
   Chrome 126+, Safari 18.2+; Firefox silently does an ordinary navigation.
   Usage across 36 award-winning sites surveyed: zero. Cheapest available
   differentiation.

   Nothing respects reduced motion automatically here – verified negative for
   both View Transitions and scroll-driven animations – so the opt-out below is
   required, not belt-and-braces. */
@view-transition { navigation: auto; }

::view-transition-old(root) { animation: 180ms ease-out both vt-out; }
::view-transition-new(root) { animation: 220ms ease-in both vt-in; }
@keyframes vt-out { to { opacity: 0 } }
@keyframes vt-in { from { opacity: 0 } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* ── RESET ─────────────────────────────────────────────────────────────────── */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Anchored sections shouldn't hide under the sticky header. */
  scroll-padding-top: 5rem;
}
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--step-0);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

h1, h2, h3 { margin: 0; line-height: 1.08; text-wrap: balance; font-weight: 600; }
p { margin: 0; text-wrap: pretty; }
ul, ol { margin: 0; padding: 0; list-style: none; }
img, svg { display: block; max-width: 100%; height: auto; }
a { color: inherit; }

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

.skip {
  position: absolute; left: -9999px; top: 0;
  background: var(--ink); color: var(--bg);
  padding: 0.75rem 1.25rem; z-index: 100;

  &:focus { left: 0; }
}

/* ── GRAIN ─────────────────────────────────────────────────────────────────
   Inline SVG turbulence as a data URI: ~400 bytes, zero extra HTTP requests, and
   the single cheapest thing that stops a flat warm background reading as a
   template. Kept very low opacity – it should be felt, not seen. */
.grain {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 1;
  opacity: 0.035;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* ── LAYOUT ────────────────────────────────────────────────────────────────── */

.wrap {
  width: min(100% - 2.5rem, var(--wrap));
  margin-inline: auto;
}
/* 46rem is about 70–80 characters, the readable range – long lines lose the eye
   on the return sweep. Centred, deliberately.

   TRIED AND REVERTED 18 Aug 2026: left-aligning this to share the wide wrap's
   left edge. The reasoning looked sound – measured on /about/, the hero and
   trust bar start at 128px and the prose at 352, a 224px step inward. But
   left-aligned narrow prose leaves the whole right half empty, and that reads
   as a MISSING SIDEBAR rather than as a margin. Centring is what tells a reader
   the space is deliberate.

   So the step-in stays. If it needs solving, the answer is to put something in
   the right column, not to shove the text left. */
.wrap--narrow { width: min(100% - 2.5rem, var(--wrap-narrow)); }

.sec { padding-block: var(--sec-y); position: relative; z-index: 2; }

.sec__h {
  font-family: var(--serif);
  font-size: var(--step-3);
  font-variation-settings: 'WONK' 1, 'SOFT' 0;
  letter-spacing: -0.02em;
  max-width: 20ch;
  margin-block: 0.4em 0;
}

.sec__intro {
  max-width: var(--measure);
  margin-top: 1.1rem;
  color: var(--muted);
  font-size: var(--step-1);
  line-height: 1.5;
}

/* Uppercase micro-label at +0.03em. Used on 17 of 36 measured sites and the
   strongest single "not a template" typographic tell available. */
.eyebrow {
  font-size: var(--step--1);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  font-weight: 600;
  color: var(--accent);
  margin: 0;
}

/* ── BUTTONS ───────────────────────────────────────────────────────────────
   Exactly one border-radius value in the whole stylesheet, used sparingly.
   Sites that read as bespoke use either no radius or one value; uniform 16px
   radius everywhere is part of the measured "AI slop" signature. */
.actions { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; }

.btn {
  --btn-bg: var(--accent);
  --btn-fg: #fff;
  display: inline-block;
  padding: 0.85em 1.5em;
  font: inherit;
  font-weight: 600;
  font-size: var(--step-0);
  text-decoration: none;
  border-radius: 3px;
  background: var(--btn-bg);
  color: var(--btn-fg);
  border: 1.5px solid var(--btn-bg);
  transition: transform var(--fast) var(--ease), background var(--fast) var(--ease);

  &:hover { background: var(--accent-lo); border-color: var(--accent-lo); }
  &:active { transform: translateY(1px); }
}

.btn--ghost {
  --btn-bg: transparent;
  --btn-fg: var(--ink);
  border-color: var(--rule);

  &:hover { background: var(--surface); border-color: var(--ink); }
}

.btn--big { font-size: var(--step-1); padding: 0.9em 1.8em; }

.link-arrow {
  color: var(--accent);
  font-weight: 600;
  text-decoration: none;
  border-bottom: 1.5px solid currentColor;
  padding-bottom: 2px;

  &::after { content: ' →'; transition: none; }
  &:hover { color: var(--accent-lo); }
}

/* ── HEADER ────────────────────────────────────────────────────────────────── */

.site-head {
  position: sticky;
  top: 0;
  z-index: 20;
  /* OPAQUE, corrected 17 Aug 2026. This was 88% with the note below explaining
     why there is no backdrop-filter – which was and is the right call on
     performance. But 12% transparency WITHOUT a blur behind it is not a frosted
     panel, it is a smudge: body text scrolling underneath showed through as a
     ghost across the logo and the nav, which is exactly what it looked like.
     The colour-mix was doing the cost of a translucent layer and buying nothing.
     Opaque, so the header stays readable over any content that passes beneath. */
  background: var(--bg);
  border-bottom: 1px solid var(--rule);
  /* Still deliberately NOT backdrop-filter. Measured 15–30% FPS drops on real
     devices, worst on Android – which is exactly the audience. */
}

.site-head__in {
  display: flex;
  align-items: center;
  gap: var(--gap);
  min-height: 4.5rem;
  /* Positioning context for the mobile menu panel.

     It was on .menu – anchoring the panel to the BUTTON – which looked right on
     a tablet and broke on a phone, where the button is not the rightmost thing
     in the header (the phone number is). The panel ran 63px off the left edge
     and was silently clipped by body{overflow-x:hidden}, so menu items were cut
     off with no scrollbar to hint at it. Anchoring to the header row means the
     panel spans the wrap and therefore always fits. */
  position: relative;
}

.brand {
  text-decoration: none;
  display: grid;
  line-height: 1.15;
  margin-right: auto;
}
.brand__name { font-family: var(--serif); font-size: var(--step-1); font-weight: 600; letter-spacing: -0.015em; }
.brand__where { font-size: var(--step--1); color: var(--caption); letter-spacing: 0.04em; text-transform: uppercase; }

.nav ul { display: flex; gap: 1.5rem; }
.nav a {
  text-decoration: none;
  font-size: var(--step--1);
  font-weight: 500;
  color: var(--muted);
  padding-block: 0.5rem;
  border-bottom: 2px solid transparent;
  transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);

  &:hover { color: var(--ink); border-bottom-color: var(--accent); }
}

.head-phone {
  text-decoration: none;
  display: grid;
  text-align: right;
  line-height: 1.2;
}
.head-phone__label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--caption); }
.head-phone__num { font-weight: 700; color: var(--accent); font-size: var(--step-0); white-space: nowrap; }

/* ── MOBILE MENU ───────────────────────────────────────────────────────────
   <details>/<summary>, no JavaScript. The site had NO navigation below 60rem
   for several commits – the desktop nav was simply display:none with a TODO –
   which on a tablet left the footer as the only way to move around. Worth
   recording: a hidden element with a TODO beside it reads as "in progress" in
   the source and as "broken" to a visitor, and only one of those is true. */
.menu { display: none; }

.menu__btn {
  display: flex; align-items: center; gap: 0.55rem;
  cursor: pointer; list-style: none;
  padding: 0.55rem 0.85rem;
  border: 1.5px solid var(--rule);
  border-radius: 3px;
  font-size: var(--step--1); font-weight: 600;
  user-select: none;

  &::-webkit-details-marker { display: none; }
  &:hover { border-color: var(--ink); }
}
.menu__bars { display: grid; gap: 3.5px; width: 1rem; }
.menu__bars i {
  display: block; height: 2px; background: var(--ink); border-radius: 1px;
  transition: transform var(--fast) var(--ease), opacity var(--fast) var(--ease);
}
/* Bars morph to a cross when open. Transform and opacity only, so it composites. */
.menu[open] .menu__bars i:nth-child(1) { transform: translateY(5.5px) rotate(45deg); }
.menu[open] .menu__bars i:nth-child(2) { opacity: 0; }
.menu[open] .menu__bars i:nth-child(3) { transform: translateY(-5.5px) rotate(-45deg); }

.menu__panel {
  position: absolute;
  top: calc(100% + 0.6rem);
  /* Spans the header wrap rather than hanging off the button, so it cannot
     overflow regardless of what else is in the header. */
  left: 0;
  right: 0;
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 3px;
  box-shadow: 0 12px 32px -12px rgb(28 26 23 / 0.22);
  padding: 0.5rem;
  z-index: 30;
}
.menu__list {
  display: grid;

  & a {
    display: block;
    padding: 0.75rem 0.85rem;
    text-decoration: none;
    font-size: var(--step-0);
    font-weight: 500;
    border-radius: 2px;

    &:hover { background: var(--surface); color: var(--accent); }
  }
  /* aria-current is set server-side; styling it means the open menu tells you
     where you already are, which a nav that only ever highlights on hover does not. */
  & [aria-current] { color: var(--accent); font-weight: 700; }
}
.menu__foot {
  margin-top: 0.5rem; padding: 0.85rem;
  border-top: 1px solid var(--rule);
  display: grid; gap: 0.75rem; justify-items: stretch;
}
.menu__foot .btn { text-align: center; }
.menu__tel {
  text-align: center; font-weight: 700; color: var(--accent);
  text-decoration: none; font-size: var(--step-0);
}

@media (prefers-reduced-motion: no-preference) {
  .menu[open] .menu__panel {
    animation: menu-in 0.22s var(--ease) both;
  }
  @keyframes menu-in {
    from { opacity: 0; transform: translateY(-6px); }
    to   { opacity: 1; transform: none; }
  }
}

@media (max-width: 60rem) {
  .nav { display: none; }
  .menu { display: block; }
}
@media (max-width: 30rem) {
  .head-phone__label { display: none; }
  .brand__where { display: none; }
}

/* ── HERO ──────────────────────────────────────────────────────────────────── */

.hero {
  padding-block: clamp(3.5rem, 2.5rem + 5vw, 7rem) clamp(2.5rem, 2rem + 3vw, 4.5rem);
  position: relative;
  z-index: 2;
}

.hero__h {
  font-family: var(--serif);
  font-size: var(--step-5);
  font-variation-settings: 'WONK' 1, 'SOFT' 0;
  letter-spacing: -0.028em;
  max-width: 17ch;
  margin-top: 0.5rem;
}

/* The name gets its own line and the accent colour. Every strong competitor
   site opens with a first name; the two that tried to look bigger than they
   were came off worst. */
.hero__name {
  display: block;
  color: var(--accent);
  font-style: italic;
  font-variation-settings: 'WONK' 1;
}

.hero__sub {
  margin-top: 1.6rem;
  max-width: 46ch;
  font-size: var(--step-1);
  line-height: 1.55;
  color: var(--muted);
}

.avail {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  margin-top: 1.6rem;
  padding: 0.4rem 0.85rem 0.4rem 0.7rem;
  background: var(--accent-wash);
  border: 1px solid color-mix(in oklab, var(--accent) 22%, transparent);
  border-radius: 999px;
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--accent-lo);
}
.avail__dot {
  width: 0.5rem; height: 0.5rem; border-radius: 50%;
  background: var(--accent);
  flex: none;
}
@media (prefers-reduced-motion: no-preference) {
  .avail__dot { animation: pulse 2.6s var(--ease) infinite; }
  @keyframes pulse {
    0%, 100% { opacity: 1; transform: scale(1); }
    50% { opacity: 0.45; transform: scale(0.82); }
  }
}

/* ── TRUST STRIP ───────────────────────────────────────────────────────────── */

.trust {
  border-block: 1px solid var(--rule);
  background: var(--surface);
  position: relative;
  z-index: 2;
}
.trust__list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  margin: 0;
}
.trust__item {
  /* The divider is a right border, so BOTH sides of it need clearance. There was
     padding-right only, which meant each item's text sat hard against the
     PREVIOUS item's border – measured at 0px gap. First item keeps a flush left
     edge so it lines up with .wrap and with the hero above it. */
  padding: 1.5rem 1rem;
  border-right: 1px solid var(--rule);

  &:first-child { padding-left: 0; }
  &:last-child { border-right: 0; padding-right: 0; }
}
.trust__item dt {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--caption); margin-bottom: 0.3rem;
}
.trust__item dd {
  margin: 0;
  font-family: var(--serif);
  font-size: var(--step-1);
  font-weight: 600;
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.015em;
}
@media (max-width: 40rem) {
  .trust__item { border-right: 0; padding-block: 1rem; }
}

/* ── PROBLEM LIST ──────────────────────────────────────────────────────────── */

.probs {
  margin-top: clamp(2.5rem, 2rem + 2vw, 4rem);
  display: grid;
  gap: 0;
  counter-reset: p;
}
.prob {
  display: grid;
  grid-template-columns: 4rem 1fr;
  gap: 0 var(--gap);
  padding-block: 1.75rem;
  border-top: 1px solid var(--rule);
  align-items: start;

  &:last-child { border-bottom: 1px solid var(--rule); }
}
.prob__n {
  font-family: var(--serif);
  font-size: var(--step-1);
  color: var(--accent);
  font-variation-settings: 'WONK' 1;
  opacity: 0.75;
  grid-row: span 2;
}
.prob__h { font-size: var(--step-1); letter-spacing: -0.012em; }
.prob__b { color: var(--muted); margin-top: 0.4rem; max-width: 56ch; }

@media (max-width: 40rem) {
  .prob { grid-template-columns: 1fr; gap: 0.35rem; }
  .prob__n { grid-row: auto; font-size: var(--step-0); }
}

/* ── SERVICES ──────────────────────────────────────────────────────────────── */

/* Five services on an auto-fit grid leaves a single orphan card with a dead
   cell beside it – it reads as a broken layout rather than a deliberate one.
   Fixed by pinning to 3 columns at desktop and letting the last card span the
   remainder: 3 on row one, then 1 + 1-spanning-2. Balanced at any of 3/4/5/6
   items, so adding a sixth service later doesn't reintroduce the problem. */
.svcs {
  margin-top: clamp(2.5rem, 2rem + 2vw, 4rem);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
}

@media (min-width: 58rem) {
  /* SIX columns, not three, so a short last row can be balanced rather than
     patched. Each card normally spans 2, giving the same three-up rhythm.

     The previous rule widened a lone trailing card to span 2 of 3, which with
     five services produced a 1 + 2 bottom row – visibly lopsided, and it read
     as a mistake rather than a choice. On six columns the two cards in a short
     row take 3 each instead, so the row fills evenly and the halves line up.

     grid-auto-rows: 1fr equalises row heights. Without it rows size to their
     own content, so the top row stood taller than the bottom purely because its
     copy ran a line longer. */
  .svcs {
    grid-template-columns: repeat(6, 1fr);
    grid-auto-rows: 1fr;
  }
  .svc { grid-column: span 2; }

  /* Two left over → 3 + 3. */
  .svc:nth-last-child(2):nth-child(3n + 1),
  .svc:last-child:nth-child(3n + 2) { grid-column: span 3; }

  /* One left over → full width, which beats a card marooned beside two gaps. */
  .svc:last-child:nth-child(3n + 1) { grid-column: span 6; }
}
.svc { background: var(--bg); }
.svc__link {
  /* Flex column so .svc__meta can be pushed to the foot with margin-top:auto.
     Two things that fixes: the price lines now align across a row regardless of
     how many lines the body runs to (Hosting is a line longer than its
     neighbours), and with equal row heights the slack falls between body and
     price rather than dangling below the price. */
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: 1.75rem 1.5rem 1.5rem;
  text-decoration: none;
  transition: background var(--fast) var(--ease);

  &:hover {
    background: var(--surface);

    & .svc__h { color: var(--accent); }
  }
}
.svc__h {
  font-family: var(--serif);
  font-size: var(--step-1);
  letter-spacing: -0.015em;
  font-variation-settings: 'WONK' 1;
  transition: color var(--fast) var(--ease);
}
.svc__b { margin-top: 0.6rem; color: var(--muted); font-size: var(--step-0); }
.svc__meta {
  /* auto pushes it to the foot of the flex column; the 1rem gap that used to be
     margin-top has to become padding, or it would override the auto. */
  margin-top: auto;
  padding-top: 1rem;
  font-size: var(--step--1);
  font-weight: 700;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--accent);
}

/* ── CARE PLANS ────────────────────────────────────────────────────────────── */

.sec--care { background: var(--surface); border-block: 1px solid var(--rule); }

.plans {
  /* The top margin is doing two jobs: section rhythm, and clearance for
     .plan__flag, which is lifted out of flow above the featured card. row-gap
     carries the same clearance once the cards stack. */
  margin-top: clamp(3rem, 2.5rem + 2vw, 4rem);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  /* Four rows - name, price, description, features - declared on the PARENT so
     each card can subgrid onto them. That is what makes the rule above the
     feature list land on the same line in all three cards.

     min-height on the description cannot do this job: at the narrowest
     three-column width the Priority line wraps to three lines while Standard is
     on one, so any reserved height is either wrong somewhere or wastes a line
     everywhere. Subgrid measures instead of guessing. */
  grid-template-rows: auto auto auto auto;
  column-gap: 1rem;
  row-gap: 2.5rem;
  /* stretch, not start. Three plans with 6, 5 and 6 features should still read
     as one row of equal boxes. */
  align-items: stretch;
}
.plan {
  position: relative;
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 3px;
  padding: 1.5rem 1.35rem;
  display: grid;
  grid-row: span 4;
  grid-template-rows: subgrid;
  /* The spacing between a card's own rows is carried by each child's margin, so
     the card must not also take the parent's 2.5rem row-gap. The parent gap is
     there to clear the featured card's tab once the cards stack, and it still
     applies between cards. */
  row-gap: 0;
}
.plan--featured {
  border-color: var(--accent);
  border-width: 2px;
  /* A pixel off the padding pays for the extra pixel of border, so the featured
     card's title lands on exactly the same line as the other two rather than
     one pixel below. */
  padding: calc(1.5rem - 1px) calc(1.35rem - 1px);
  box-shadow: 0 3px 16px -8px color-mix(in oklab, var(--accent) 45%, transparent);
  /* Square where the tab lands. The tab's bottom-left is already square, so the
     card's rounded top-left was the only thing pulling away from it and leaving
     a notch. Featured card only - the other two keep the full radius. */
  border-top-left-radius: 0;
}
.plan__flag {
  /* Out of flow, so the featured card's title, price and rule sit on the same
     lines as the other two cards'. In the flow it pushed them all ~28px down,
     which is what made the row look broken.

     Filled rather than bare text: floating accent-coloured caps above the card
     read as a stray label with nothing holding it to anything. As a solid tab
     flush against the top edge – same colour as the 2px border, so the two
     read as one shape – it belongs to the card and carries some weight.

     left is -2px to clear the featured card's border, since an absolutely
     positioned child is offset from the PADDING box, not the border box. */
  position: absolute;
  bottom: calc(100% + 2px);
  left: -2px;
  margin: 0;
  padding: 0.35rem 0.7rem 0.3rem;
  background: var(--accent);
  color: var(--bg);
  border-radius: 3px 3px 0 0;
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.09em;
  font-weight: 700; line-height: 1;
}
.plan__h {
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em;
}
.plan__price { margin-top: 0.5rem; display: flex; align-items: baseline; gap: 0.35rem; }
.plan__amt {
  font-family: var(--serif);
  font-size: var(--step-4);
  font-weight: 600;
  letter-spacing: -0.03em;
  font-variation-settings: 'WONK' 1;
  line-height: 1;
}
.plan__per { color: var(--caption); font-size: var(--step--1); }
.plan__for {
  margin-top: 0.6rem; color: var(--muted); font-size: var(--step--1);
  min-height: 2.6em;
}
.plan__f {
  margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--rule);
  display: grid; gap: 0.55rem; font-size: var(--step--1); color: var(--ink-2);
}
.plan__f li {
  position: relative; padding-left: 1.25rem; line-height: 1.45;

  &::before {
    content: '';
    position: absolute; left: 0; top: 0.5em;
    width: 0.5rem; height: 0.5rem;
    border-left: 1.5px solid var(--accent);
    border-bottom: 1.5px solid var(--accent);
    transform: rotate(-45deg);
  }
}

.care__notes {
  margin-top: 1.75rem;
  display: grid;
  gap: 0.4rem;
  font-size: var(--step--1);
  color: var(--muted);
  max-width: 60ch;
}
.care__vat { font-weight: 700; color: var(--ink); }

/* ── ANCHOR ────────────────────────────────────────────────────────────────── */

.sec--anchor { text-align: center; }
.anchor__h {
  font-family: var(--serif);
  font-size: var(--step-3);
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.022em;
  line-height: 1.15;
  text-wrap: balance;
}
.anchor__b { margin-top: 1.1rem; color: var(--muted); font-size: var(--step-1); }

/* ── CLIENTS ───────────────────────────────────────────────────────────────── */

.clients {
  margin-top: clamp(2rem, 1.5rem + 2vw, 3rem);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13.5rem, 1fr));
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
}
.client { background: var(--bg); padding: 1.4rem 1.25rem; display: grid; align-content: start; }
.client__n {
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em; line-height: 1.2;
}
.client__l {
  text-decoration: none;
  border-bottom: 1.5px solid var(--rule);
  transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);

  &:hover { color: var(--accent); border-bottom-color: var(--accent); }
}
.client__d {
  margin-top: 0.6rem;
  font-size: var(--step--1);
  color: var(--muted);
  line-height: 1.45;
}
/* The dates are the point. A link proves a company exists, not that you worked
   on it, and costs a liar nothing – dates are the thing a fabricated list
   doesn't have. */
.client__s {
  margin-top: 0.45rem; font-size: var(--step--1);
  color: var(--accent); font-weight: 600;
  letter-spacing: 0.03em; text-transform: uppercase;
}

/* ── ABOUT ─────────────────────────────────────────────────────────────────── */

.about {
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
  gap: clamp(2rem, 1rem + 4vw, 4rem);
  align-items: center;
}
@media (max-width: 52rem) { .about { grid-template-columns: 1fr; } }

/* aspect-ratio reserves the space, so dropping the real photograph in later
   causes exactly zero layout shift. */
.about__img {
  width: 100%;
  border-radius: 3px;
  /* Reserves the box before the file lands, so swapping the placeholder for the
     real photograph costs zero layout shift. */
  aspect-ratio: 928 / 1152;
  object-fit: cover;
}

/* Kept for pages that have no photograph yet. */
.about__ph {
  aspect-ratio: 4 / 5;
  background:
    repeating-linear-gradient(
      45deg,
      var(--surface) 0 12px,
      color-mix(in oklab, var(--surface) 80%, var(--rule)) 12px 24px
    );
  border: 1px solid var(--rule);
  border-radius: 3px;
  display: grid;
  place-items: center;
  text-align: center;
  color: var(--caption);
  font-size: var(--step--1);
  text-transform: uppercase;
  letter-spacing: 0.09em;
  line-height: 1.7;
}
.about__p { margin-top: 1.1rem; color: var(--muted); font-size: var(--step-1); line-height: 1.55; }
.about__body > p:last-child { margin-top: 1.6rem; }

/* ── FAQ ───────────────────────────────────────────────────────────────────
   <details name="faq"> is a zero-JS exclusive accordion – Baseline since
   Sept 2024. Opening one closes the others with no script at all. */

.faq { margin-top: clamp(2rem, 1.5rem + 2vw, 3rem); border-top: 1px solid var(--rule); }
.faq__i { border-bottom: 1px solid var(--rule); }

.faq__q {
  cursor: pointer;
  list-style: none;
  padding: 1.15rem 2.5rem 1.15rem 0;
  font-family: var(--serif);
  font-size: var(--step-1);
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.012em;
  line-height: 1.3;
  position: relative;
  transition: color var(--fast) var(--ease);

  &::-webkit-details-marker { display: none; }
  &:hover { color: var(--accent); }

  &::after {
    content: '';
    position: absolute; right: 0.35rem; top: 1.6rem;
    width: 0.7rem; height: 0.7rem;
    border-right: 2px solid var(--accent);
    border-bottom: 2px solid var(--accent);
    transform: rotate(45deg);
    transition: transform var(--fast) var(--ease);
  }
}
.faq__i[open] .faq__q::after { transform: rotate(-135deg); }

.faq__a {
  padding-bottom: 1.35rem;
  color: var(--muted);
  max-width: 62ch;
}

@media (prefers-reduced-motion: no-preference) {
  /* ::details-content + allow-discrete gives an animated accordion with no
     script. Chrome 131+, Safari 18.4+, Firefox 143+ – everywhere else it simply
     snaps open, which is correct behaviour, not a bug. */
  .faq__i::details-content {
    block-size: 0;
    overflow: hidden;
    transition: block-size var(--fast) var(--ease), content-visibility var(--fast) allow-discrete;
  }
  .faq__i[open]::details-content { block-size: auto; }
}

/* ── CONTACT ───────────────────────────────────────────────────────────────── */

.sec--contact {
  background: var(--ink);
  color: var(--bg);
  text-align: center;
}
.sec--contact .eyebrow { color: color-mix(in oklab, var(--accent) 55%, #fff); }

.contact__h {
  font-family: var(--serif);
  font-size: var(--step-4);
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.028em;
  margin-top: 0.5rem;
  text-wrap: balance;
}
.contact__b {
  margin: 1.2rem auto 0;
  max-width: 52ch;
  font-size: var(--step-1);
  line-height: 1.55;
  color: color-mix(in oklab, var(--bg) 78%, var(--ink));
}
.contact__promise {
  margin-top: 0.9rem;
  font-size: var(--step--1);
  color: color-mix(in oklab, var(--bg) 62%, var(--ink));
}
.sec--contact .actions { justify-content: center; }
.sec--contact .btn--primary {
  --btn-bg: var(--bg);
  --btn-fg: var(--ink);

  &:hover { background: #fff; border-color: #fff; }
}
.contact__or { margin-top: 1.4rem; font-size: var(--step-0); color: color-mix(in oklab, var(--bg) 70%, var(--ink)); }
.contact__tel {
  color: var(--bg); font-weight: 700; text-decoration: none;
  border-bottom: 1.5px solid color-mix(in oklab, var(--accent) 60%, #fff);
  white-space: nowrap;
}

/* ── FOOTER ────────────────────────────────────────────────────────────────── */

.site-foot { border-top: 1px solid var(--rule); position: relative; z-index: 2; }
.site-foot__in {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) repeat(2, minmax(0, 1fr));
  gap: var(--gap);
  padding-block: clamp(2.5rem, 2rem + 2vw, 4rem) 2rem;
}
@media (max-width: 48rem) { .site-foot__in { grid-template-columns: 1fr; } }

.foot-brand__n { font-family: var(--serif); font-size: var(--step-1); font-weight: 600; font-variation-settings: 'WONK' 1; }
.foot-brand__b { margin-top: 0.6rem; color: var(--muted); font-size: var(--step--1); max-width: 40ch; }

/* NAP. Consistency here is the whole game for local search, and a sole trader
   has no Companies House record to corroborate against – so these strings must
   match GBP and every directory listing byte for byte. */
.foot-nap {
  margin-top: 1.1rem;
  font-style: normal;
  display: grid;
  gap: 0.25rem;
  font-size: var(--step--1);

  & a { color: var(--accent); text-decoration: none; font-weight: 600;

    &:hover { text-decoration: underline; }
  }
}
.foot-note { margin-top: 0.8rem; font-size: 0.8rem; color: var(--caption); }

.foot-col__h {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--caption); font-weight: 600; margin-bottom: 0.85rem;
}
.foot-col ul { display: grid; gap: 0.5rem; }
.foot-col a {
  text-decoration: none; font-size: var(--step--1); color: var(--muted);

  &:hover { color: var(--accent); }
}

.site-foot__legal {
  display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem;
  justify-content: space-between;
  padding-block: 1.25rem;
  border-top: 1px solid var(--rule);
  font-size: 0.8rem;
  color: var(--caption);
}

/* ── REVEAL ────────────────────────────────────────────────────────────────
   THE POLARITY HERE IS LOAD-BEARING. Default is VISIBLE; the .js class (set by
   an inline <head> script) opts IN to the hidden state.

   The usual pattern – .reveal { opacity: 0 } by default, JS reveals – means that
   if JS fails, errors early, or is disabled, the content is permanently
   invisible. Here every failure mode leaves the page readable.

   Also note: an element at opacity:0 is invisible but STILL FOCUSABLE and still
   announced by screen readers. A keyboard user tabbing ahead of the scroll
   position would land on a link they cannot see. :focus-within force-reveals the
   container, which is why it must sit outside any transition:none branch. */

.reveal { opacity: 1; }

@media (prefers-reduced-motion: no-preference) {
  .js .reveal {
    opacity: 0;
    transform: translateY(18px);
    transition:
      opacity var(--slow) var(--ease) calc(var(--i, 0) * 60ms),
      transform var(--slow) var(--ease) calc(var(--i, 0) * 60ms);
  }
  .js .reveal.is-visible,
  .js .reveal:focus-within {
    opacity: 1;
    transform: none;
  }

  /* Where scroll-driven animation is supported this runs OFF THE MAIN THREAD –
     free TBT, which is 30% of the Lighthouse score – and needs no JS at all.
     Not Baseline: Chrome 115+, Safari 26+, Firefox preview-only.

     The keyframes MUST end at the visible state. In Firefox the timeline is
     dropped, the animation runs on the document timeline at ~1ms with fill:both,
     and the element lands on the `to` keyframe. Author it the other way round
     and every Firefox visitor sees blank space. */
  @supports (animation-timeline: view()) {
    .js .reveal {
      opacity: 1;
      transform: none;
      transition: none;
      animation: reveal-in linear both;
      animation-duration: 1ms;              /* Firefox needs an explicit duration */
      animation-timeline: view();           /* MUST come after any `animation` shorthand */
      animation-range: entry 0% cover 26%;
    }
  }
}

@keyframes reveal-in {
  from { opacity: 0; transform: translateY(18px); }
  to   { opacity: 1; transform: none; }     /* end state visible – non-negotiable */
}

@media (prefers-reduced-motion: reduce) {
  .reveal, .js .reveal {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
    transition: none !important;
  }
}

/* Below-the-fold containment. Measured 232ms → 30ms rendering.
   Never above the fold – it would delay the LCP element, which is 25%. */
@supports (content-visibility: auto) {
  .sec--proof, .sec--about, .sec--faq {
    content-visibility: auto;
    contain-intrinsic-size: auto 600px;
  }
}

/* ── BREADCRUMBS ───────────────────────────────────────────────────────────
   A real Google feature (desktop, all regions) and one of only three this site
   can reach – so worth having, and cheap. */
.crumbs {
  border-bottom: 1px solid var(--rule);
  position: relative; z-index: 2;
  font-size: var(--step--1);
}
.crumbs ol { display: flex; flex-wrap: wrap; gap: 0.4rem; padding-block: 0.85rem; }
.crumbs li { display: flex; gap: 0.4rem; color: var(--caption); }
.crumbs li + li::before { content: '/'; opacity: 0.5; }
.crumbs a { color: var(--muted); text-decoration: none;

  &:hover { color: var(--accent); }
}
.crumbs [aria-current] { color: var(--ink); }

/* ── INNER PAGE HERO ───────────────────────────────────────────────────────
   Smaller than the homepage hero on purpose. Repeating the full-height opening
   on every page is the thing that makes a site feel like a template. */
.page-hero {
  padding-block: clamp(2.5rem, 2rem + 3vw, 4.5rem) clamp(2rem, 1.5rem + 2vw, 3rem);
  position: relative; z-index: 2;
}
.page-hero__h {
  font-family: var(--serif);
  font-size: var(--step-4);
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.028em;
  max-width: 18ch;
  margin-top: 0.5rem;
}
.page-hero__sub {
  margin-top: 1.3rem; max-width: 48ch;
  font-size: var(--step-1); line-height: 1.55; color: var(--muted);
}

/* ── PROSE ─────────────────────────────────────────────────────────────────
   Measure capped and blocks separated by a rule rather than by more whitespace,
   so a long page still has visible rhythm. */
.sec--prose { padding-block: clamp(2.5rem, 2rem + 2vw, 4rem); }
.prose + .prose { margin-top: 2.25rem; padding-top: 2.25rem; border-top: 1px solid var(--rule); }
.prose__h {
  font-family: var(--serif); font-size: var(--step-2);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.02em;
}
.prose__p { margin-top: 0.9rem; color: var(--muted); font-size: var(--step-0); line-height: 1.65; }

/* ── PROSE, TWO-COLUMN ─────────────────────────────────────────────────────
   Used on the service pages. The single narrow column was a problem there
   specifically: it sat between a full-width hero above and, on two of them,
   the full-width plans grid below, so it read as a gap in the page rather
   than a section of it. The FAQs lower down never had the problem, because
   the contact block beneath them is also narrow – the page narrows on
   purpose as it ends.

   Heading left, paragraphs at reading measure right. No content invented, the
   measure is preserved, and the heading sits on the same left rail as the
   hero, so the indented body reads as structure rather than drift. It also
   copes with blocks of uneven length – two paragraphs against three is just
   more grey in one column, but visibly its own unit in two.

   No rule between blocks here: the two-column structure separates them
   already, where the single-column .prose + .prose needs the border. */
.prose--split {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, var(--wrap-narrow));
  gap: clamp(1.5rem, 1rem + 3vw, 4rem);
  align-items: start;
}
/* These carry .prose too, so `.prose + .prose` would add its rule and padding
   on top of this. Explicitly cleared rather than dropping the .prose class,
   because .prose__h and .prose__p still want to read as part of that block. */
.prose--split + .prose--split {
  margin-top: clamp(2.5rem, 2rem + 2vw, 4rem);
  padding-top: 0;
  border-top: 0;
}
.prose--split .prose__h { margin: 0; }
.prose--split .prose__p:first-of-type { margin-top: 0; }

/* 68rem, matching the about rail. At 62 it technically fitted, but the heading
   column came out at 224px – and /seo/ has a heading reading "The map results
   and the normal results are different things", which wraps to a paragraph at
   that width. At 68 the column gets 275px and up.

   Below it, the heading goes back above its text. */
@media (max-width: 68rem) {
  .prose--split { grid-template-columns: 1fr; gap: 0.75rem; }
}


/* ── FULL-BLEED BAND ───────────────────────────────────────────────────────
   Edge to edge, deliberately breaking the wrap. Its whole job is to interrupt
   the section rhythm – a long page of identically-shaped sections is what reads
   as monotonous, regardless of how good the colour and type are. */
.band {
  position: relative;
  z-index: 2;
  isolation: isolate;
  /* REQUIRED, added 17 Aug 2026. The parallax below scales .band__img to 1.12
     permanently, so the painted image is bigger than its layout box – measured
     at 1920x1080: 36.9px proud at the top, 16.8px at the bottom. Without
     clipping, two things go wrong. The image bleeds over whatever sits above
     it, and .band__over – which is correctly pinned to the LAYOUT box bottom –
     stops ~17px short of the painted image bottom, leaving a strip of
     un-scrimmed photo under the text. That strip was the visible bug.

     The 1.12 scale exists precisely to give the ±2% drift room to move without
     exposing an edge, so clipping is how the effect was always meant to work,
     not a workaround bolted onto it. */
  overflow: hidden;
}
.band__img {
  display: block;
  width: 100%;
  aspect-ratio: 1376 / 768;
  max-height: 28rem;
  object-fit: cover;
  filter: saturate(0.85) contrast(1.02);
}
.band__over {
  position: absolute;
  inset: auto 0 0 0;
  padding-block: clamp(1.5rem, 1rem + 3vw, 3rem);
  /* Align to .wrap rather than hugging the viewport. The scrim stays full-bleed
     - the edge-to-edge IMAGE is the thing breaking the section rhythm, and one
     break is a decision where two is just inconsistency. Measured at 1900px the
     text sat 310px left of every other left edge on the page.

     .wrap is min(100% - 2.5rem, var(--wrap)) centred, so its side space is
     exactly this. Derived from --wrap rather than hard-coded, so the two cannot
     drift apart. */
  padding-inline: max(1.25rem, calc((100% - var(--wrap)) / 2));
  /* A scrim rather than a flat overlay: legible over any photograph without
     flattening it, and it costs nothing on the GPU (unlike backdrop-filter,
     which measured 15–30% FPS drops on real Android devices). */
  background: linear-gradient(to top,
    color-mix(in oklab, var(--ink) 88%, transparent),
    color-mix(in oklab, var(--ink) 55%, transparent) 55%,
    transparent);
}
.band__t {
  color: var(--bg);
  font-family: var(--serif);
  font-size: var(--step-2);
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.02em;
  line-height: 1.25;
  max-width: 30ch;
  text-wrap: balance;
}
@media (max-width: 40rem) {
  .band__img { aspect-ratio: 4 / 3; }
  .band__t br { display: none; }
}

/* ── RATES PAGE ────────────────────────────────────────────────────────────
   Alternating surface between groups so a long price page has visible rhythm
   rather than being one undifferentiated column of numbers. */
.sec--alt { background: var(--surface); border-block: 1px solid var(--rule); }
.sec--rategroup { padding-block: clamp(2.75rem, 2rem + 3vw, 4.5rem); }
.rategroup__head { max-width: var(--measure); margin-bottom: 2rem; }
.rategroup__head .sec__h { margin-top: 0; }
.rategroup__head > p:last-child { margin-top: 1rem; }

.rates__notes {
  margin-top: 1.5rem; display: grid; gap: 0.4rem;
  font-size: var(--step--1); color: var(--muted); max-width: 62ch;
}

.tiers {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
  /* The grid sat straight under the heading with nothing between them. On
     /rates/ a .sec__intro softened it slightly; on /web-design/ there is no
     intro, so the boxes were touching the title. Same spacing as .egs. */
  margin-top: clamp(2rem, 1.5rem + 2vw, 3rem);
}
.tier { background: var(--bg); padding: 1.5rem 1.35rem; display: grid; align-content: start; }
.sec--alt .tier { background: var(--bg); }
.tier--featured { box-shadow: inset 0 0 0 2px var(--accent); }
.tier__top { display: grid; gap: 0.35rem; }
.tier__h {
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em;
}
/* wrap + nowrap together. The amount is one span, so when "From £4,000" broke
   across two lines the unit stayed pinned to the first baseline and floated
   oddly beside it. Keeping the amount unbreakable and letting the FLEX line
   wrap instead drops the unit cleanly underneath when it will not fit. */
.tier__price { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem; }
.tier__amt {
  font-family: var(--serif); font-size: var(--step-3); font-weight: 600;
  letter-spacing: -0.03em; font-variation-settings: 'WONK' 1; line-height: 1;
  white-space: nowrap;
}
.tier__unit { font-size: var(--step--1); color: var(--caption); }
.tier__for { margin-top: 0.9rem; color: var(--muted); font-size: var(--step--1); line-height: 1.5; }
.tier__fy {
  margin-top: 0.9rem; padding-top: 0.9rem; border-top: 1px solid var(--rule);
  font-size: var(--step--1); font-weight: 600; color: var(--accent);
}

/* Worked examples. Deliberately not a calculator – NN/g found configurators
   "complex, time-consuming and error prone", finished only by the most engaged. */
.egs {
  margin-top: clamp(2rem, 1.5rem + 2vw, 3rem);
  display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: var(--gap);
}
.eg { border-top: 2px solid var(--accent); padding-top: 1.1rem; }
.eg__who {
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em;
}
.eg__what { margin-top: 0.6rem; color: var(--muted); font-size: var(--step--1); line-height: 1.55; }
.eg__cost {
  margin-top: 1rem; font-family: var(--serif); font-size: var(--step-2);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.02em; line-height: 1.15;
}
.eg__after { display: block; font-family: var(--sans); font-size: var(--step--1); color: var(--caption); font-weight: 500; margin-top: 0.2rem; }

/* The closing line under the homepage client list ends in a short link. At
   desktop widths "More on that" was breaking after "More", stranding the word at
   the end of a line. A three-word link reads as one unit, so it wraps as one. */
.sec--proof .sec__intro .link-arrow { white-space: nowrap; }

/* /services/ triage list. Full .wrap so its left edge lines up with the hero
   above and the card grid below - a narrow centred column between two full-width
   sections reads as a mistake rather than a decision. The measure cap keeps the
   lines readable without indenting the block. */
.sec--triage .depends { max-width: var(--measure); }
.sec--triage .sec__h { max-width: var(--measure); }

.depends { margin-top: 2rem; }
.depends__i { padding-block: 1.1rem; border-top: 1px solid var(--rule); }
.depends__i:last-child { border-bottom: 1px solid var(--rule); }
.depends dt { font-weight: 700; font-size: var(--step-0); }
.depends dd { margin: 0.4rem 0 0; color: var(--muted); font-size: var(--step-0); line-height: 1.6; }

/* ══════════════════════════════════════════════════════════════════════════
   MOTION PASS
   Everything below is compositor-only (transform / opacity / clip-path), so it
   costs no layout and no TBT – which matters, because TBT is 30% of the score
   and is the metric a marketing site is most likely to fail on a throttled
   mid-range Android.
   ══════════════════════════════════════════════════════════════════════════ */

@media (prefers-reduced-motion: no-preference) {

  /* 1. HEADING MASK REVEAL.
        The line wipes up from behind its own edge rather than fading in. Reads
        as considerably more expensive than it is: one clip-path and one
        transform, no JS, no extra markup beyond a wrapper span. */
  /* overflow:hidden is what makes the wipe work – but it clips to the LINE BOX,
     and Fraunces' glyphs are taller than that. Measured from the font tables:
     ascender 97.8% + descender 25.5% = 1.233em of ink, against a 1.08 line-height.
     That crops ~0.077em off the top and bottom, which is invisible on a heading
     of capitals and obvious the moment a 'y' or 'g' appears.

     Padding enlarges the clip box; the matching negative margin cancels it out of
     the layout, so nothing moves. Padding-top is safe during the animation
     because the span starts BELOW the mask at translateY(105%) – there is nothing
     above to expose early. */
  .js .mask {
    display: block;
    overflow: hidden;
    padding-block: 0.12em;
    margin-block: -0.12em;
  }
  .js .mask > span {
    display: block;
    transform: translateY(105%);
    transition: transform 0.7s var(--ease) calc(var(--i, 0) * 70ms);
  }
  .js .is-visible .mask > span,
  .js .mask.is-visible > span { transform: none; }

  /* 2. BAND PARALLAX, scroll-driven.
        Runs on the compositor where supported – Chrome's own docs confirm
        scroll-driven animations run off the main thread – so it is free TBT.
        @supports-gated, and the keyframes end at a neutral transform so a
        non-supporting browser lands on a correct, un-shifted image. */
  @supports (animation-timeline: view()) {
    .band__img {
      animation: band-drift linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
      will-change: transform;
    }
    @keyframes band-drift {
      from { transform: scale(1.12) translateY(-2%); }
      to   { transform: scale(1.12) translateY(2%); }
    }
  }

  /* 3. CARD LIFT.
        transform only – never box-shadow alone on hover, which repaints a large
        area every frame. 1px is enough to feel; more reads as a bounce. */
  .svc__link, .plan, .tier, .client {
    transition: transform var(--fast) var(--ease), background var(--fast) var(--ease);
  }
  .svc__link:hover, .client:hover { transform: translateY(-2px); }

  /* 4. LINK UNDERLINE WIPE.
        scaleX on a pseudo-element, transform-origin flipping on hover so it
        wipes out the other side. Cheap, and the detail people notice without
        being able to say why. */
  .link-arrow {
    position: relative;
    border-bottom-color: transparent;

    &::before {
      content: '';
      position: absolute; left: 0; right: 0; bottom: -2px; height: 1.5px;
      background: currentColor;
      transform: scaleX(1); transform-origin: left;
      transition: transform var(--fast) var(--ease);
    }
    &:hover::before { transform: scaleX(0); transform-origin: right; }
  }

  /* 5. AVAILABILITY DOT – already present, kept here for the record: the only
        looping animation on the site. Anything that loops indefinitely sits
        inside WCAG 2.2.2 (Level A), so it is deliberately tiny, non-moving in
        position, and suppressed entirely under reduced motion. */
}

/* Spring easing, applied where a movement should overshoot slightly rather than
   ease flat. Baseline since Safari 17.2. */
.btn--primary { transition-timing-function: var(--spring); }

/* Hero mask reveal fires on LOAD, not on scroll – it is above the fold, so a
   scroll-triggered reveal would never fire at all. The `both` fill keeps the
   finished state; the text is plain in the HTML regardless, so nothing depends
   on this running. */
@media (prefers-reduced-motion: no-preference) {
  .js .hero__h .mask > span,
  .js .page-hero__h .mask > span {
    transform: translateY(105%);
    animation: mask-up 0.85s var(--ease) both;
    animation-delay: calc(0.1s + var(--i, 0) * 0.09s);
  }
  @keyframes mask-up {
    from { transform: translateY(105%); }
    to   { transform: none; }
  }
}

/* ── CHECK / ABOUT / CONTACT / 404 ─────────────────────────────────────────
   Deliberately reusing .trust, .depends, .prose and .faq rather than inventing
   new components. Every extra component is another thing that can drift out of
   step with the rest – and the site is small enough that it does not need them. */

.promises {
  margin-top: 2rem; display: grid; gap: 0.6rem;
  border-top: 1px solid var(--rule); padding-top: 1.5rem;
}
.promises__i {
  position: relative; padding-left: 1.75rem;
  font-size: var(--step-0); color: var(--ink-2);

  &::before {
    content: ''; position: absolute; left: 0.15rem; top: 0.45em;
    width: 0.6rem; height: 0.6rem;
    border-left: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
    transform: rotate(-45deg);
  }
}

.about-hero { display: grid; grid-template-columns: minmax(0,1.4fr) minmax(0,0.6fr); gap: var(--gap); align-items: center; }
.about-hero__media { max-width: 24rem; margin-left: auto; }
@media (max-width: 60rem) {
  .about-hero { grid-template-columns: 1fr; }
  .about-hero__media { max-width: 20rem; margin-left: 0; order: -1; }
}

/* ── ABOUT: PROSE + STICKY FACT RAIL ───────────────────────────────────────
   The four facts used to sit in a full-width .trust bar between the hero and
   the prose, where they interrupted the read and were then scrolled past. They
   are credentials – "since 2012", "Plaistow, E13", "not VAT registered" – so
   they are worth having in view for the whole read rather than for two seconds.

   Moving them here also fixes the layout problem underneath. Prose in a narrow
   centred column left a dead right half; left-aligning it made that half read
   as a sidebar that had failed to load. With the rail in it, the prose can
   align to the same left rail as the hero and the space is accounted for.

   .trust itself stays – the homepage still uses it. */
.about-grid {
  display: grid;
  /* minmax(0, …) stops a long word forcing the column past the readable
     measure. align-items:start is what allows the rail to stick rather than
     stretching to the row height. */
  grid-template-columns: minmax(0, var(--wrap-narrow)) 1fr;
  gap: clamp(2rem, 1rem + 4vw, 4.5rem);
  align-items: start;
}
.about-rail {
  position: sticky;
  top: 6.5rem;
  border-left: 2px solid var(--accent);
  padding-left: 1.25rem;
}
.about-rail__h {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--caption); margin: 0 0 1.1rem;
}
.about-rail dl { margin: 0; }
.about-rail div + div {
  margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--rule);
}
.about-rail dt {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--caption); margin-bottom: 0.2rem;
}
.about-rail dd {
  margin: 0;
  font-family: var(--serif);
  font-size: var(--step-0);
  font-weight: 600;
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.015em;
  line-height: 1.25;
}

/* 68rem, not 62. At 62 the rail technically fitted but came out about 168px
   wide, which is too tight for serif values like "Plaistow, E13". 68rem is the
   point where the rail gets roughly 280px and stops looking squeezed.

   Below it, a plain block above the prose – same facts, no cleverness. */
@media (max-width: 68rem) {
  .about-grid { grid-template-columns: 1fr; gap: 2rem; }
  .about-rail { position: static; }
  .about-rail dl {
    display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
    gap: 1rem 1.5rem;
  }
  .about-rail div + div { margin-top: 0; padding-top: 0; border-top: 0; }
}

.methods {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  gap: 1px; background: var(--rule); border: 1px solid var(--rule);
  margin-bottom: 2.5rem;
}
.method { background: var(--bg); padding: 1.35rem 1.25rem; }
.method__k { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--caption); }
.method__v {
  margin-top: 0.4rem; font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em; word-break: break-word;

  & a { color: var(--accent); text-decoration: none; border-bottom: 1.5px solid var(--rule);

    &:hover { border-bottom-color: var(--accent); }
  }
}
.method__n { margin-top: 0.5rem; font-size: var(--step--1); color: var(--muted); line-height: 1.5; }

.hours { margin-top: 1rem; }
.hours__i {
  display: flex; justify-content: space-between; gap: 1rem;
  padding-block: 0.6rem; border-bottom: 1px solid var(--rule);
}
.hours dt { color: var(--muted); }
.hours dd { margin: 0; font-weight: 600; text-align: right; }

.fourofour {
  margin: 2rem 0; display: grid; gap: 0.55rem;
  border-top: 1px solid var(--rule); padding-top: 1.5rem;

  & a { color: var(--accent); font-weight: 600; text-decoration: none;

    &:hover { text-decoration: underline; }
  }
}

/* ── PROCESS STEPS ─────────────────────────────────────────────────────────
   Three columns at desktop: number, body, timing. The timing column is the
   point – "three to five weeks" beside the step it applies to answers the
   question people are actually holding. */
.steps { counter-reset: s; }
.step {
  display: grid;
  grid-template-columns: 3.5rem 1fr 11rem;
  gap: var(--gap);
  padding-block: 1.75rem;
  border-top: 1px solid var(--rule);
  align-items: start;
}
.step:last-child { border-bottom: 1px solid var(--rule); }
.step__n {
  font-family: var(--serif); font-size: var(--step-2); color: var(--accent);
  font-variation-settings: 'WONK' 1; line-height: 1; opacity: 0.8;
}
.step__h {
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em;
}
.step__p { margin-top: 0.5rem; color: var(--muted); line-height: 1.6; max-width: 52ch; }
.step__t {
  font-size: var(--step--1); font-weight: 600; color: var(--accent);
  text-transform: uppercase; letter-spacing: 0.05em; text-align: right;
}
@media (max-width: 52rem) {
  .step { grid-template-columns: 2.5rem 1fr; gap: 0.75rem var(--gap); }
  .step__t { grid-column: 2; text-align: left; margin-top: 0.6rem; }
}

/* ── TESTIMONIALS ──────────────────────────────────────────────────────────
   Attribution is styled to be as prominent as the quote, deliberately. A quote
   without a checkable name is worth less than no quote at all – it signals
   willingness to invent, which is fatal on a page whose premise is that a
   stranger can verify what it says. If a name can't go here, the quote
   shouldn't either. */
.quotes {
  margin-top: clamp(2rem, 1.5rem + 2vw, 3rem);
  display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
  gap: var(--gap);
}
.quote { display: grid; align-content: start; }
.quote__q { margin: 0; }
.quote__q p {
  font-family: var(--serif);
  font-size: var(--step-1);
  font-variation-settings: 'WONK' 1;
  letter-spacing: -0.012em;
  line-height: 1.4;
  color: var(--ink);

  /* Hanging quote mark. Purely typographic, costs one pseudo-element, and it is
     the detail that stops a quote block reading as a bordered div. */
  &::before {
    content: '\201C';
    font-size: 2.4em; line-height: 0; vertical-align: -0.32em;
    color: var(--accent); margin-right: 0.06em;
  }
}
.quote__by {
  margin-top: 1rem; padding-top: 0.9rem;
  border-top: 2px solid var(--accent);
  display: grid; gap: 0.15rem;
}
.quote__name { font-weight: 700; font-size: var(--step-0); }
.quote__role { font-size: var(--step--1); color: var(--caption); }

/* ── DEMOS ─────────────────────────────────────────────────────────────────
   The disclaimer is styled to be read, not skimmed past. Accent border, its own
   surface, sitting above the examples rather than beneath them. If it looks like
   small print it is doing the opposite of its job. */
.sec--disclaimer { padding-block: 0 clamp(2rem, 1.5rem + 2vw, 3rem); }
.disclaimer {
  border-left: 3px solid var(--accent);
  background: var(--accent-wash);
  padding: 1.25rem 1.5rem;
  border-radius: 0 3px 3px 0;
}
.disclaimer__h {
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em; color: var(--accent-lo);
}
.disclaimer__p { margin-top: 0.6rem; color: var(--ink-2); font-size: var(--step-0); line-height: 1.6; }

.demo {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
  gap: var(--gap);
  align-items: center;
}
/* Alternate the image side so a list of two doesn't read as a stack of
   identical rows. */
.demo:nth-child(even) .demo__media { order: 2; }
@media (max-width: 56rem) {
  .demo { grid-template-columns: 1fr; }
  .demo:nth-child(even) .demo__media { order: 0; }
}

.demo__media { position: relative; overflow: hidden; border-radius: 3px; }
/* Image scale on hover – the one remaining pattern from the motion inventory
   with a genuine home on this site. transform only, inside an overflow:hidden
   frame, so it composites and never repaints the surrounding layout. */
@media (prefers-reduced-motion: no-preference) {
  .demo__img { transition: transform 0.5s var(--ease); }
  .demo__media:hover .demo__img { transform: scale(1.035); }
}
.demo__img {
  width: 100%; border-radius: 3px; border: 1px solid var(--rule);
  aspect-ratio: 3 / 2; object-fit: cover;
}
/* Repeated on every card, not just at the top of the page. Someone arriving
   from a shared link to one example must see it too. */
.demo__flag {
  position: absolute; top: 0.75rem; left: 0.75rem;
  background: var(--ink); color: var(--bg);
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.09em;
  font-weight: 700; padding: 0.35rem 0.6rem; border-radius: 2px;
}
.demo__type {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--accent); font-weight: 700;
}
.demo__h {
  margin-top: 0.4rem;
  font-family: var(--serif); font-size: var(--step-2);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.02em;
}
.demo__what { margin-top: 0.8rem; color: var(--muted); line-height: 1.6; }
.demo__meta {
  margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--rule);
  display: flex; flex-wrap: wrap; gap: 1.5rem;
}
.demo__meta dt { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--caption); }
.demo__meta dd {
  margin: 0.2rem 0 0;
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; font-weight: 600;
}

/* Wider capability footnote. Deliberately quiet – the brief lists software and
   sysadmin, but also says the entry point is always a website problem and the
   rest is expansion revenue. A sixth service card would invert that. */
.more {
  margin-top: 2.5rem; padding: 1.5rem;
  background: var(--surface); border-radius: 3px;
  max-width: 58ch;
}
.more__h {
  font-family: var(--serif); font-size: var(--step-1);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.015em;
}
.more__p { margin-top: 0.7rem; color: var(--muted); line-height: 1.6; }

.foot-agency { margin-top: 0.9rem; font-size: 0.8rem; }
.foot-agency a { color: var(--muted); text-decoration: none; border-bottom: 1px solid var(--rule);

  &:hover { color: var(--accent); border-bottom-color: var(--accent); }
}

/* ── FORM ──────────────────────────────────────────────────────────────────
   Inputs are 16px minimum on purpose: iOS Safari zooms the whole page when a
   focused input is below 16px, which is jarring and then leaves the layout
   zoomed. It is the single most common mobile form bug. */
.form { margin-top: 2rem; display: grid; gap: 1.25rem; max-width: 34rem; }
.field { display: grid; gap: 0.4rem; }
.field label { font-weight: 600; font-size: var(--step-0); }
.field__opt { font-weight: 400; color: var(--caption); font-size: var(--step--1); }
.field input,
.field textarea {
  font: inherit;
  font-size: max(1rem, var(--step-0));
  padding: 0.7rem 0.85rem;
  border: 1.5px solid var(--rule);
  border-radius: 3px;
  background: var(--bg);
  color: var(--ink);
  width: 100%;
  transition: border-color var(--fast) var(--ease);

  &::placeholder { color: var(--caption); }
  &:hover { border-color: var(--muted); }
  &:focus-visible { border-color: var(--accent); outline-offset: 1px; }
}
.field textarea { resize: vertical; min-height: 5.5rem; }
.field--err input, .field--err textarea { border-color: var(--accent); }
.field__err { color: var(--accent-lo); font-size: var(--step--1); font-weight: 600; }

/* Honeypot. Not display:none – some bots skip anything hidden that way, and
   some browsers skip it too. Taken out of flow and clipped instead. */
.hp {
  position: absolute !important;
  left: -9999px !important;
  width: 1px; height: 1px; overflow: hidden;
}

.form__privacy { font-size: var(--step--1); color: var(--muted); line-height: 1.5;

  & a { color: var(--accent); }
}
.form__alt { margin-top: 1.5rem; font-size: var(--step--1); color: var(--muted);

  & a { color: var(--accent); font-weight: 600; }
}
.form__alert {
  margin-top: 1.5rem; padding: 1rem 1.15rem;
  border-left: 3px solid var(--accent); background: var(--accent-wash);
  color: var(--ink-2); border-radius: 0 3px 3px 0;
}
.sent {
  margin-top: 2rem; padding: 1.5rem;
  border-left: 3px solid var(--accent); background: var(--accent-wash);
  border-radius: 0 3px 3px 0;

  &:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
}
.sent__h {
  font-family: var(--serif); font-size: var(--step-2);
  font-variation-settings: 'WONK' 1; letter-spacing: -0.02em; color: var(--accent-lo);
}
.sent__p { margin-top: 0.6rem; color: var(--ink-2); line-height: 1.6;

  & a { color: var(--accent); font-weight: 700; }
}

