/* ==========================================================================================
   Paul's Plastering Services
   One stylesheet, shared by index.html and portfolio.html. Change it here and both pages
   follow. No framework, no build step, one 14KB self-hosted font file.

   THE REGISTER, set 2026-09-20 against a named reference.
   Will's brief: "premium professional", pointing at https://www.12.studio. What was measured
   off that page, rather than described:
     ground #121212 (not black)   text #F7F7F7 (not white)   the whole palette is 4 values
     EVERY WEIGHT IS 400. Nothing on it is bold, not even the 58px headings
     headings lowercase, serif display, very large. Labels tiny, uppercase, letterspaced
     enormous negative space, few words
   So the moves here are: near-black ground, one weight, a serif at a size it has never been
   used at on a trade site, hairlines instead of cards, and colour spent once.

   COLOURS ARE MEASURED, NOT CHOSEN. Sampled off the logo artwork in content/brand/:
   green #A4C044, black #000000, his grey #949494. It is a yellow-green, an olive lime, not a
   pure or emerald green, and getting that wrong is the quickest way to make this look unlike
   his brand. His grey is used as-is for every label on the page.

   CONTRAST, computed rather than assumed, against the #121212 ground:
     #F7F7F7 text        17.5:1
     #C9C9C4 secondary   11.3:1
     #949494 his grey     6.2:1   so it is safe at 11px label sizes
     #A4C044 green        9.1:1
     black on green      10.2:1   so a green button takes BLACK text. Never white: green on
                                  white is 2.06:1 and fails outright.

   WHERE THE GREEN GOES, and this is the rule that changed. It used to be on the eyebrows, the
   headings, the stat numbers, the service icons, the review badges, the nav hover and the map.
   On a #121212 ground one green element per screen is more confident than five, so green is
   now reserved for ONE thing: ringing Paul. The call pill, the call buttons, the call bar and
   the focus ring. Everything that was decorative green is now his grey or a hairline.
   ========================================================================================== */

/* The display face. Spectral 400, latin only, self-hosted at public/font/ so there is no
   Google request, no cookie banner implication and nothing to break if fonts.gstatic is
   blocked on somebody's work wifi. 14KB. ONE weight, because the register has one weight and
   a file that cannot supply a bold is a register that cannot drift into one.
   The fallback stack is real serifs in preference order, so a page that never gets the file
   still renders in the right register rather than in a sans. */
@font-face{
  font-family:"Spectral";
  src:url("/font/spectral-400-latin.woff2") format("woff2");
  font-weight:400;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+2000-206F,U+20AC,U+2122,U+2212,U+FEFF,U+FFFD;
}

:root{
  color-scheme: dark;

  --brand-green:#A4C044;       /* measured off his logo */
  --brand-green-lit:#B4CE5D;   /* hover only. derived, not a second brand colour */
  --brand-black:#000000;       /* measured. his artwork is a true black */
  --brand-grey:#949494;        /* the grey in his own contact line, used for every label */

  --ground:#121212;            /* the page. measured off the reference, not pure black */
  --ground-2:#1A1A19;          /* the two raised bands, a hair warm so it is not a grey step */
  --text:#F7F7F7;              /* measured off the reference, not pure white */
  --text-2:#C9C9C4;            /* secondary body copy */
  --muted:var(--brand-grey);   /* labels, captions, meta. HIS grey, 6.2:1 */
  --muted-2:#7E7E78;           /* footer meta only. 4.6:1, still AA at 13px */

  --on-green:var(--brand-black);              /* text ON a green fill. never white. */
  --rule:rgba(247,247,247,.14);               /* a visible hairline */
  --rule-2:rgba(247,247,247,.07);             /* a structural hairline */

  --font-display:"Spectral","Iowan Old Style","Palatino Linotype",Palatino,"Times New Roman",Georgia,serif;
  --font-body:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;

  /* ONE WEIGHT. b and strong inherit it further down, so a bold cannot creep back in through
     markup. The single exception is the call, which is declared once here and used three
     times: a phone number is the conversion event on this site and it is allowed to be the
     firmest thing on the page. 500 is not bold. */
  --w:400;
  --w-call:500;

  /* Heading case. The reference sets every headline lowercase and it is the most recognisable
     thing about it. It is ONE VALUE because a lowercase "aylesbury" is a taste call Paul may
     not want: set this to `none` and every heading on both pages goes back to sentence case.
     The HTML keeps its real capitals either way, so search engines and screen readers are
     unaffected by whatever this says. */
  /* Was lowercase, copied from the 12.studio reference. Will overruled it on 2026-09-20:
     his client's trade name is PAUL'S PLASTERING SERVICES and lowercasing the trade reads
     as an affectation on a tradesman's site. The source H1 always carried the capital;
     only this transform was flattening it. Do not put it back.

     Put back once, at 20:47, by the design lane, which read the value with grep and never
     read the four lines above it. Reverted the same minute. The instruction was already in
     the file, in the one place anybody changing this value would look. */
  --head-case:none;

  /* spacing scale. Used everywhere instead of ad hoc numbers, which is what actually makes a
     page look expensive. Wider than it was: the reference's main material is empty space. */
  --s1:6px; --s2:10px; --s3:16px; --s4:26px; --s5:40px; --s6:64px; --s7:96px;
  --sp:clamp(68px,11vw,132px);       /* the vertical beat between sections */
  --gut:clamp(18px,5vw,44px);        /* the page gutter. The hero photograph bleeds by exactly
                                        this, so it must stay a token, not a literal. */
  --r:2px;                           /* the only radius on the page. Squared, not rounded. */

  --callbar:76px;              /* measured: the bar renders 70px, plus the safe-area inset */
}

