/* ============================================================
   Prime Shine Exterior Care — brand layer
   Loads AFTER kit.css so it can override components.
   Order: fonts.css -> design.css -> kit.css -> brand.css -> texture.css

   Palette sampled from the logo artwork. Re-derive with:
     convert logo-mark-final.png -alpha remove -background white -flatten \
             -colors 16 -depth 8 -format %c histogram:info:

   The previous third-party site used #2b8cf8 / #38b24a. Those colours are
   NOT in the logo. Do not reintroduce them.

   CONTRAST RULE, measured, not guessed:
     --ps-water (2.33:1) --ps-water-pale (1.45:1) --ps-sand (1.73:1)
     and --ps-green (3.06:1) are DECORATIVE ONLY on the white ground.
     Text green is --ps-green-ink (7.26:1). Never swap them "to brighten it up".
   ============================================================ */

:root,
:root[data-direction="d3-swiss-tech"] {

  /* ---- Sampled straight from the logo artwork ---- */
  --ps-navy:        #05255A;   /* shield ink, dominant      14.80:1 on white */
  --ps-navy-deep:   #062031;   /* pressure-washer gun body  16.68:1 */
  --ps-blue:        #13549D;   /* roof / lance mid-blue      7.55:1  <- signal */
  --ps-water:       #47A9DC;   /* jet mid-tone               2.33:1  decorative */
  --ps-water-pale:  #A2DFF8;   /* jet highlight, wordmark    1.45:1  decorative */
  --ps-foam:        #CFF2FC;   /* soap foam                  surface tint */
  --ps-green:       #52A62F;   /* ribbon                     3.06:1  large only */
  --ps-green-ink:   #25623D;   /* ribbon shadow              7.26:1  text-safe */
  --ps-sand:        #CCC5AA;   /* block paving arc           decorative */

  /* ---- Tonal ladder. Depth here comes from VALUE and hairlines,
          never from heavy shadow. Navy plus a big shadow reads as mud. ---- */
  --navy-900: #041B41;
  --navy-800: #05255A;
  --navy-700: #0B3670;
  --navy-600: #13549D;

  --hairline:        rgba(5, 37, 90, 0.12);
  --hairline-strong: rgba(5, 37, 90, 0.22);
  --hairline-water:  rgba(71, 169, 220, 0.35);

  /* ---- Tokens the kit reads ---- */
  --brand-primary:      var(--ps-navy);
  --brand-primary-900:  var(--navy-900);
  --brand-primary-700:  var(--ps-blue);
  --brand-accent:       var(--ps-blue);

  --surface-page:  #FFFFFF;
  --surface-card:  #FFFFFF;
  --surface-alt:   #F4FBFE;      /* foam at 25%, the only tinted ground */
  --surface-ink:   var(--ps-navy);
  --surface-rule:  var(--hairline);

  --text-default:  var(--ps-navy);
  --text-muted:    #4A5768;      /* 7.36:1 on white */

  /* Pure white is the thesis of this build. The whole brand promise is
     "we make surfaces clean again", and any tinted or textured ground
     contaminates how an "after" photo reads. Keep grounds white. */
}

/* ---- Nav: the logo is a raster mark on a white ground ------------------
   logo-mark.webp has enclosed near-white glow regions inside the artwork
   (sparkles round the gun) that a corner flood-fill cannot reach. They are
   invisible on white and only on white. Never place this mark on a dark or
   coloured surface. The footer uses the wordmark in type instead. */
.site-nav__brand img { height: 44px; width: auto; display: block; }
@media (max-width: 640px) { .site-nav__brand img { height: 38px; } }

/* ---- Trust strip: hairline-separated facts, no pill badges ---- */
.trust {
  display: grid; grid-template-columns: repeat(4, 1fr);
  border-block: 1px solid var(--surface-rule);
}
.trust__item {
  display: flex; align-items: center; gap: .6rem;
  padding: 1.15rem 1.25rem; font-size: var(--cb-type-scale-small);
  font-weight: 500; color: var(--text-default);
}
.trust__item + .trust__item { border-left: 1px solid var(--surface-rule); }
.trust__item svg { width: 1.25rem; height: 1.25rem; color: var(--ps-blue); flex: none; }
@media (max-width: 860px) {
  .trust { grid-template-columns: repeat(2, 1fr); }
  .trust__item:nth-child(3) { border-left: none; }
  .trust__item:nth-child(n+3) { border-top: 1px solid var(--surface-rule); }
}
@media (max-width: 420px) {
  .trust { grid-template-columns: 1fr; }
  .trust__item + .trust__item { border-left: none; border-top: 1px solid var(--surface-rule); }
}

/* ---- Process steps: mono numerals, D3's tabular-num signature ---- */
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--cb-space-6); position: relative; }
.step__n {
  font-family: var(--cb-font-heading_family); font-variant-numeric: tabular-nums;
  font-size: .8rem; font-weight: 700; letter-spacing: .12em;
  color: var(--ps-blue); display: block; margin-bottom: .75rem;
}
.step h3 { font-size: var(--cb-type-scale-h5); margin: 0 0 .4rem; }
.step p { color: var(--text-muted); margin: 0; }
/* The water line between steps. Decorative, drawn once, aria-hidden. */
.steps__flow { position: absolute; inset-inline: 12%; top: .55rem; height: 2px; overflow: hidden; }
.steps__flow::before {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg, var(--hairline-water) 0 40%, var(--ps-water) 50%, var(--hairline-water) 60% 100%);
  background-size: 250% 100%; background-position: 100% 0;
}
.io .steps__flow::before { animation: ps-flow 7s linear infinite; }
@keyframes ps-flow { to { background-position: -150% 0; } }
@media (max-width: 780px) { .steps { grid-template-columns: 1fr; } .steps__flow { display: none; } }

/* ---- Service cards: photo-led, no boxed-card pile-up ---- */
/* Three across, so six services land as 3+3. auto-fit gives 4+2 on a wide
   screen and leaves an orphan gap on the last row. Fixed columns, stepped
   down deliberately. */
.svc-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--cb-space-5); }
@media (max-width: 1000px) { .svc-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 620px)  { .svc-grid { grid-template-columns: 1fr; } }
.svc {
  display: flex; flex-direction: column; text-decoration: none; color: inherit;
  border: 1px solid var(--surface-rule); border-radius: var(--cb-radius-card); overflow: hidden;
  background: var(--surface-card); transition: border-color .2s, transform .2s;
}
.svc:hover, .svc:focus-visible { border-color: var(--ps-blue); transform: translateY(-2px); }
.svc__media { aspect-ratio: 4 / 3; overflow: hidden; background: var(--surface-alt); }
.svc__media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter .5s ease, transform .5s ease; }
/* dirty -> clean on hover/focus. Purely decorative: the label never depends on it. */
@media (hover: hover) {
  .svc__media img { filter: saturate(.72) contrast(.94) brightness(.96); }
  .svc:hover .svc__media img, .svc:focus-within .svc__media img { filter: none; transform: scale(1.02); }
}
.svc__body { padding: 1.1rem 1.2rem 1.3rem; display: flex; flex-direction: column; gap: .35rem; flex: 1; }
.svc__body h3 { font-size: var(--cb-type-scale-h6); margin: 0; }
.svc__body p { color: var(--text-muted); font-size: var(--cb-type-scale-small); margin: 0; }
.svc__go { margin-top: auto; padding-top: .7rem; color: var(--ps-blue); font-weight: 600; font-size: var(--cb-type-scale-small); }

/* ---- Before / after: the wipe slider ---------------------------------
   The control is a real <input type="range">. Keyboard, touch and
   screen-reader support come from the platform, not from us. */
.ba { margin: 0; }
.ba__stack {
  position: relative; aspect-ratio: 4 / 3; overflow: hidden;
  border-radius: var(--cb-radius-image); border: 1px solid var(--surface-rule);
  background: var(--surface-alt);
  cursor: ew-resize;
  /* pan-y, not none: horizontal drags come to us for the slider, vertical
     swipes still scroll the page. touch-action:none here would trap a thumb
     on a photo that is most of the screen on a phone. */
  touch-action: pan-y;
  -webkit-user-select: none; user-select: none;
}
.ba__stack img { pointer-events: none; -webkit-user-drag: none; }
.ba.is-dragging .ba__stack { cursor: grabbing; }
.ba.is-dragging .ba__img--before { transition: none; }
.ba__img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
/* --pos is the percentage of BEFORE still showing, from the left edge. */
.ba__img--before { clip-path: inset(0 calc(100% - var(--pos, 50) * 1%) 0 0); }
/* BEFORE in green, AFTER in blue, and both react live to the drag so it is
   obvious which half you are looking at.

   --pos is the percentage of the BEFORE image still showing and it inherits
   down from .ba, so the labels can read it straight out of CSS with no extra
   JavaScript. Drag right and BEFORE brightens and grows while AFTER recedes;
   drag left and they swap. Text is coloured rather than the pill, which is
   what keeps both readable: white on --ps-green is only 3.06:1 and would
   fail, whereas --ps-green-ink on white is 7.26:1 and --ps-blue is 7.55:1. */