*{box-sizing:border-box}
html{
  scroll-behavior:smooth;-webkit-text-size-adjust:100%;
  /* the sticky header is ~52px. Without this, an in-page link puts its heading underneath it */
  scroll-padding-top:70px;
}
body{
  margin:0;background:var(--ground);color:var(--text);
  font:400 17px/1.66 var(--font-body);
  overflow-x:hidden;
  -webkit-font-smoothing:antialiased;
  padding-bottom:calc(var(--callbar) + env(safe-area-inset-bottom,0px));
}
@media(min-width:760px){ body{padding-bottom:0} }
p{margin:0}
a{color:inherit}
img{max-width:100%;height:auto;display:block}
svg{max-width:100%}
/* EVERY icon on this page is a `viewBox="0 0 24 24"` with NO width attribute, so any one that
   no selector happens to size fills its container and renders as a full-width watermark. There
   are 17 of them and the sizing rules below are all class-scoped (.callbtn svg, .btn svg,
   .b-line svg). The five in the review cards carry no class at all, so they were rendering as
   ~345px-tall grey thumbs, one per review, which was the single ugliest thing on the page.
   Default them small. The class-scoped rules below have equal specificity and come later, so
   they still win, and .mapwrap svg at the map still wins over this. */
svg[aria-hidden="true"]{width:16px;height:16px;flex:none}
/* One weight means one weight. A <b> in the copy emphasises with COLOUR, not with weight,
   which is the whole reason this page reads calm. */
b,strong{font-weight:var(--w);color:var(--text)}

.shell{max-width:68rem;margin:0 auto;padding-inline:var(--gut)}
/* Never the `padding` shorthand on anything carrying .shell: it resets padding-inline and the
   text ends up flush against the edge of a phone screen. */

/* ------------------------------------------------------------------------------ typography */
h1,h2,h3{
  margin:0;font-family:var(--font-display);font-weight:var(--w);
  text-transform:var(--head-case);letter-spacing:-.012em;line-height:1.05;
  text-wrap:balance;
}
h1{font-size:clamp(2.75rem,11.5vw,4.75rem);line-height:1.02;letter-spacing:-.022em}
h2{font-size:clamp(2.125rem,8vw,3.5rem)}
h3{font-size:clamp(1.375rem,4.4vw,1.625rem);line-height:1.15}

/* The micro label. Tiny, uppercase, heavily letterspaced, his grey. It is the reference's one
   piece of furniture and it is used here only where it carries something the heading does not:
   the trade and the town, the postcodes he covers, where a review came from. A label that
   merely repeats the heading underneath it has been deleted rather than restyled. */
.eyebrow{
  font-family:var(--font-body);font-size:.6875rem;line-height:1.5;font-weight:var(--w);
  letter-spacing:.22em;text-transform:uppercase;color:var(--muted);margin:0;
}
:where(a,button,summary):focus-visible{
  outline:2px solid var(--brand-green);outline-offset:3px;border-radius:var(--r);
}

/* ---------------------------------------------------------------------------- header + nav */
header{
  position:sticky;top:0;z-index:40;
  background:rgba(18,18,18,.88);
  backdrop-filter:blur(12px);border-bottom:1px solid var(--rule-2);
}
.bar{display:flex;align-items:center;gap:var(--s3);padding-block:10px}
.logo{display:flex;align-items:center;gap:var(--s2);text-decoration:none;min-width:0}
.logo .mark{
  width:30px;height:30px;flex:0 0 30px;border-radius:50%;
  /* the source is a 114px crop of a phone screenshot, so a hairline tidies the edge */
  box-shadow:0 0 0 1px rgba(247,247,247,.16);
}
.nm{
  font-family:var(--font-display);font-size:1.0625rem;font-weight:var(--w);
  letter-spacing:-.008em;line-height:1.05;white-space:nowrap;
}
/* WILL, 2026-09-20: "Only Paul's is bold not the whole name". He is reading his own logo, where
   PAUL'S PLASTERING SERVICES is one wordmark at one weight in one colour across two lines, and
   the header was making "Paul's" the brand and "plastering services" a caption under it. The
   whole trading name is the brand. So the second line now carries the same colour and the same
   face, at a size close enough to read as one lockup rather than a name plus a descriptor.
   It is still slightly smaller and slightly letterspaced, which is what stops two serif lines
   of identical size fighting each other in a 52px header. Do not send it back to --muted. */
.nm small{
  display:block;font-family:var(--font-display);
  /* .875rem is Will's size and it holds from about 400px up. Below that it scales
     rather than pushing the wordmark under the call pill. */
  font-size:clamp(.6875rem,2.6vw,.875rem);
  letter-spacing:.06em;
  text-transform:uppercase;color:var(--text);font-weight:var(--w);margin-top:2px;
}
/* The call pill. The only saturated thing in the header, and the only thing on the page
   allowed to be firmer than 400. */
.callbtn{
  margin-left:auto;background:var(--brand-green);color:var(--on-green);text-decoration:none;
  font-weight:var(--w-call);padding:10px 14px;border-radius:var(--r);font-size:.9375rem;
  white-space:nowrap;display:flex;align-items:center;gap:7px;letter-spacing:-.005em;
  transition:background .2s;
}
.callbtn:hover{background:var(--brand-green-lit)}
.callbtn svg{width:14px;height:14px;flex:none}

/* MOVED BELOW THE BASE RULES ON 2026-09-20 BY THE DESIGN LANE, CONTENTS UNCHANGED.
   It sat ABOVE them, and a media query carries no extra specificity, so
   `.callbtn{font-size:.9375rem}` and `.callbtn svg{width:14px}` a few lines later simply won
   and the pill never went icon-only. MEASURED at 393px before the move: the pill rendered
   151px wide with all twelve digits, flex squeezed `.nm` to 178px, and because `.nm` is
   `white-space:nowrap` the words PLASTERING SERVICES painted out of their box and under the
   green. The `.callbar` line in here was unaffected because it carries !important, which is
   why only half of this block was ever doing anything.

   On a narrow phone the pill keeps the icon and loses the digits. The number is still
   in the hero button and in the sticky bar, so this costs a glance and not a call,
   and it is the only way to keep his full wordmark legible at 320px. 44px minimum
   tap target either way. */
@media(max-width:759px){
  /* Icon only. The digits here were the second of three numbers on one screen. */
  .callbtn{font-size:0;padding-inline:14px;min-width:44px;min-height:44px;justify-content:center}
  .callbtn svg{width:18px;height:18px}
}

/* THE THIRD NUMBER. Will's complaint was "still 3 numbers in view" and the answer here had
   become `.callbar{display:none!important}` on every phone. That fixes the complaint and
   costs the thing the whole site is for: past the hero there was then no tap-to-call on a
   phone except a 46px icon in the header. It also broke the project's own guard.
   MEASURED: `bash clients/check-render.sh paulsplastering` exited 1 with
   "1 button-shaped tap-to-call target(s) under 44px tall", because a display:none anchor is
   still an `a[href^=tel:]` and its height is 0.

   Both constraints hold at once, and this is how. THE DIGITS APPEAR EXACTLY ONCE ON SCREEN
   AT ANY SCROLL POSITION:
     at the top   header pill is ICON ONLY (rule above) + hero green button WITH digits
                  + call bar hidden, because the observer sees the hero button. One number.
     scrolled     header pill is ICON ONLY + call bar WITH digits. One number.
   The hiding is the existing data-under-hero IntersectionObserver in index.html, which only
   ever ADDS the attribute, so no script, a thrown error or no IntersectionObserver all leave
   the bar visible. Failing towards a reachable phone number is the right direction to fail.

   If Will still wants the bar gone outright, the honest fix is to drop the element from
   build-pages.py rather than hide it, because a hidden tel: link fails the render guard and a
   guard people switch off protects nothing. One line either way.

   The transition and the data-under-hero rule themselves live down with the rest of the call
   bar, near the bottom of this file. Nothing new is declared here. */

/* The nav is a thin strip UNDER the sticky bar on a phone and scrolls away with the page, so
   the sticky furniture stays at ~52px and does not eat a third of an 852px fold. On desktop
   the links move up into the bar. */
.navstrip{border-bottom:1px solid var(--rule-2)}
/* The padding lives on the LINKS, not on the row, so the tap target is the whole height of
   the strip instead of the 21px of text inside it. Measured at 393px on 2026-09-21: the
   targets were 21px tall, under the 24px WCAG 2.5.8 minimum and well under Apple's 44px.
   Moving the same 11px changes the strip's height by nothing, which matters, because the
   sticky furniture is deliberately held near 56px so it does not eat a third of an 852px
   fold. Bigger target, identical layout. */
.navstrip .in{display:flex;gap:var(--s3);padding-block:0;overflow-x:auto;scrollbar-width:none}
.navstrip .in::-webkit-scrollbar{display:none}
.navstrip a{
  font-size:.625rem;letter-spacing:.16em;text-transform:uppercase;font-weight:var(--w);
  color:var(--muted);text-decoration:none;white-space:nowrap;padding-block:13px;
  transition:color .2s;
}
.navstrip a:hover,.navstrip a[aria-current="page"]{color:var(--text)}
.bar nav{display:none}
@media(min-width:760px){
  .navstrip{display:none}
  .bar nav{display:flex;gap:clamp(18px,2.6vw,32px);margin-left:var(--s5);align-items:center}
  .bar nav a{
    font-size:.6875rem;letter-spacing:.22em;text-transform:uppercase;font-weight:var(--w);
    color:var(--muted);text-decoration:none;position:relative;padding-block:5px;
    transition:color .24s;
  }
  .bar nav a::after{
    content:"";position:absolute;left:0;right:0;bottom:0;height:1px;
    background:var(--text);transform:scaleX(0);transform-origin:left center;
    transition:transform .32s cubic-bezier(.16,1,.3,1);
  }
  .bar nav a:hover{color:var(--text)}
  .bar nav a:hover::after,.bar nav a[aria-current="page"]::after{transform:scaleX(1)}
  .bar nav a[aria-current="page"]{color:var(--text)}
}

/* ---------------------------------------------------------------------------------- hero */
.hero{padding-block:clamp(44px,9vw,92px) clamp(40px,6vw,72px)}
.hero-grid{display:grid;gap:var(--s5)}
@media(min-width:900px){
  .hero-grid{grid-template-columns:1.02fr .98fr;gap:clamp(40px,5vw,72px);align-items:center}
}
.hero h1{max-width:17ch;margin-top:var(--s4)}
.hero .sub{
  margin-top:var(--s4);color:var(--text-2);font-size:clamp(1.0625rem,2.2vw,1.1875rem);
  max-width:38ch;line-height:1.6;
}
/* The trust facts as one quiet line with a hairline over it, not a row of rounded pills.
   Restraint reads as confidence; badges read as marketing. These facts appear HERE and
   nowhere else on the page. */
.trust{
  margin-top:var(--s4);padding-top:var(--s4);border-top:1px solid var(--rule-2);
  color:var(--muted);font-size:.9375rem;max-width:46ch;line-height:1.6;
}

/* One shout, not three. The primary call is filled; the secondary is a text link. */
.acts{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s4);margin-top:var(--s5)}
.btn{
  text-decoration:none;font-weight:var(--w-call);padding:16px 26px;border-radius:var(--r);
  font-size:1.0625rem;letter-spacing:-.008em;
  display:inline-flex;align-items:center;justify-content:center;gap:10px;
  transition:background .2s,border-color .2s,color .2s;
}
.btn svg{width:17px;height:17px;flex:none}
/* No glow. A zero-offset coloured halo is decoration, and a lime field on a near-black ground
   needs no help being seen. */