.ba__tag {
  position: absolute; bottom: .8rem; z-index: 3; pointer-events: none;
  font-family: var(--cb-font-heading_family); font-size: .7rem; font-weight: 800;
  letter-spacing: .14em; text-transform: uppercase;
  padding: .38rem .8rem; border-radius: 999px;
  background: #fff;
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  transform-origin: bottom center;
  transition: box-shadow .18s ease;
  will-change: opacity, transform;
}
.ba__tag--before {
  left: .8rem; color: var(--ps-green-ink);
  box-shadow: 0 2px 10px rgba(5, 37, 90, .18), inset 0 0 0 1.5px rgba(82, 166, 47, .5);
  opacity: 1;
  transform: scale(calc(.9 + var(--pos, 50) * .0022));
}
.ba__tag--after {
  right: .8rem; color: var(--ps-blue);
  box-shadow: 0 2px 10px rgba(5, 37, 90, .18), inset 0 0 0 1.5px rgba(19, 84, 157, .45);
  opacity: 1;
  transform: scale(calc(1.12 - var(--pos, 50) * .0022));
}
.ba__seam {
  position: absolute; top: 0; bottom: 0; z-index: 2; width: 3px; margin-left: -1.5px;
  left: calc(var(--pos, 50) * 1%); background: #fff;
  pointer-events: none;
}
.ba__seam::after {
  content: ""; position: absolute; top: 50%; left: 50%; width: 46px; height: 46px;
  transform: translate(-50%, -50%); border-radius: 50%;
  transition: transform .15s ease;
  background: #fff center / 17px no-repeat
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2313549D' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18-6-6 6-6'/%3E%3Cpath d='m15 6 6 6-6 6'/%3E%3C/svg%3E");
  box-shadow: 0 2px 10px rgba(5, 37, 90, .3);
}
.ba__range {
  -webkit-appearance: none; appearance: none;
  display: block; width: 100%; margin: .85rem 0 0; background: transparent;
  cursor: ew-resize; touch-action: pan-y;
}
.ba__range::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--surface-rule); }
.ba__range::-moz-range-track            { height: 4px; border-radius: 2px; background: var(--surface-rule); }
.ba__range::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 26px; height: 26px; margin-top: -11px;
  border-radius: 50%; background: var(--ps-blue); border: 3px solid #fff;
  box-shadow: 0 1px 6px rgba(5, 37, 90, .4);
}
.ba__range::-moz-range-thumb {
  width: 20px; height: 20px; border-radius: 50%;
  background: var(--ps-blue); border: 3px solid #fff; box-shadow: 0 1px 6px rgba(5, 37, 90, .4);
}
.ba__range:focus-visible { outline: 3px solid var(--ps-blue); outline-offset: 4px; border-radius: 4px; }
.ba figcaption { margin-top: .6rem; font-size: var(--cb-type-scale-small); color: var(--text-muted); }
.ba figcaption b { color: var(--text-default); font-weight: 600; }

/* ---- Before / after: side-by-side, for pairs shot from different angles.
   Honest alternative to a wipe that would not register. ---- */
.bapair { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; margin: 0; }
.bapair figure { margin: 0; position: relative; }
.bapair img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; }
.bapair figure:first-child img { border-radius: var(--cb-radius-image) 0 0 var(--cb-radius-image); }
.bapair figure:last-child  img { border-radius: 0 var(--cb-radius-image) var(--cb-radius-image) 0; }
.bapair figcaption {
  position: absolute; bottom: .7rem; left: .7rem;
  font-family: var(--cb-font-heading_family); font-size: .64rem; font-weight: 700;
  letter-spacing: .14em; text-transform: uppercase; color: #fff;
  background: rgba(5, 37, 90, .82); padding: .3rem .62rem; border-radius: 999px;
}
.bapair--cap { margin-top: .6rem; font-size: var(--cb-type-scale-small); color: var(--text-muted); }

/* ---- The one pressure-washed reveal. Runs once, on the first section
   below the hero. Content is fully visible without JS. ---- */
.washed { position: relative; }
/* Default is CLEAN, opacity 0. The dirty start lives in the keyframes, so
   every failure path (observer never fires, animation unsupported, JS dead)
   leaves the section fully clean and readable rather than stuck under a
   permanent beige film. An effect must never be able to degrade content. */