.b-hot{background:var(--brand-green);color:var(--on-green)}
.b-hot:hover{background:var(--brand-green-lit)}
/* The secondary action is a hairline-underlined line of text, not a second button. Two
   buttons side by side is two shouts, and only one of them is the one that earns money. */
.b-line,.b-quiet{
  color:var(--text);text-decoration:none;font-weight:var(--w);
  font-size:.9375rem;letter-spacing:.02em;
  display:inline-flex;align-items:center;gap:9px;
  border-bottom:1px solid var(--rule);padding:0 0 6px;background:none;border-radius:0;
  transition:color .22s,border-color .22s;
}
.b-line:hover,.b-quiet:hover{border-bottom-color:var(--text)}
.b-line svg,.b-quiet svg{width:15px;height:15px;transition:transform .28s cubic-bezier(.16,1,.3,1)}
.b-line:hover svg,.b-quiet:hover svg{transform:translateX(4px)}
@media(max-width:480px){ .acts .b-hot{width:100%} }

/* The hero photograph. A plasterer's product is the finish, so it goes high on the page and
   big, and on a phone it bleeds to both edges of the screen: an edge-to-edge photograph reads
   as the thing itself, a rounded bordered card reads as a picture of it.
   The bleed is exactly --gut, the same token .shell pads by, so the image lands flush on the
   viewport edge and CANNOT overflow it. Do not replace that with a literal pixel value.
   Portrait, because 65 of his 69 photographs are 4:5 and designing for a landscape crop he
   does not have would mean cropping his best work badly. */
.hero-media{position:relative;margin:0}
/* The wrapper exists only so the phone hero can lift four elements onto the photograph.
   Everywhere else it must not exist at all, and display:contents is what makes a box
   disappear while its children stay where the grid expects them. */
.hero-over{display:contents}
.hero-media img{
  width:100%;height:100%;object-fit:cover;aspect-ratio:4/5;background:var(--ground-2);
}
/* The caption is a label UNDER the photograph, not white text in a gradient burned over the
   corner of it. Nothing is allowed to sit on top of his work. */
/* NOT a micro label, and that is the fix for a real collision. Set in the same 11px tracked
   uppercase grey as `.eyebrow`, it landed 26px above the hero's own label, and two identical
   tracked grey lines that close together read as one broken block rather than as a caption and
   a label. It is a CAPTION, so it is set like one: the display serif, small, his grey, no
   tracking, no capitals. Different job, different voice, no collision. */
.hero-media figcaption{
  font-family:var(--font-display);font-size:.875rem;letter-spacing:0;text-transform:none;
  color:var(--muted);margin-top:var(--s2);padding-inline:var(--gut);
}
@media(max-width:899px){
  /* ON A PHONE THE PHOTOGRAPH GOES FIRST, and this is a measurement, not a preference.
     With the text block first, `.hero-media` started at y=816 in an 852px fold: thirty-six
     pixels of photograph, which is nothing. For a plasterer the finish IS the product and
     the brief makes it a hard requirement that the work is visible without scrolling far.
     `order:-1` on the grid item does it in CSS, so a rebuild of the generated markup cannot
     undo it. Re-measured after: photograph 126 to 388, headline 480 to 618, and the green
     call button landing at 782, all three inside the fold.

     3/2 rather than the portrait 4:5 the source photographs are, and that is a real trade.
     Cover-cropping a 4:5 building shot to 3:2 loses sky and foreground, which those frames
     have plenty of, and 4:5 here is 491px tall on its own and pushes the call button off the
     screen. A photograph nobody sees is worth less than a cropped one they do.

     !important on the bleed is load bearing rather than lazy. The figure in the generated
     index.html carries an inline style="margin:0", which outranks any normal rule. An author
     !important beats an inline NORMAL declaration, so the bleed survives a rebuild instead of
     quietly reverting to a 354px boxed photograph nobody notices.
     MEASURED both ways: 20px from the left and 354 wide without it, 0 and 393 with it. */
  /* ================================================================ THE FULL-BLEED HERO
     Approved by Will on 2026-09-21 after seeing it beside the old one at 393px. The
     photograph fills the fold and the headline and the call button sit on it.

     THIS IS ARITHMETIC, NOT TASTE, AND THE NUMBER IS THE REASON IT CHANGED. The boxed
     version put the green call button's bottom edge at 802px. The device is 852px tall, but
     Safari's bars take roughly 140 of that, so the real viewport is nearer 710: his phone
     number was BELOW the fold and you had to scroll to ring him. This puts it at 648.
     Measured both ways in an iframe held at a true 393x852, not eyeballed.

     The notes above this block describe the layout it replaces. They are kept because the
     reasoning still holds for the boxed treatment and because the fold arithmetic is the
     same argument that produced this.

     78vh rather than 100vh on purpose. A full-height hero looks like the page has ended,
     and the strip of the next section showing under the fold is what tells a thumb to
     scroll. */
  .hero{padding:0 0 clamp(40px,6vw,72px)}
  /* flex, not block, so `order` still moves the photograph above the text column. The text
     column stays IN FLOW under the photograph and carries the sentence; only .hero-over is
     lifted onto the photograph. */
  .hero-grid{display:flex;flex-direction:column;position:relative;gap:0}
  .hero-media{order:-1;margin:0 !important;position:relative}
  .hero-media img{
    aspect-ratio:auto;height:78vh;width:100%;object-fit:cover;border-radius:0;
  }
  /* The caption named the town on the photograph. It cannot live on top of the headline, and
     "Silicone render, Tring" is not lost: Tring is in the town list, in the map and in the
     gallery captions. */
  .hero-media figcaption{display:none}
  /* THE SCRIM IS ONLY WHERE THE TEXT IS. It started at .72 alpha across the top, copied from
     a mock, and on the live page that turned the top of the photograph into what reads as
     black in a screenshot: 0.72 of #0A0B09 over a bright sky computes to about rgb(39,51,64).
     Caught by looking at the deployed page rather than at the CSS, and bisected by switching
     this one rule off, at which point the render came back perfectly.
     There is nothing over the top of the photograph to protect. The header and the nav strip
     are their own elements ABOVE it (0 to 109px), so the first third is left alone and his
     work is the brightest thing on the screen, which is the entire point of the change. The
     darkening starts where the headline does and closes on the page colour so the join to
     the next section is invisible. */
  .hero-media::after{
    content:"";position:absolute;inset:0;pointer-events:none;
    background:linear-gradient(to bottom,
      rgba(10,11,9,0) 0%, rgba(10,11,9,0) 26%, rgba(10,11,9,.38) 44%,
      rgba(10,11,9,.66) 62%, rgba(10,11,9,.88) 84%, var(--ground) 100%);
  }
  /* Pinned to the photograph, not to the grid: top 0 and the same 78vh, so the block sits
     on the bottom of the image however tall the sentence underneath turns out to be. An
     earlier version positioned it against .hero-grid with inset:auto 0 0 0, which pinned it
     to the bottom of the WHOLE hero, and the sentence rendered on top of the headline. */
  .hero-over{
    position:absolute;left:0;right:0;top:0;height:78vh;z-index:2;
    display:flex;flex-direction:column;justify-content:flex-end;
    padding:0 var(--gut) var(--s5);pointer-events:none;
  }
  .hero-over a{pointer-events:auto}
  .hero-grid > div{display:block;padding:0}
  .hero .eyebrow{
    order:0;margin:0 0 var(--s3);color:var(--text);letter-spacing:.18em;font-size:.72rem;
    /* Two shadows: a tight one for the edge against a bright sky, a wide soft one for the
       body. One of them alone left it washed out over the window in the render photograph. */
    text-shadow:0 1px 3px #000, 0 2px 16px rgba(0,0,0,.95);
  }
  .hero h1{
    order:1;margin:0 0 var(--s3);max-width:14ch;
    font-size:clamp(2.5rem,12vw,3.6rem);line-height:.98;text-wrap:balance;
    text-shadow:0 2px 22px rgba(0,0,0,.55);
  }
  /* The sentence moves BELOW the photograph rather than being deleted. The mock hid it to
     make room and Will approved that fold, but silently dropping a line of copy is a
     content decision, not a layout one. The fold he approved is unchanged; the sentence
     now opens the section under it. */
  .hero .sub{margin:0;padding:var(--s5) var(--gut) 0}
  .acts{margin:0}
  .hero-over > .acts{order:2}
  /* NOT opacity. It was .86 here for about ten minutes, and clients/check-render.sh went
     red: that guard treats any .rv element under 0.9 opacity as a reveal animation that
     failed to reveal, which is exactly the fault it exists to catch, and it cannot tell a
     deliberate fade from a broken one. Weakening the guard to fit the design would have been
     the wrong way round. Colour does the same job better anyway: opacity faded the two bold
     phrases along with everything else, and those are the facts worth keeping bright. */
  .hero-over > .trust{order:3;margin:var(--s3) 0 0;font-size:.82rem;color:var(--muted)}
  .hero-over > .trust b{color:var(--text)}
  /* One action on the photograph. "See all the work" is repeated at the foot of the gallery
     section immediately below, so nothing is lost and nothing competes with the call. */
  .hero .acts .b-quiet{display:none}
  .hero .acts .btn{width:100%;justify-content:center;font-size:1.06rem;padding-block:16px}
}

/* THE CALL COMES BEFORE THE PROOF, and again this is arithmetic rather than taste.
   In source order the hero reads label, headline, sentence, trust line, call. The trust line
   is 123px tall on a phone, and with the photograph moved to the top it pushed the green call
   button to y=956 in an 852px fold: the conversion event, off the screen. Reordering puts the
   button at 794 and the trust line under it.
   It is also the right hierarchy. A statement, then the thing to do about it, then the
   evidence for anyone still deciding. Nothing may compete with the call.
   Done with flex `order` rather than by moving the markup, because public/index.html is
   generated and a rebuild would put it back. */
.hero-grid > div{display:flex;flex-direction:column}
/* Matched through .hero-over, not through the div. `display:contents` removes the wrapper
   from the LAYOUT tree so its children become flex items of the column, but it does not
   remove it from the DOM, so `.hero-grid > div > .acts` stopped matching the moment the
   wrapper went in and every order value silently reverted to 0. The desktop hero then read
   headline, trust line, call button, sentence: the sentence after the call, and the proof
   before it, which is the exact hierarchy the note above says must never happen.
   Caught by measuring the desktop layout after changing the phone one. */
.hero-over > .acts{order:1}
.hero-over > .trust{order:2}
@media(min-width:900px){
  .hero-media figcaption{padding-inline:0}
}

/* --------------------------------------------------------------------------- proof strip */
/* TWO facts, not three. A follower count was the third and it is a vanity metric: 245 invites
   "only 245?" from somebody deciding whether to trust him with their ceiling.
   The numerals are in the display serif at 400 and in TEXT WHITE, not green. Two large green
   numbers were the loudest thing on the old page and they were spending the brand colour on
   something that is not the phone call. */