.io .washed .washed__layer {
  position: absolute; inset: 0; z-index: 4; pointer-events: none;
  background: var(--ps-sand);
  opacity: 0;
  -webkit-mask-image: linear-gradient(105deg, #000 0 38%, rgba(0,0,0,.35) 47%, transparent 56%);
          mask-image: linear-gradient(105deg, #000 0 38%, rgba(0,0,0,.35) 47%, transparent 56%);
  -webkit-mask-size: 260% 100%; mask-size: 260% 100%;
  -webkit-mask-position: 0 0;  mask-position: 0 0;
}
.io .washed.is-washing .washed__layer { animation: ps-wash 1500ms cubic-bezier(.4, 0, .2, 1) forwards; }
@keyframes ps-wash {
  from { -webkit-mask-position: 0 0;    mask-position: 0 0;    opacity: .34; }
  to   { -webkit-mask-position: 100% 0; mask-position: 100% 0; opacity: 0; }
}

/* ---- Gallery ---- */
.filters { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: var(--cb-space-6); }
.filters button {
  font: inherit; font-size: var(--cb-type-scale-small); font-weight: 500;
  padding: .5rem 1rem; border-radius: 999px; cursor: pointer;
  border: 1px solid var(--surface-rule); background: #fff; color: var(--text-muted);
  min-height: 44px;
}
.filters button[aria-pressed="true"] { background: var(--ps-navy); border-color: var(--ps-navy); color: #fff; }
.filters button:focus-visible { outline: 3px solid var(--ps-blue); outline-offset: 2px; }
.shots { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: var(--cb-space-4); }
.shots figure { margin: 0; border-radius: var(--cb-radius-image); overflow: hidden; border: 1px solid var(--surface-rule); }
.shots img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; }
.shots figcaption { padding: .7rem .85rem; font-size: var(--cb-type-scale-small); color: var(--text-muted); background: #fff; }

/* ---- [hidden] must actually hide. ------------------------------------
   The attribute's UA default is display:none, which is the weakest rule
   in the cascade: ANY class that sets display beats it. .btn sets
   display:inline-flex, .shots figure is a grid item, so `el.hidden = true`
   was a no-op on both. This bit us twice: the gallery filter, and the
   wizard's Back button showing on step 1. One rule, not one per component. */
[hidden] { display: none !important; }

/* ---- Sticky mobile action bar. ekobuild's version has no safe-area
   padding and sits under the iPhone home indicator. This one does. ---- */
.actionbar { display: none; }
@media (max-width: 860px) {
  /* Floats clear of the bottom edge rather than sitting flush against it.
     env(safe-area-inset-bottom) is 0 on an iPhone in portrait when Safari's
     URL bar is at the bottom, because the URL bar is browser chrome and not a
     safe-area inset. So max() alone gives no clearance and the bar ends up
     jammed against Safari's toolbar on a real device. A real margin
     is the only thing that works across the toolbar's expanded, collapsed and
     home-screen-app states. */
  .actionbar {
    display: grid; grid-template-columns: 1fr 1fr; gap: .55rem;
    position: fixed; left: .7rem; right: .7rem; z-index: 150;
    bottom: calc(.7rem + env(safe-area-inset-bottom));
    padding: .55rem;
    border-radius: 14px;
    background: rgba(255, 255, 255, .97);
    -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
    border: 1px solid var(--surface-rule);
    box-shadow: 0 6px 24px rgba(5, 37, 90, .18);
  }
  .actionbar .btn { width: 100%; min-height: 50px; display: inline-flex; align-items: center; justify-content: center; gap: .45rem; }
  body { padding-bottom: calc(92px + env(safe-area-inset-bottom)); }
}
@media print { .actionbar { display: none; } }

/* ---- Reviews empty state. No star glyphs with nothing behind them. ---- */
.reviews-empty {
  border: 1px dashed var(--surface-rule); border-radius: var(--cb-radius-card);
  padding: var(--cb-space-6); text-align: center; color: var(--text-muted);
}

/* ---- Focus visibility, applied once, globally ---- */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 3px solid var(--ps-blue); outline-offset: 2px; border-radius: 3px;
}

/* ---- Skip link ---- */
.skip {
  position: absolute; left: -9999px; top: 0; z-index: 999;
  background: var(--ps-navy); color: #fff; padding: .75rem 1.1rem; border-radius: 0 0 6px 0;
}
.skip:focus { left: 0; }

/* ---- Anything decorative stops when the visitor asks it to ---- */
@media (prefers-reduced-motion: reduce) {
  .io .steps__flow::before { animation: none; }
  .io .washed .washed__layer { display: none; }
  .svc__media img { filter: none !important; transform: none !important; }
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}

/* ---- Nav wordmark ------------------------------------------------------
   The mark on its own is unreadable at nav size: 1024px of detail crushed
   into 44px is a tap-target minimum. The mark stays as the
   visual anchor and carries alt="" because the adjacent text is the
   accessible name, so a screen reader hears it once, not twice. */
.brandname {
  display: flex; flex-direction: column; line-height: 1.05;
  font-family: var(--cb-font-heading_family); font-weight: 700;
  font-size: 1.06rem; letter-spacing: -0.02em; color: var(--ps-navy);
}
.brandname small {
  font-family: var(--cb-font-body_family); font-weight: 500;
  font-size: .58rem; letter-spacing: .14em; text-transform: uppercase;
  color: var(--ps-blue); margin-top: .18rem;
}
@media (max-width: 430px) { .brandname { font-size: .95rem; } .brandname small { font-size: .52rem; } }

/* ---- Breadcrumbs ---- */
.crumbs { font-size: var(--cb-type-scale-small); color: var(--text-muted); margin-bottom: 1.4rem; }
.crumbs a { color: var(--ps-blue); }
.crumbs span[aria-hidden] { margin: 0 .4rem; opacity: .5; }

/* ---- Tick lists on the services page ---- */
.ticks { list-style: none; padding: 0; margin: 0; display: grid; gap: .55rem; }
.ticks li { position: relative; padding-left: 1.75rem; color: var(--text-muted); }
.ticks li::before {
  content: ""; position: absolute; left: 0; top: .34em; width: 1.05rem; height: 1.05rem;
  background: center / contain no-repeat
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2325623D' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}
.ticks--no li::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234A5768' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E");
}

/* ---- Breadcrumbs ---- */
.crumbs { font-size: var(--cb-type-scale-small); color: var(--text-muted); margin-bottom: 1.4rem; }
.crumbs a { color: var(--ps-blue); }
.crumbs span[aria-hidden] { margin: 0 .4rem; opacity: .5; }

/* ---- Tick lists on the services page ---- */
.ticks { list-style: none; padding: 0; margin: 0; display: grid; gap: .55rem; }
.ticks li { position: relative; padding-left: 1.75rem; color: var(--text-muted); }
.ticks li::before {
  content: ""; position: absolute; left: 0; top: .34em; width: 1.05rem; height: 1.05rem;
  background: center / contain no-repeat
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2325623D' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}
.ticks--no li::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%234A5768' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E");
}

/* ---- Mobile nav: stop the off-canvas panel widening the page -----------
   The kit parks .site-nav__links at translateX(100%) while closed. A fixed
   element sitting past the right edge still counts toward scrollWidth, so
   every page could be dragged sideways on a phone. Found in QA, 12 Aug 2026.

   overflow-x: clip rather than hidden, because hidden on <html> creates a
   scroll container and breaks position: sticky on the header.

   visibility also matters and is not cosmetic: while the menu is closed its
   links were still in the tab order, so a keyboard or screen-reader user on a
   phone tabbed into an invisible menu. */
html { overflow-x: clip; }
@media (max-width: 860px) {
  .site-nav__links { visibility: hidden; }
  .site-nav.is-open .site-nav__links { visibility: visible; }
  /* The panel needs a way out that does not depend on hunting for the
     hamburger again. */
  .site-nav.is-open .site-nav__links::before {
    content: "Menu"; font-family: var(--cb-font-heading_family);
    font-size: .72rem; letter-spacing: .16em; text-transform: uppercase;
    color: var(--text-muted); margin-bottom: -.6rem;
  }
  .site-nav__links a { min-height: 44px; display: inline-flex; align-items: center; }
}

/* ---- Tap targets, from the 12 Aug QA sweep --------------------------------
   The before/after control is the site's signature interaction and its hit
   area was the 4px track, which is miserable to grab on a phone. Give the
   input a full-height transparent hit area and draw the visible track with a
   gradient instead, so the thumb still looks like a 4px rail but the finger
   target is 44px. */
.ba__range { height: 44px; margin-top: .5rem; }
.ba__range::-webkit-slider-runnable-track {
  height: 44px; border-radius: 0; background: transparent;
  background-image: linear-gradient(var(--surface-rule), var(--surface-rule));
  background-size: 100% 4px; background-position: 0 center; background-repeat: no-repeat;
}
.ba__range::-moz-range-track {
  height: 44px; border-radius: 0; background: transparent;
  background-image: linear-gradient(var(--surface-rule), var(--surface-rule));
  background-size: 100% 4px; background-position: 0 center; background-repeat: no-repeat;
}
.ba__range::-webkit-slider-thumb { margin-top: 9px; }

/* FAQ rows and the brand link were both under the 44px minimum. */
.faq summary { min-height: 44px; }
.site-nav__brand { min-height: 44px; }

/* ---- Mobile menu: make position:fixed actually mean the viewport ----------
   The kit puts `backdrop-filter: blur(10px)` on .site-nav. A backdrop-filter
   establishes a containing block for position:fixed DESCENDANTS, so the
   off-canvas panel was being laid out against the 70px header instead of the
   viewport: a 70px-tall strip with the menu links spilling out above and
   below it. Verified in the browser at 390px on 12 Aug 2026 (panel rect came
   back 320x70, links at y=-138).

   Dropping the blur below 720px restores normal fixed positioning. Nothing is
   lost: the bar goes fully opaque at the same time, which is what the blur was
   compensating for. The 100dvh is belt and braces, and dvh is right rather
   than vh because of the iOS URL bar. */
@media (max-width: 860px) {
  .site-nav {
    -webkit-backdrop-filter: none; backdrop-filter: none;
    background: var(--surface-page);
    gap: clamp(8px, 2vw, 16px);
  }
  .site-nav__brand { flex-shrink: 0; }
  .site-nav__toggle { position: relative; z-index: 2; flex: 0 0 44px; }
  .site-nav__tel { position: relative; z-index: 2; }
  .site-nav__links {
    height: 100dvh; overflow-y: auto;
    padding-top: 5rem;          /* clear of the header and the close button */
    gap: .35rem;
  }
}


/* ---- Sticky action bar: hold it back until they start scrolling ----------
   On landing there is already a Get a free quote button in
   the hero, so a second one pinned across the bottom is shouting at someone
   who has not read a word yet. It slides up once they scroll past the hero,
   which is also the point they have shown some interest.

   The hiding is armed by site.js after its scroll listener is ready. If that
   file cannot load, the real contact links stay visible.
   The page keeps its bottom padding either way so nothing jumps. */
@media (max-width: 860px) {
  .actionbar.is-ready {
    transform: translateY(130%);
    visibility: hidden;
    transition: transform 320ms cubic-bezier(.22, 1, .36, 1);
    will-change: transform;
  }
  .actionbar.is-ready.is-up { transform: translateY(0); visibility: visible; }
}
@media (prefers-reduced-motion: reduce) {
  .actionbar.is-ready { transition: none; }
}

/* ============================================================================
   GREEN BRAND LAYER
   The ribbon green was only appearing in tick marks. He wants it working
   harder: glowing animated underlines, and a live green edge on the images
   and sliders.

   The contrast rule at the top of this file still holds. Everything below is
   a GLOW, a RULE or a BORDER. Not one of them is text, so --ps-green at full
   strength is fine here and would not be fine on a label.
   ========================================================================= */

@property --ps-angle {
  syntax: "<angle>"; initial-value: 0deg; inherits: false;
}

/* ---- Live green edge on images and sliders -------------------------------
   A conic gradient rotating inside a 2px ring. Drawn on a pseudo-element with
   a mask so only the ring paints, which keeps it off the photo itself.

   Only the two hero/results sliders and the About photo rotate continuously.
   Gallery tiles get the same ring but static, lighting up on hover, because
   thirteen rotating conic gradients on one page is a real GPU cost for very
   little extra charm. */
.ps-edge { position: relative; isolation: isolate; }
.ps-edge::before {
  content: ""; position: absolute; inset: -2px; z-index: -1;
  border-radius: calc(var(--cb-radius-image, 12px) + 2px);
  background: linear-gradient(135deg,
    var(--ps-green) 0%, #6FBF3A 30%, var(--ps-water) 62%, var(--ps-green) 100%);
  opacity: .85;
}
.ps-edge::after {
  content: ""; position: absolute; inset: -6px; z-index: -2;
  border-radius: calc(var(--cb-radius-image, 12px) + 6px);
  background: linear-gradient(135deg, var(--ps-green), var(--ps-water), var(--ps-green));
  filter: blur(11px); opacity: .38;
}
/* THE RINGS NO LONGER ROTATE, and that is a performance decision, not a
   taste one. Animating --ps-angle re-rasterises the whole conic gradient every
   frame, and one of the two layers has a 14px blur on top of it. Profiled at
   6x CPU throttle on a 390px viewport while scrolling the top of the page:

     as built .............. median 33.4ms/frame (30fps), worst 200ms
     rings not rotating .... median 16.7ms/frame (60fps), worst 117ms

   Three of them on the homepage halved the frame rate and made the hero feel
   stuck under a thumb, as reproduced on a real iPhone.
   A static green-to-blue edge with a slow opacity pulse looks all but
   identical and costs nothing: opacity is compositor-only.

   The nav CTA ring still rotates. It is one small element, on hover, and it
   is not competing with a scroll. */
.io .ps-edge::after { animation: ps-edge-breathe 4.5s ease-in-out infinite; }
@keyframes ps-edge-breathe { 0%, 100% { opacity: .34; } 50% { opacity: .6; } }
@keyframes ps-spin { to { --ps-angle: 360deg; } }

/* Where @property is missing the angle cannot animate, so give those browsers
   a fixed green-to-blue edge rather than a stuck conic seam. */
@supports not (background: conic-gradient(from var(--ps-angle), red, blue)) {
  .ps-edge::before { background: linear-gradient(135deg, var(--ps-green), var(--ps-water)); }
  .ps-edge::after  { background: linear-gradient(135deg, var(--ps-green), var(--ps-water)); }
}

/* Gallery tiles: same family, static until you point at them. */
.shots figure { position: relative; border-color: transparent; box-shadow: 0 0 0 1px var(--surface-rule); transition: box-shadow .25s ease; }
.shots figure:hover, .shots figure:focus-within {
  box-shadow: 0 0 0 2px var(--ps-green), 0 6px 22px rgba(82, 166, 47, .22);
}
.svc:hover, .svc:focus-within {
  border-color: var(--ps-green);
  box-shadow: 0 6px 22px rgba(82, 166, 47, .18);
}

/* The slider handle and seam pick the green up too, so the edge and the
   control read as one system rather than two unrelated ideas. */
.ba__seam { box-shadow: 0 0 0 1px rgba(82, 166, 47, .5), 0 0 12px rgba(82, 166, 47, .35); }
.ba.is-dragging .ba__seam::after { transform: translate(-50%, -50%) scale(1.12); }
.ba__range::-webkit-slider-thumb { box-shadow: 0 1px 6px rgba(5,37,90,.4), 0 0 0 3px rgba(82,166,47,.28); }
.ba__range::-moz-range-thumb     { box-shadow: 0 1px 6px rgba(5,37,90,.4), 0 0 0 3px rgba(82,166,47,.28); }

/* ---- Floating bubbles across the page ------------------------------------
   Requested addition. Worth noting the original brief warned
   against "a meaningless particle background covering the whole page", and
   the old site had a 180-particle canvas burning battery behind an
   opacity:0 layer. This is the opposite of that on purpose:

     - 12 elements, not 180, and pure CSS. No canvas, no rAF loop, no library.
     - transform and opacity only, so it never leaves the compositor.
     - fixed and pointer-events:none, so it cannot block a tap or add scroll.
     - aria-hidden, and behind everything at z-index 0.
     - 6 on a phone, none at all under reduced motion.

   Opacity is deliberately low. You should notice it only if you look. */
.bubbles {
  position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden;
  contain: strict;
}
.bubbles b {
  position: absolute; bottom: -12vh; display: block;
  border-radius: 50%;
  background:
    radial-gradient(circle at 32% 28%, rgba(255,255,255,.9), rgba(207,242,252,.55) 42%, rgba(71,169,220,.22) 72%, rgba(71,169,220,.05) 100%);
  box-shadow: inset 0 0 6px rgba(255,255,255,.7), 0 0 10px rgba(82,166,47,.10);
  opacity: 0;
}
.io .bubbles b { animation: ps-drift linear infinite; }
@keyframes ps-drift {
  0%   { opacity: 0;   transform: translate3d(0, 0, 0) scale(.7); }
  8%   { opacity: .5; }
  70%  { opacity: .38; }
  100% { opacity: 0;   transform: translate3d(var(--dx, 30px), -118vh, 0) scale(1.15); }
}
/* Each one gets its own lane, size, speed and drift. Written out rather than
   generated so there is no JS involved and nothing to go wrong. */
.bubbles b:nth-child(1)  { left:  4%; width: 16px; height: 16px; --dx:  34px; animation-duration: 26s; animation-delay:  0s; }
.bubbles b:nth-child(2)  { left: 13%; width:  9px; height:  9px; --dx: -22px; animation-duration: 34s; animation-delay: -6s; }
.bubbles b:nth-child(3)  { left: 22%; width: 22px; height: 22px; --dx:  48px; animation-duration: 41s; animation-delay: -14s; }
.bubbles b:nth-child(4)  { left: 31%; width: 12px; height: 12px; --dx: -38px; animation-duration: 29s; animation-delay: -3s; }
.bubbles b:nth-child(5)  { left: 41%; width: 18px; height: 18px; --dx:  26px; animation-duration: 37s; animation-delay: -21s; }
.bubbles b:nth-child(6)  { left: 49%; width:  7px; height:  7px; --dx: -30px; animation-duration: 24s; animation-delay: -11s; }
.bubbles b:nth-child(7)  { left: 58%; width: 20px; height: 20px; --dx:  42px; animation-duration: 44s; animation-delay: -30s; }
.bubbles b:nth-child(8)  { left: 66%; width: 11px; height: 11px; --dx: -25px; animation-duration: 31s; animation-delay: -8s; }
.bubbles b:nth-child(9)  { left: 74%; width: 15px; height: 15px; --dx:  36px; animation-duration: 39s; animation-delay: -18s; }
.bubbles b:nth-child(10) { left: 82%; width:  8px; height:  8px; --dx: -20px; animation-duration: 27s; animation-delay: -25s; }
.bubbles b:nth-child(11) { left: 90%; width: 24px; height: 24px; --dx:  30px; animation-duration: 46s; animation-delay: -36s; }
.bubbles b:nth-child(12) { left: 96%; width: 13px; height: 13px; --dx: -28px; animation-duration: 33s; animation-delay: -16s; }

@media (max-width: 720px) {
  .bubbles b:nth-child(n+7) { display: none; }   /* six is plenty on a phone */
}
@media (prefers-reduced-motion: reduce) {
  .bubbles { display: none; }
  .io .ps-edge::before, .io .ps-edge::after { animation: none; }
  .io .ps-head h2::before, .io .ps-hero h1::before { animation: none; opacity: 0; }
  .io .ps-head[data-reveal] h2::after { transform: scaleX(1); transition: none; }
}

/* The bubble layer is fixed at z-index 0. A positioned element with z-index 0
   paints ABOVE ordinary block content, so without this the bubbles would sit
   on top of the text. Lifting the three landmarks to z-index 1 puts them back
   in front, and leaves the bubbles visible through the white bands. */
header.site-nav { position: sticky; z-index: 100; }
/* main sits ABOVE .ps-foot, not level with it.
   Both were z-index:1, and because the footer comes later in the document it
   won the tie and painted over anything fixed inside main. On a phone that
   meant the footer covered the booking wizard's fixed Continue bar, so the
   last step of a booking could not be pressed at all. .wiz-nav's own z-index
   cannot fix that: main is a stacking context, so its whole subtree is
   composited at main's level, whatever the children ask for. */
main { position: relative; z-index: 2; }
.ps-foot { position: relative; z-index: 1; }
/* .actionbar is NOT in that list, and must never be. It is position:fixed
   inside a media query, and a media query adds no specificity, so a later
   `position: relative` on the same selector silently wins. That is exactly
   what happened: the sticky bar stopped being sticky on mobile and sat in the
   document flow at the very bottom of the page instead, which is what was
   photographed. position:fixed already creates its own stacking context, so
   it never needed the relative in the first place. */

/* Ring tuning after looking at it on screen: 2px under the stack's own grey
   1px border read as a hairline, not an edge. Drop the competing border, take
   the ring to 3px and lift the glow so the green actually registers. */
.ps-edge { border-radius: var(--cb-radius-image, 12px); }
.ps-edge.ba__stack { border-color: transparent; }
.ps-edge::before { inset: -3px; border-radius: calc(var(--cb-radius-image, 12px) + 3px); opacity: 1; }
.ps-edge::after  { inset: -9px; border-radius: calc(var(--cb-radius-image, 12px) + 9px);
                   filter: blur(14px); opacity: .5; }

/* The ring lives on a WRAPPER, never on .ba__stack itself. The stack needs
   overflow:hidden so the clip-path wipe stays inside its corners, and an
   element's own overflow clips its own pseudo-elements, so the ring was being
   cut away on three sides. Only the left edge survived, which is exactly how
   it looked on screen. */
.ps-edge--wrap { display: block; line-height: 0; }
.ps-edge--wrap .ba__stack { border-color: transparent; }

/* Underline: it was sitting -.42em below a 2.8rem heading, which put it
   straight through the paragraph underneath. Give the heading room of its own
   and tuck the rule inside it. */
.ps-head h2, .ps-hero h1 { padding-bottom: .34em; }
.ps-head h2::after, .ps-head h2::before,
.ps-hero h1::after, .ps-hero h1::before { bottom: .04em; }

/* ============================================================================
   ROUND THREE
   ========================================================================= */

/* ---- Buttons on the navy band were navy on navy -------------------------
   .btn-primary takes its background from --brand-primary, which IS the navy
   the band is painted in, so both CTAs rendered as bare text with an
   invisible border. Measured: button bg rgb(5,37,90) on section bg
   rgb(5,37,90).

   The fix doubles as more green. #8FD64F with navy text measures 8.39:1 for
   the label AND 8.39:1 against the band, so it passes AA comfortably and the
   button genuinely pops. The obvious brighter choice, --ps-green #52A62F,
   only manages 3.06:1 with white text and would have failed. */
.ps-sec--ink .btn-primary {
  background: #8FD64F; border-color: #8FD64F; color: var(--ps-navy);
  box-shadow: 0 0 0 0 rgba(143, 214, 79, .5);
  transition: box-shadow .25s ease, transform .2s ease, background .2s ease;
}
.ps-sec--ink .btn-primary:hover {
  background: #A3E06A; border-color: #A3E06A;
  box-shadow: 0 0 26px rgba(143, 214, 79, .45); transform: translateY(-1px);
}
.ps-sec--ink .btn-ghost {
  background: transparent; color: #fff; border-color: rgba(255, 255, 255, .55);
}
.ps-sec--ink .btn-ghost:hover { border-color: #fff; background: rgba(255, 255, 255, .1); }
.ps-sec--ink :where(a, button):focus-visible { outline-color: #8FD64F; }

/* ---- Underline: a loading bar the width of the text ----------------------
   The rule runs to the end of the text and fills like a loading bar.

   width:fit-content on the heading is what makes "the width of the text"
   possible: a block-level h2 is as wide as its container, so a 100% rule
   would run to the column edge, and the old fixed 180px stub was arbitrary.
   With fit-content the heading box hugs its longest line, so left:0/right:0
   on the pseudo-elements lands exactly on the text.

   ::before is the empty track, ::after is the fill. */
.ps-head h2, .ps-hero h1 {
  position: relative; width: fit-content; max-width: 100%;
  padding-bottom: .38em;
}
.ps-head h2::before, .ps-hero h1::before {
  content: ""; position: absolute; left: 0; right: 0; bottom: .02em;
  height: 4px; border-radius: 4px;
  background: rgba(5, 37, 90, .1);
}
.ps-head h2::after, .ps-hero h1::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: .02em;
  height: 4px; border-radius: 4px;
  background: linear-gradient(90deg, var(--ps-green) 0%, #6FBF3A 38%, var(--ps-water) 100%);
  box-shadow: 0 0 10px rgba(82, 166, 47, .5), 0 0 20px rgba(71, 169, 220, .3);
  transform-origin: left center;
}
/* Fills on reveal. Without JS it is simply already full. */
.io .ps-head[data-reveal] h2::after {
  transform: scaleX(0);
  transition: transform 1.1s cubic-bezier(.16, .84, .38, 1) .12s;
}
.io .ps-head[data-reveal].is-in h2::after { transform: scaleX(1); }
/* The hero has no reveal wrapper, so it fills on load. */
.io .ps-hero h1::after { animation: ps-load 1.2s cubic-bezier(.16, .84, .38, 1) .35s both; }
@keyframes ps-load { from { transform: scaleX(0); } to { transform: scaleX(1); } }

.ps-sec--ink .ps-head h2::before { background: rgba(255, 255, 255, .16); }
.ps-sec--ink .ps-head h2::after {
  background: linear-gradient(90deg, #8FD64F 0%, #7BC93F 38%, var(--ps-water) 100%);
  box-shadow: 0 0 12px rgba(143, 214, 79, .5), 0 0 24px rgba(143, 214, 79, .22);
}

/* ---- Centre the sections -------------------------------------------------
   These read better centred than pushed to the left. Applies to the
   section heads and to the full-width blocks under them. NOT to the split
   blocks, where the text is paired with a photo beside it and centring would
   break that pairing. */
.ps-head { margin-inline: auto; text-align: center; }
.ps-head h2 { margin-inline: auto; }
.ps-head p  { margin-inline: auto; }
.ps-split .ps-head, .ps-split > div > .eyebrow { text-align: left; }

.steps { text-align: center; }
.steps__flow { inset-inline: 16%; }
.areas { justify-content: center; }
.faq { text-align: left; }        /* answers still read left */
.svc__body { text-align: left; }  /* card copy still reads left */

/* Section-level CTA rows and standalone paragraphs line up with the head. */
.ps-sec > .wrap > .btn-group,
.ps-sec > .wrap > p:not(.bapair--cap) { justify-content: center; text-align: center; }

@media (max-width: 900px) {
  .ps-split .ps-head, .ps-split > div > .eyebrow { text-align: left; }
}

/* ---- Areas: a list of places, not a row of dead buttons ------------------
   They were pill-shaped, evenly spaced and left-aligned, with the first one
   outlined in blue, so they read as filter chips you could press. They are
   not interactive and there are no town pages yet. Restyled as plain labels,
   with Rhyl marked as the base rather than looking pre-selected. */
.areas { list-style: none; padding: 0; margin: 0; gap: .45rem .5rem; }
.areas li {
  border-radius: 8px; border-color: transparent;
  background: rgba(5, 37, 90, .045);
  color: var(--text-muted); font-weight: 500; cursor: default;
  padding: .5rem 1rem;
}
.areas li:first-child {
  background: rgba(82, 166, 47, .12);
  border: 1px solid rgba(82, 166, 47, .35);
  color: var(--ps-green-ink); font-weight: 650;
  display: inline-flex; align-items: center; gap: .4rem;
}
.areas li:first-child::before {
  content: ""; width: 1rem; height: 1rem; flex: none;
  background: center / contain no-repeat
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2325623D' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0'/%3E%3Ccircle cx='12' cy='10' r='3'/%3E%3C/svg%3E");
}

/* ---- Icon cards for the services with no photograph yet ------------------
   Eight services are offered. Four of them (render, decking,
   walls and brickwork, windows) have no photo in the set he has sent, and
   putting a driveway picture on the window cleaning card would be a small
   lie. These get a deliberate icon panel instead, in the same shape as the
   photo cards, so it reads as a design choice rather than a hole. Swap in a
   real photo the day one arrives and the card becomes a photo card. */
.svc__media--ico {
  display: grid; place-items: center;
  background:
    radial-gradient(70% 70% at 30% 25%, rgba(82, 166, 47, .10), transparent 70%),
    radial-gradient(70% 70% at 72% 74%, rgba(71, 169, 220, .14), transparent 70%),
    var(--surface-alt);
  position: relative;
}
.svc__media--ico::after {
  content: ""; position: absolute; inset: 0;
  background-image:
    linear-gradient(rgba(5,37,90,.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(5,37,90,.05) 1px, transparent 1px);
  background-size: 26px 26px;
}

/* Same idea at full size on the services page. */
.svc-figure {
  display: grid; place-items: center; gap: .9rem;
  aspect-ratio: 4 / 3; border-radius: var(--cb-radius-image);
  border: 1px dashed var(--surface-rule);
  background:
    radial-gradient(60% 60% at 30% 25%, rgba(82, 166, 47, .10), transparent 70%),
    radial-gradient(60% 60% at 72% 74%, rgba(71, 169, 220, .14), transparent 70%),
    var(--surface-alt);
}
.svc-figure__cap { font-size: var(--cb-type-scale-small); color: var(--text-muted); }

/* Eight services want four across on a wide screen, so they land 4+4 rather
   than 3+3+2 with two orphans. */
@media (min-width: 1180px) { .svc-grid { grid-template-columns: repeat(4, 1fr); } }

/* The rule spans the widest RENDERED line, which CSS alone cannot measure:
   width:fit-content resolves from max-content, i.e. the heading on ONE line,
   so on a heading that wraps the box is far wider than anything you can see.
   site.js measures the line boxes and sets --ul-w. Without JS it falls back to
   a sensible proportion rather than nothing. */
.ps-head h2, .ps-hero h1 { width: auto; --ul-w: 46%; }
.ps-head h2::before, .ps-head h2::after,
.ps-hero h1::before, .ps-hero h1::after { right: auto; width: var(--ul-w); }
.ps-head h2::before, .ps-head h2::after { left: 50%; margin-left: calc(var(--ul-w) / -2); }
.ps-hero h1::before, .ps-hero h1::after { left: 0; margin-left: 0; }

/* ---- Nav: the same loading bar under each link on hover -----------------
   Same idea as the section underlines, scaled down: a faint
   track fades in and the green fill sweeps across it from the left. Fill only
   would be a hover underline; the track is what makes it read as loading.

   Keyed off :focus-visible as well as :hover, so a keyboard user gets the
   same signal and the nav does not rely on a pointer. */
.site-nav__links a:not(.btn) {
  position: relative;
  padding-bottom: .42rem;
}
.site-nav__links a:not(.btn)::before,
.site-nav__links a:not(.btn)::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0;
  height: 2.5px; border-radius: 3px;
  pointer-events: none;
}
.site-nav__links a:not(.btn)::before {
  background: rgba(5, 37, 90, .12);
  opacity: 0; transition: opacity .18s ease;
}
.site-nav__links a:not(.btn)::after {
  background: linear-gradient(90deg, var(--ps-green) 0%, #6FBF3A 42%, var(--ps-water) 100%);
  box-shadow: 0 0 8px rgba(82, 166, 47, .55);
  transform: scaleX(0); transform-origin: left center;
  transition: transform .42s cubic-bezier(.16, .84, .38, 1);
}
.site-nav__links a:not(.btn):hover::before,
.site-nav__links a:not(.btn):focus-visible::before { opacity: 1; }
.site-nav__links a:not(.btn):hover::after,
.site-nav__links a:not(.btn):focus-visible::after { transform: scaleX(1); }
.site-nav__links a:not(.btn):hover { opacity: 1; color: var(--ps-navy); }

/* Keyboard focus on the nav links.
   This used to be `outline: none`, on the reasoning that the green hover bar
   already showed where you were. Screenshotting a real Tab proved that wrong:
   the bar is a 2.5px rule under one word, identical to what a mouse gives you,
   and it was the weakest focus cue on the site sitting on the page's main
   choices. The bar stays; the ring comes back on top of it. */
.site-nav__links a:not(.btn):focus-visible {
  outline: 3px solid var(--ps-blue);
  outline-offset: 4px;
  border-radius: 4px;
  opacity: 1;
}

/* ---- Nav CTA: green travels round the outside of the navy button --------
   A conic gradient masked to a 3px ring, so only the ring paints and the
   button keeps its navy face. This is the mask-composite border trick rather
   than a z-index:-1 pseudo-element: .site-nav carries a backdrop-filter on
   desktop, which makes it a stacking context, and a negative-z-index child
   would paint over the button's own background and tint it green. */
.site-nav__links .btn-primary { position: relative; isolation: isolate; }
.site-nav__links .btn-primary::after {
  content: ""; position: absolute; inset: -3px; pointer-events: none;
  border-radius: calc(var(--cb-radius-btn, 6px) + 3px);
  padding: 3px;
  background: conic-gradient(from var(--ps-angle),
    var(--ps-green) 0deg, #8FD64F 90deg, var(--ps-water) 180deg, #8FD64F 270deg, var(--ps-green) 360deg);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
          mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
          mask-composite: exclude;
  opacity: 0; transition: opacity .22s ease;
}
/* The glow is a box-shadow, NOT a pseudo-element. Inside a stacking context
   a z-index:-1 child paints above its own parent's background, so the first
   version tinted the navy button face green from the inside. A box-shadow
   only ever paints outside the border box. */
.site-nav__links .btn-primary {
  transition: box-shadow .22s ease, transform .18s ease;
}
.site-nav__links .btn-primary:hover,
.site-nav__links .btn-primary:focus-visible {
  box-shadow: 0 0 20px rgba(82, 166, 47, .5), 0 4px 16px rgba(5, 37, 90, .25);
  transform: translateY(-1px);
}
.site-nav__links .btn-primary:hover::after,
.site-nav__links .btn-primary:focus-visible::after { opacity: 1; }
.io .site-nav__links .btn-primary:hover::after,
.io .site-nav__links .btn-primary:focus-visible::after { animation: ps-spin 2.4s linear infinite; }

/* Where @property is missing the angle cannot animate, so give those browsers
   a fixed green-to-blue ring rather than a stuck conic seam. */
@supports not (background: conic-gradient(from var(--ps-angle), red, blue)) {
  .site-nav__links .btn-primary::after {
    background: linear-gradient(135deg, var(--ps-green), #8FD64F, var(--ps-water));
  }
}

@media (prefers-reduced-motion: reduce) {
  .site-nav__links a:not(.btn)::after { transition: none; }
  .io .site-nav__links .btn-primary:hover::after,
  .io .site-nav__links .btn-primary:focus-visible::after { animation: none; }
}

/* ---- Hero: centre the logo with the text --------------------------------
   The logo sat left of a left-aligned block and the two did
   not line up with each other. Centring the whole copy column also matches
   what the rest of the page now does. The slider stays where it is; only the
   text column changes. */
.ps-hero__copy { text-align: center; }
.ps-hero .hero-logo { margin-inline: auto; }
.ps-hero h1 { margin-inline: auto; }
.ps-hero .lead, .ps-hero__micro { margin-inline: auto; }
.ps-hero .btn-group { justify-content: center; }
/* The underline follows the heading rather than staying pinned left. */
.ps-hero h1::before, .ps-hero h1::after { left: 50%; margin-left: calc(var(--ul-w) / -2); }

/* The hero mesh + grain also showed up in the scroll profile, adding ~100ms
   spikes on a throttled phone. Keep it on desktop where there is headroom,
   drop the grain and soften the mesh on a small screen. */
@media (max-width: 860px) {
  .hero__atmos { opacity: .55; }
  .hero__atmos.tex-grain::after { display: none; }
}

/* Two matching before/after pairs, not the text-left / image-right layout the
   base .ps-split is tuned for. Equal columns, tops aligned: with 1fr 1.1fr and
   align-items:center the two pairs came out different sizes and floated to
   different heights, which read as a mistake rather than a comparison. */
/* Double class on purpose: page.css loads AFTER brand.css and defines
   .ps-split, so a single-class rule here loses the cascade on equal
   specificity. .ps-split.ps-split--even (0,2,0) wins wherever it sits.

   Two matching before/after pairs, not the text-left / image-right layout the
   base .ps-split is tuned for. Equal columns, and the four rows (heading,
   intro, photo pair, caption) line up across both columns via subgrid.
   min-height guesses were tried first and only held at the widths they were
   measured at: at 1000px one intro wrapped to a third line and the photos
   drifted apart again. Subgrid has no magic numbers to get wrong. Where it is
   unsupported the columns simply flow normally, which is the old behaviour. */
.ps-split.ps-split--even {
  grid-template-columns: minmax(0,1fr) minmax(0,1fr);
  grid-template-rows: auto auto auto auto;
  align-items: start;
}
.ps-split.ps-split--even > div {
  display: grid;
  grid-row: span 4;
  grid-template-rows: subgrid;
  gap: 0;
}
.ps-split.ps-split--even > div > h3 { margin: 0 0 .5rem; align-self: start; }
.ps-split.ps-split--even > div > h3 + p { margin: 0 0 1rem; align-self: start; }
.ps-split.ps-split--even .bapair--cap { margin: .7rem 0 0; align-self: start; }
@media (max-width: 880px) {
  .ps-split.ps-split--even { grid-template-columns: 1fr; grid-template-rows: none; }
  .ps-split.ps-split--even > div { display: block; grid-row: auto; }
}

/* ---- Prose links must look like links -------------------------------
   kit.css:20 sets `a { color: inherit; text-decoration: none }` globally,
   which is right for nav, cards and buttons that carry their own affordance
   and wrong for a link sitting inside a paragraph. On /privacy/, /cookies/
   and the 404 the prose links rendered in the exact colour of the sentence
   around them, 1.00:1, no underline, no hover change. Underline, not colour,
   because colour alone fails SC 1.4.1 and disappears in greyscale. */
main p a:not(.btn),
main li a:not(.btn),
main dd a:not(.btn),
main td a:not(.btn),
.crumbs a,
.reviews-empty a:not(.btn) {
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: 1px;
  color: var(--ps-blue);
}
main p a:not(.btn):hover, main li a:not(.btn):hover, main dd a:not(.btn):hover,
main td a:not(.btn):hover, .crumbs a:hover,
main p a:not(.btn):focus-visible, main li a:not(.btn):focus-visible,
.crumbs a:focus-visible {
  text-decoration-thickness: 2px;
}
/* The dark CTA band needs the light link colour, not navy-on-navy. */
.ps-sec--ink main p a:not(.btn), .ps-sec--ink p a:not(.btn) { color: #8FD64F; }


/* ---- A data table must stay reachable at 320px ----------------------
   kit.css sets overflow-x: hidden on html/body, so a table wider than the
   viewport is not merely cut off, it cannot be scrolled to at all. 320px is
   the WCAG reflow width. The wrapper gives the table back its own scroll. */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-scroll:focus-visible { outline: 3px solid var(--ps-blue); outline-offset: 2px; }

/* ============================================================
   HERO: the patio cleans itself.

   The hero should not ask anyone to drag anything.
   It runs on its own so the transformation lands before the visitor has
   decided whether to scroll. The draggable slider is still on /results/,
   where someone who wants to poke at the evidence will go looking.

   One animated custom property drives everything: the clip on the BEFORE
   image, the wash line, and the two labels, all of which already read --pos.
   That is why it needs @property — a plain custom property is a string to the
   animation engine and jumps between keyframes instead of tweening.

   --pos is the percentage of BEFORE still showing: 100 = dirty, 0 = clean.
   ============================================================ */
@property --pos {
  syntax: '<number>';
  inherits: true;
  initial-value: 50;
}

.ba--auto .ba__stack { cursor: default; }

.ba--auto {
  animation: ps-wash 9s cubic-bezier(.4, 0, .2, 1) infinite;
  animation-delay: var(--wash-delay, 0s);
  /* Off-screen it is pure wasted paint. site.js flips this to running. */
  animation-play-state: paused;
}
.ba--auto.is-onscreen { animation-play-state: running; }
/* Several on one page wiping in unison looks like a screensaver. Offsetting
   them means one is always mid-pass while another rests, which reads as the
   page being alive rather than as one effect applied N times. site.js sets
   --wash-delay per figure from its position in the document. */

@keyframes ps-wash {
  /* hold on dirty just long enough to register what you are looking at */
  0%,   7%   { --pos: 100; }
  /* the pass. slowest part of the loop, because it is the bit worth watching */
  40%        { --pos: 0; }
  /* hold on clean. the longest beat, so the resting impression is the result */
  78%        { --pos: 0; }
  /* ease back, quicker, so it reads as a reset rather than as re-dirtying */
  96%, 100%  { --pos: 100; }
}

/* The wash line: a soft edge that travels with the pass, not a slider grip.
   Positioned with translate, never `left`, because animating `left` is a
   layout property and this runs sixty times a second. */
.ba--auto .ba__seam {
  left: 0;
  margin-left: 0;
  width: 2px;
  transform: translateX(calc(var(--pos, 50) * 1cqw));
  background: linear-gradient(180deg,
              rgba(255,255,255,.25) 0%, #fff 18%, #fff 82%, rgba(255,255,255,.25) 100%);
  box-shadow: 0 0 10px 2px rgba(162, 223, 248, .75),
              0 0 22px 6px rgba(71, 169, 220, .35);
  opacity: .95;
}
/* cqw needs a container to measure against */
.ba--auto .ba__stack { container-type: inline-size; }

/* The grip circle belongs to a control. There is no control here. */
.ba--auto .ba__seam::after { display: none; }

/* Both ends of the loop sit on a solid state, so fade the line out there and
   it reads as a wash passing rather than a bar parked at the edge. */
.ba--auto .ba__seam { animation: ps-wash-line 9s linear infinite; animation-delay: var(--wash-delay, 0s); }
.ba--auto:not(.is-onscreen) .ba__seam { animation-play-state: paused; }
@keyframes ps-wash-line {
  0%, 9%     { opacity: 0; }
  16%        { opacity: .95; }
  34%        { opacity: .95; }
  44%, 76%   { opacity: 0; }
  86%        { opacity: .7; }
  97%, 100%  { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  /* No loop at all. Park it half and half: still an honest before and after,
     just one that holds still. */
  .ba--auto, .ba--auto .ba__seam { animation: none; }
  .ba--auto { --pos: 50; }
  .ba--auto .ba__seam { opacity: .95; }
}

/* One big reveal per section, rather than two small photos side by side.
   Paired thumbnails read as a contact sheet, whereas the
   single large frame reads as the work. */
/* width:100% is load-bearing. As a grid item, `margin-inline:auto` makes the
   figure shrink-to-fit instead of stretch, and .ba__stack carries
   container-type:inline-size so it has no intrinsic width to fit to. The five
   reveals came out at five different sizes (404px to 598px) with no pattern.
   An explicit width gives max-width something to cap. */
.ba--feature { width: 100%; max-width: 900px; margin-inline: auto; justify-self: center; }
.ba--feature figcaption { text-align: center; }
.reveal-stack { display: grid; gap: clamp(2.4rem, 5vw, 4rem); }

/* ---- Placeholder panels for services with no photo yet ---------------
   Five services (render, decking, walls, windows, commercial) do not yet have
   photographs. These panels carry a soft-focused, desaturated material
   texture with the service icon over the top.

   They are illustration, never evidence: never captioned as a job, never
   paired as a before and after, and never used in the results gallery. The
   alt text says "Illustration" out loud. Replace them when photographs of
   these five services are available. */
.svc-figure--tex, .svc__media--tex { position: relative; overflow: hidden; }
.svc-figure--tex { aspect-ratio: 4 / 3; border-radius: var(--cb-radius-image); border: 1px solid var(--surface-rule); display: grid; place-items: center; }
.tex__img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
/* The scrim, rebuilt 27 Aug 2026 after measuring the panels as rendered.

   It used to be a flat rgba(5,37,90,.62) navy across the whole panel. That was
   measured against the images actually underneath it and it flattened every one
   of them to the same blue mush: the panel read as a blue rectangle with an icon
   on it rather than as a picture of a material. Replacing the images without
   touching this would have changed nothing a visitor could see.

   But simply lifting it broke the other half of its job. Measured on the
   deployed page: with an even light tint, the render card came back at mean
   luminance 198 and the WHITE icon had 3.8 points of contrast against the panel
   behind it. Invisible. The old scrim was heavy for a reason.

   So the darkening now sits WHERE THE ICON IS instead of over everything: a soft
   radial pool behind the centred icon, clearing towards the edges so the
   material still reads. Bright images get a legible icon, dark images do not go
   muddy, and the panel is still tinted enough to sit in the brand and to stay
   visibly an illustration rather than a photograph of somebody's job.

   The service DETAIL pages carry the same panel with NO icon inside it, so a
   dark pool there would be a shadow with nothing casting it. :has() splits the
   two cases. */
/* The scrim over the illustration panels.

   It has been wrong twice, in opposite directions. It began as a flat rgba(5,37,90,.62)
   navy across the whole panel, which crushed every image under it to the same blue mush.
   Lifting it to an even tint then left the white centre icon with 3.8 points of contrast
   against the panel, invisible, so it became a radial pool sitting behind that icon.

   30 Aug 2026: the icons were removed from all five panels, so the pool is a shadow with
   nothing casting it. One light tint now, which is what the icon-less service detail pages
   have carried since 27 Aug.

   It still earns its place: it holds these panels in the brand beside the three real job
   photographs, and keeps them a shade darker and cooler than those photographs, which is
   part of what stops a generated material study reading as a picture of somebody's job. */
.svc-figure--tex::after, .svc__media--tex::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(160deg, rgba(5,37,90,.14) 0%, rgba(5,37,90,.08) 45%, rgba(5,37,90,.28) 100%);
}
.svc__media--tex { aspect-ratio: 4 / 3; display: grid; place-items: center; }

/* ============================================================
   Layout and accessibility corrections
   ============================================================ */

/* 1. Legal prose was centred. The rule above was written for marketing
   sections, but /privacy/ and /cookies/ put their body copy directly inside
   .ps-sec > .wrap, so it caught all 13 paragraphs on one and all 10 on the
   other while headings and tick lists stayed left. Multi-line legal copy
   ragged on both edges, next to left-aligned headings, in one column. */
/* Specificity matched deliberately: the rule this overrides is
   `.ps-sec > .wrap > p:not(.bapair--cap)` at (0,3,1). A plain
   `.wrap--prose > p` is (0,1,1) and loses however far down the file it sits. */
.ps-sec > .wrap--prose > p:not(.bapair--cap),
.ps-sec > .wrap--prose > .btn-group { text-align: start; justify-content: flex-start; }

/* 3. `* { margin: 0 }` in the kit resets paragraph spacing and nothing puts it
   back, so two adjacent <p> touched exactly: measured gap 0px on the About
   block and on seven pairs across the legal pages. They read as one run-on
   wall with an unexplained mid-sentence break. */
main p + p { margin-top: 1em; }

/* 4. The services hero CTA sat left of a centred heading, 229px off at 1440.
   .btn-group is a flex container, so the inherited text-align does nothing,
   and the justify-content that would fix it only matches .wrap > .btn-group. */
.ps-head .btn-group { justify-content: center; }
.ps-split .ps-head .btn-group { justify-content: flex-start; }

@media (max-width: 860px) {
  /* 5. 92px of blank white page below the footer on /thanks/, /privacy/ and
     /cookies/. The padding exists to clear the sticky action bar, but only
     three pages have one. Keyed off the bar itself now, the same way
     booking.css keys off body.has-wizard. */
  body { padding-bottom: 0; }
  body:has(.actionbar) { padding-bottom: calc(92px + env(safe-area-inset-bottom)); }
}

/* 2. On a short viewport the field you tab into scrolled to just under the
   fixed Continue bar, so you were typing into something you could not see. */
@media (max-width: 860px) {
  html { scroll-padding-bottom: 130px; }
}

/* Native fragment navigation works before scripts load and if they fail.
   Its 44px Menu link occupies the same slot as the enhanced button, so a
   delayed script never collapses an expanded header and shifts the page. */
.site-nav__fallback, .site-nav__fallback-close { display: none; }
html:not(.nav-ready) .site-nav__toggle { display: none !important; }
@media (max-width: 860px) {
  header.site-nav { z-index: 200; }
  html:not(.nav-ready) .site-nav__fallback {
    display: inline-flex; flex: 0 0 44px; width: 44px; height: 44px;
    align-items: center; justify-content: center;
    position: relative; z-index: 2; font-size: .8rem; font-weight: 600;
  }
  html:not(.nav-ready) .site-nav__links:target {
    transform: translateX(0); visibility: visible;
  }
  html:not(.nav-ready) .site-nav__links:target .site-nav__fallback-close {
    display: inline-flex; margin-top: .6rem;
  }
}
@media (max-width: 860px) and (max-height: 520px) {
  .site-nav__links { justify-content: flex-start; }
}

/* Service page hero: the copy gets the wider column, not the photo. The base
   .ps-split is 1fr 1.1fr, which is right when the image is the point and wrong
   when a long H1 like "Block paving re-sanding and patio re-grouting in North
   Wales" has to live in the narrow half. */
.ps-split--copy { grid-template-columns: minmax(0,1.25fr) minmax(0,1fr); }
@media (max-width: 880px) { .ps-split.ps-split--copy { grid-template-columns: 1fr; } }

/* ---- Header call button, added 3 Sept 2026 ------------------------------
   The site had no phone number in the header on any page except /book/. For a
   trade that gets found on "pressure washing near me", on a phone, that is the
   single most expensive omission on the site: the visitor who is ready to ring
   had nothing to press.

   It sits OUTSIDE .site-nav__links on purpose. Those links are hidden behind
   the burger under 720px, and a call button you have to open a menu to find is
   not a call button. */
.site-nav__tel {
  display: inline-flex; align-items: center; gap: .45rem;
  min-height: 44px; padding: 0 .85rem;
  border: 1px solid var(--line, rgba(5,37,90,.18)); border-radius: 999px;
  font-weight: 700; font-size: .95rem; text-decoration: none;
  color: var(--brand-deep, #05255A); background: transparent;
  white-space: nowrap;
}
.site-nav__tel:hover,
.site-nav__tel:focus-visible { border-color: var(--brand, #13549D); color: var(--brand, #13549D); }
.site-nav__tel svg { flex: 0 0 auto; }

@media (max-width: 860px) {
  /* Keep the number itself on screens with room; below that the icon alone is
     an unambiguous call affordance and the 44px target is what matters. */
  .site-nav__tel { margin-left: auto; margin-right: .4rem; padding: 0 .7rem; }
}
@media (max-width: 400px) {
  .site-nav__tel span { position: absolute; width: 1px; height: 1px; overflow: hidden;
    clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
  .site-nav__tel { padding: 0; width: 44px; justify-content: center; }
}

/* ================= 3 Sept 2026 audit fixes ================= */

/* ---- 1. The illustration panels need a marker a SIGHTED visitor can see.
   The alt text says "Illustration" and the tint is now so light that nothing
   else distinguishes these five panels from the three real job photographs
   beside them. Alt text is not a disclosure for someone looking at the page.
   A homeowner scanning the grid on a phone cannot currently tell which tiles
   are Prime Shine's work and which are generated material studies. Given the
   site's own promise that "every before and after is a real Prime Shine job",
   that ambiguity is the one thing that could make an honest claim read as a
   dishonest one. Label them. */
.svc-figure--tex::before, .svc__media--tex::before {
  content: "Illustration";
  position: absolute; z-index: 2; top: .5rem; left: .5rem;
  padding: .2rem .5rem; border-radius: 999px;
  font-family: var(--cb-font-heading_family, inherit);
  font-size: .62rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
  color: #fff; background: rgba(5,37,90,.72);
  -webkit-backdrop-filter: saturate(140%) blur(2px); backdrop-filter: saturate(140%) blur(2px);
  pointer-events: none;
}

/* ---- 2. Review stars rendered navy, the same colour as the body text, so
   five stars read as five dark glyphs rather than as a rating. Gold is the
   convention every visitor already knows. */
/* #E8A317 was the obvious "review gold" and measured 2.17:1 on white, which
   fails WCAG 2.2 AA. The stars carry the rating, so they are information, not
   decoration. #A0691A is a deep amber that still reads unmistakably as a review
   star and measures 4.64:1. Verified by calculation, not by eye. */
.quote__stars { color: #A0691A; letter-spacing: .12em; }

/* ---- 3. The floating bubbles read as smudges, not soap.
   At the current opacity they look like dust on the lens or JPEG artefacts.
   That is a poor look anywhere and an actively bad one on /results/, which
   exists to prove this company leaves surfaces clean. Two changes: give them
   a defined rim so they read as bubbles, and take them off the proof page
   entirely. */
.bubbles b {
  box-shadow:
    inset 0 0 0 1px rgba(255,255,255,.55),
    inset 0 0 6px rgba(255,255,255,.7),
    0 0 10px rgba(82,166,47,.10);
}
body.page-results .bubbles { display: none; }

/* The rim helped on desktop but on a phone these still read as marks on the
   screen rather than as soap. On a business whose whole promise is "we leave
   surfaces clean", faint blobs over the headline work against the pitch, and
   phones are where most of this traffic lands. Off under 720px. */
@media (max-width: 720px) { .bubbles { display: none; } }

/* Reviews source line, added 3 Sept 2026. The ten reviews are Google's and
   nothing said so once they rendered, so a visitor had no way to check they
   were real and a happy customer had no route to leave one. */
.reviews-source{
  margin-top:1.4rem; padding-top:1.1rem; border-top:1px solid var(--surface-rule, rgba(5,37,90,.12));
  display:flex; flex-wrap:wrap; align-items:center; gap:.85rem 1.2rem;
}
.reviews-source__line{ margin:0; font-size:.95rem; color:var(--text-muted); flex:1 1 260px; }
.reviews-source__cta{ flex:0 0 auto; }

/* System contrast colours must also reach custom focus indicators. */
@media (forced-colors: active) {
  :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
    outline: 3px solid Highlight !important;
    outline-offset: 3px;
  }
  .field input:focus, .field textarea:focus { outline: 3px solid Highlight; }
  .bubbles, .hero-bubbles { display: none; }
}