.strip{background:var(--ground-2);border-block:1px solid var(--rule-2)}
.strip .in{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--rule-2)}
.cell{background:var(--ground-2);padding:var(--s5) clamp(16px,3.4vw,34px)}
/* MEASURED at 393px: "100%" started at x=36 while every heading on the page starts at x=20,
   so the proof band was indented 16px further than the rest of the site and the left edge
   visibly stepped. The cells still need inner padding to hold off the divider between them;
   they do not need it on the outside, where the page gutter already provides it. */
.cell:first-child{padding-left:0}
.cell:last-child{padding-right:0}
.cell b{
  display:block;font-family:var(--font-display);font-weight:var(--w);
  font-size:clamp(2.5rem,9vw,3.75rem);letter-spacing:-.03em;color:var(--text);line-height:.95;
}
.cell span{
  display:block;color:var(--muted);font-size:.6875rem;letter-spacing:.22em;
  text-transform:uppercase;margin-top:var(--s3);line-height:1.6;
}

/* ------------------------------------------------------------------------------- sections */
section{padding-block:var(--sp)}
/* More space above a heading than below it, throughout. */
/* MEASURED, 1440px: with `max-width:30ch` on this container the reviews heading wrapped to
   five lines inside a 310px column and left the right half of the screen empty. `ch` is the
   width of a "0" AT THE ELEMENT'S OWN FONT SIZE, and .head inherits the body's 17px while the
   h2 inside it is 56px, so the box was sized for body copy and the display type had to live
   in it. The measure belongs on the thing being measured. */
.head{max-width:none;margin-bottom:var(--s6)}
.head h2{margin-top:var(--s4);max-width:20ch}
.head p{color:var(--text-2);margin-top:var(--s4);max-width:46ch;font-size:1.0625rem}
/* The green rule that used to draw itself in under every h2 is gone. Six identical accented
   underlines is not a signature, it is a template, and it was six more greens on the page. */

/* ---------------------------------------------------------------------------- the gallery */
/* 2 across on a phone, 4 on a desktop. Decided by screenshotting both with his real
   photographs: at 113px you cannot see the finish on a plastered wall, which is the only thing
   a gallery is for. Square corners and a 4px gutter: the grid reads as one laid surface rather
   than as a pile of rounded cards, which is also the closest thing on this page to what he
   actually does for a living.
   Each tile is a plain link to the full image. No lightbox: the browser's own image view gives
   pinch-zoom for free and a JS overlay fights a thumb. */
.work{
  display:grid;gap:4px;
  grid-template-columns:repeat(auto-fill,minmax(min(47.5%,15rem),1fr));
}
.work a{
  display:block;position:relative;overflow:hidden;background:var(--ground-2);aspect-ratio:1;
}
.work a img{width:100%;height:100%;object-fit:cover;transition:transform .8s cubic-bezier(.16,1,.3,1)}
.work a::after{
  content:"";position:absolute;inset:0;background:rgba(18,18,18,0);
  transition:background .3s;pointer-events:none;
}
@media(hover:hover){
  .work a:hover img{transform:scale(1.04)}
  .work a:hover::after{background:rgba(18,18,18,.14)}
}
.work-note{color:var(--muted);font-size:.9375rem;margin-top:var(--s5)}
.work-note a{color:var(--text);text-decoration:none;border-bottom:1px solid var(--rule);padding-bottom:2px}
.work-note a:hover{border-bottom-color:var(--text)}
.more{display:flex;justify-content:flex-start;margin-top:var(--s6)}

/* --------------------------------------------------------------------------- the services */
/* NOT six cards of icon plus heading plus text. That arrangement is the single most common
   thing on a trade website and it was the loudest template tell on this page, six identical
   boxes with six green icons in them.
   It is now an index: one hairline-separated row per job, the name in the display serif at the
   size a heading would be, the description in his grey opposite it. A list of what a man does,
   set like a contents page. */
.svc{display:grid;grid-template-columns:1fr;border-top:1px solid var(--rule-2)}
.card{
  display:grid;grid-template-columns:1fr;gap:var(--s2);
  padding-block:var(--s5);border-bottom:1px solid var(--rule-2);
  background:none;border-radius:0;
}
.card .ic{display:none}
.card h3{color:var(--text)}
.card p{color:var(--muted);font-size:1rem;line-height:1.6;max-width:56ch}
@media(min-width:760px){
  .card{grid-template-columns:minmax(0,11.5rem) minmax(0,1fr);gap:var(--s5);align-items:baseline}
  .card p{padding-top:.2em}
}
@media(min-width:1080px){
  .card{grid-template-columns:minmax(0,16rem) minmax(0,1fr)}
}

/* ----------------------------------------------------------------------------- the reviews */
/* The Facebook thumb badge. "Recommends" is Facebook's own word and stays as a LABEL; the
   green thumb was a fifth green on a screen that needs one. Hidden here rather than only
   deleted from the markup, because the generator puts it back: at 20:40 five of these
   rendered as ~345px grey watermarks, one per review, when a regeneration restored the <svg>
   into a stylesheet that no longer sized it. If build-pages.py drops the element the rule
   becomes inert, which is the harmless direction. */
.rec svg{display:none}
/* No fill, no border, no radius. A hairline over each one and the words set in the display
   serif, because a verbatim review from a real customer is the best writing on this page and
   it was previously in 15px grey inside a box. */
.revs{display:grid;grid-template-columns:1fr;gap:0}
@media(min-width:880px){
  .revs{grid-template-columns:repeat(2,1fr);column-gap:var(--s7);align-items:start}
}
.rev{
  border-top:1px solid var(--rule-2);padding-block:var(--s5);
  display:flex;flex-direction:column;background:none;border-radius:0;
}
/* "Recommends" is Facebook's own word for what these are, so it stays. It is a label now, not
   a green badge with a thumb on it. */
.rec{
  font-size:.6875rem;letter-spacing:.22em;text-transform:uppercase;color:var(--muted);
  font-weight:var(--w);
}
.rev p{
  margin-top:var(--s4);color:var(--text);font-family:var(--font-display);
  font-size:1.1875rem;line-height:1.55;letter-spacing:-.004em;max-width:54ch;flex:1;
}
.rev cite{
  margin-top:var(--s4);color:var(--text-2);font-size:.9375rem;font-style:normal;
  font-weight:var(--w);
}
.rev cite span{
  display:block;font-size:.6875rem;letter-spacing:.22em;text-transform:uppercase;
  color:var(--muted);margin-top:8px;
}

/* -------------------------------------------------------------------------------- the area */
.split{display:grid;grid-template-columns:1fr;gap:var(--s6)}
@media(min-width:880px){ .split{grid-template-columns:1.06fr .94fr;gap:var(--s7)} }
/* The counties as plain words separated by a middot, not three bordered pills. */
.areas{
  display:flex;flex-wrap:wrap;align-items:baseline;gap:0 var(--s3);
  padding-top:var(--s4);border-top:1px solid var(--rule-2);
}
.areas span{color:var(--text);font-size:1.0625rem;border:0;padding:0;background:none}
/* ::after, not ::before. With the separator leading the NEXT item, a wrap put a middot at
   the start of a line: "Buckinghamshire · Oxfordshire" then "· Hertfordshire". Trailing it
   instead leaves the middot at the end of the line it belongs to, which is how a printed
   list breaks. */
.areas span:not(:last-child)::after{content:"·";color:var(--muted);margin-left:var(--s3)}
/* The areas map is INLINED rather than an <img> so this file's tokens reach inside it: the
   fills, the boundaries and every label read custom properties set here. An <img> cannot see
   CSS variables. content/areas-map.md has the full note, including why the shapes carry no map
   licence to honour.
   THE MAP IS THE ONE PLACE A SECOND GREEN IS EARNED. Everywhere else on this page green means
   "ring Paul" and nothing else. Here it means "this is the ground he covers", which is
   information rather than decoration, and a coverage map wants one hue to say so. It is a
   low-opacity field; the call bar is a small saturated object. A field and an object are a
   hierarchy, not a clash. The one green that WAS decoration, a postcode tinted green purely to
   pick it out from the town name beside it, is his grey now: a code already looks like a code.

   EVERY OVERRIDE BELOW IS TWO CLASSES DEEP ON PURPOSE. The map's own <style> block sits in the
   body, so it comes after this file and wins any tie. `.mapwrap .apm-x` outranks `.apm-x`,
   which means this stylesheet governs the map's typography WITHOUT editing somebody else's
   artwork. The map lane and the design lane were both writing this page at once; the whole
   point of this shape is that neither has to touch the other's file. */
/* The OGL licence notice, as page text rather than inside the drawing.
   It used to be <text> in the SVG, which meant it scaled with the map: under the 11px
   legibility floor on a phone and the size of the town names on a desktop monitor. Will
   photographed his screen. A fixed size here is small print at every width, which is what
   small print is for, and the licence is satisfied by the notice accompanying the map
   rather than by living inside the artwork.
   .78rem, not smaller. This is a copyright notice and it has to stay readable. */
.maplicence{
  margin:var(--s3) 0 0;color:var(--muted);font-size:.78rem;line-height:1.55;max-width:64ch;
}
.mapwrap{
  background:none;border:0;border-radius:0;padding:0;margin-top:var(--s5);
  --map-ground:var(--ground);   /* the label halos are painted in this. It must be the page. */
  --map-town:var(--text);
  --map-code:var(--text-2);
  --map-base:var(--text);
  --map-attrib:var(--muted-2);
}
.mapwrap svg{width:100%;height:auto;display:block}
/* Nothing on this page is bold, and the map arrived carrying 800, 750, 700, 650 and 500.
   Every one of these labels is drawn with paint-order:stroke, so it keeps a halo of page
   colour behind the glyphs and stays legible at 400 without being thickened. Sizes are
   untouched: content/areas-map.md measured them so nothing lands under about 11 real pixels
   at 393px, and shrinking them is the one change that would cost a customer the map. */
.mapwrap .apm-c,
.mapwrap .apm-town,
.mapwrap .apm-aysub,
.mapwrap .apm-aycodes,
.mapwrap .apm-att{font-weight:var(--w)}
.mapwrap .apm-ay{font-weight:var(--w);font-family:var(--font-display)}
.mapwrap .apm-tc{fill:var(--muted);font-weight:var(--w)}

/* ON A WIDE SCREEN THE LABELS ARE HALVED. Read the paragraph above first: it says shrinking
   these is the one change that would cost a customer the map, and it is right about the
   PHONE. This is the other end of the same problem.
   The sizes are viewBox units, so they scale with the map. The map is about 324px wide on a
   phone and about 1040px inside the shell on a monitor, so a 16 unit label renders at 11px
   in one place and 35px in the other. Will photographed his screen on 2026-09-21: the
   postcodes were the size of headings and the map read as a poster.
   Halving every size keeps the hierarchy that content/areas-map.md worked out (town over
   code, base over town) and lands the codes at 12 to 18 real pixels across the range, which
   is what a map label is. SHRINKING IS THE SAFE DIRECTION: the label positions were solved
   at the larger size, so smaller text cannot collide with anything. Growing them could.
   Nothing here touches the phone, where the original sizes still apply. */
@media(min-width:760px){
  .mapwrap .apm-c{font-size:8px}
  .mapwrap .apm-town{font-size:10.5px}
  .mapwrap .apm-ay{font-size:13.5px}
  .mapwrap .apm-aysub{font-size:8.25px}
  .mapwrap .apm-aycodes{font-size:8px}
  .mapwrap .apm-c.apm-t2{font-size:7.3px}
  .mapwrap .apm-town.apm-t2{font-size:8.25px}
}
.fb{
  background:none;border:0;border-top:1px solid var(--rule-2);border-radius:0;
  padding:var(--s5) 0 0;
}
.fb h3{color:var(--text)}
.fb p{color:var(--muted);font-size:1rem;margin-top:var(--s3);max-width:44ch;line-height:1.6}
.fb .b-line{margin-top:var(--s5)}

/* -------------------------------------------------------------------------------- the band */
/* Left aligned, not centred. A centred CTA band with a centred button under a centred
   paragraph is the arrangement every template ships; the whole page reads off one left edge
   and the close should not be the one place that breaks it. */
.band{background:var(--ground-2);border-block:1px solid var(--rule-2)}
.band .in{padding-block:var(--sp)}
.band h2{max-width:22ch}
.band p{color:var(--text-2);margin-top:var(--s4);max-width:44ch;font-size:1.0625rem}
.band .btn{margin-top:var(--s5)}

.band .eyebrow a{color:var(--text-2);text-decoration:none;border-bottom:1px solid var(--rule);padding-bottom:2px;letter-spacing:.14em}
.band .eyebrow a:hover{color:var(--text);border-bottom-color:var(--text)}

/* The contact email carries an inline style="color:var(--brand-green)" from the generator,
   which put a second green element on the closing screen. An author !important beats an
   inline NORMAL declaration, so this holds through a rebuild. Green on this page means one
   thing: ring Paul. */
.band a[href^="mailto:"]{color:var(--text-2)!important;text-decoration:none;
  border-bottom:1px solid var(--rule);padding-bottom:2px}
.band a[href^="mailto:"]:hover{color:var(--text)!important;border-bottom-color:var(--text)}

footer{border-top:1px solid var(--rule-2);padding-block:var(--s6);color:var(--muted-2);font-size:.8125rem}
/* The three accounts, in the footer. They are also in sameAs, which is the half that
   matters right now: his Google Business Profile carries no website, so the structured data
   is what tells Google the site and the accounts are one business. */
.social{display:flex;gap:var(--s4);flex-wrap:wrap}
.social a{color:var(--muted)}
.social a:hover{color:var(--text)}
.fbar{display:flex;flex-wrap:wrap;gap:var(--s2) var(--s5);align-items:baseline;justify-content:space-between}
footer a{text-decoration:none;border-bottom:1px solid var(--rule-2);padding-bottom:1px}
footer a:hover{color:var(--text);border-bottom-color:var(--rule)}

/* --------------------------------------------------------------------- fixed call bar */
/* A phone number is the conversion event for this trade, so on a phone one is always on
   screen. Hidden on desktop, where the sticky header already carries it.
   This is the one place where the reference loses on purpose. 12.studio hides its contact
   behind a menu and a scroll reveal, and it can: it sells to venture funds. Paul sells to
   somebody with a cracked ceiling and a phone already in their hand, so the number stays
   large, green and reachable from every scroll position on the page. */
.callbar{
  position:fixed;inset:auto 0 0 0;z-index:50;
  padding:9px var(--gut) calc(9px + env(safe-area-inset-bottom,0px));
  background:rgba(18,18,18,.94);
  backdrop-filter:blur(12px);border-top:1px solid var(--rule);
}
.callbar a{width:100%;padding-block:15px;font-size:1.0625rem}
@media(min-width:760px){ .callbar{display:none} }
/* Hidden ONLY while the hero call button is on screen. Set by script; absent by
   default, so every failure mode leaves the bar showing. */
.callbar{transition:transform .28s ease, opacity .28s ease}
.callbar[data-under-hero="1"]{transform:translateY(115%);opacity:0;pointer-events:none}
@media(prefers-reduced-motion:reduce){ .callbar{transition:none} }

/* ------------------------------------------------------------------------------- reveal */
/* The hidden state applies ONLY once script has confirmed it can un-hide things, and a timer
   strips it after 1.6s regardless. A client's website must never render blank because of a
   scroll animation, and a gallery that is blank because an observer did not fire is the worst
   possible outcome on a site whose whole job is showing the work.
   The state class is `rv-on`, NOT `in`: `in` is a layout class in this project and the
   collision once grew the page 277px the moment anybody scrolled.

   ONE authored moment, not a fade on everything. Type rises a few pixels and settles.
   PHOTOGRAPHS ARE UNCOVERED LEFT TO RIGHT, which is the pass of a trowel and is the only
   gesture on this page that is about what Paul actually does. Opacity still travels 0 to 1
   underneath the wipe, deliberately: clients/check-render.sh asserts that no .rv element is
   left under 0.9 opacity after a visitor has scrolled the page, and a wipe alone would sail
   past that assertion while leaving a tile invisible. */
.js-rv .rv{
  opacity:0;transform:translateY(10px);
  transition:opacity .7s cubic-bezier(.16,1,.3,1),transform .7s cubic-bezier(.16,1,.3,1);
}
.js-rv .rv.rv-on{opacity:1;transform:none}
.js-rv .work a.rv,.js-rv .hero-media.rv{
  transform:none;clip-path:inset(0 100% 0 0);
  transition:opacity .5s ease-out,clip-path 1s cubic-bezier(.16,1,.3,1);
}
.js-rv .work a.rv.rv-on,.js-rv .hero-media.rv.rv-on{clip-path:inset(0 0 0 0)}

@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  .js-rv .rv{opacity:1!important;transform:none!important;clip-path:none!important;transition:none}
  .work a img,.b-line svg,.b-quiet svg,.bar nav a::after{transition:none}
  .work a:hover img{transform:none}
}
