/* ============================================================
   SELF-HOSTED TYPE (docs/lessons.md)
   The Google Fonts <link> is render-blocking: two third-party round
   trips before first paint, which is brutal on Slow 4G. These blocks
   are the css2 response served to a Chrome UA, with the gstatic urls
   rewritten to local files. The unicode-range gates are kept intact,
   so latin-ext only downloads when a glyph actually needs it, and the
   latin file of each family is <link rel="preload"> on every page.

   Subsets shipped: latin + latin-ext only. The site is en-US and the
   copy is Missouri place names, so the cyrillic / greek / vietnamese
   cuts Google also serves would never be requested; they are omitted
   from the repo rather than shipped as dead weight.

   Bricolage Grotesque and Source Sans 3 are VARIABLE: one file per
   subset instantiates every weight below (700/800 and 400/600/700
   respectively), which is why those blocks share a src. IBM Plex Mono
   is static, so 500 and 600 are separate files.
   Weights here must stay in lockstep with the ones the CSS below
   actually uses — a weight the family does not ship renders as faux
   bold, and a weight the CSS never asks for is a wasted download.
============================================================ */
/* Archivo :: latin. One variable file carries the whole system. The width axis
   (62 to 125%) gives the expanded display cut, the weight axis (400 to 800)
   gives everything from body copy to the hero, so this single face replaces
   both Bricolage Grotesque and Source Sans 3. Two families down to one, and
   one fewer preload on the critical path.

   Expanded is not decoration here: brick courses run horizontal, and a wide
   grotesque at 125% sits on the page the way a course sits in a wall. It is
   also the reason the face changed at all, Bricolage was shared with
   goodleed.com and an agency should not hand its own face to a client. */
@font-face{
  font-family:'Archivo';font-style:normal;font-weight:400 800;font-stretch:62% 125%;font-display:swap;
  src:url('/assets/fonts/archivo-var-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Archivo :: latin-ext */
@font-face{
  font-family:'Archivo';font-style:normal;font-weight:400 800;font-stretch:62% 125%;font-display:swap;
  src:url('/assets/fonts/archivo-var-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* Source Sans 3 is gone. Archivo above covers body weights 400 to 700 at normal
   width, so the body face was a second family doing a job the display family
   already did, and doing it in a neutral corporate voice that gave the
   fired-clinker system nothing. */
/* IBM Plex Mono 500 :: latin */
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
  src:url('/assets/fonts/ibm-plex-mono-500-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* IBM Plex Mono 500 :: latin-ext */
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;
  src:url('/assets/fonts/ibm-plex-mono-500-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* IBM Plex Mono 600 :: latin */
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
  src:url('/assets/fonts/ibm-plex-mono-600-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* IBM Plex Mono 600 :: latin-ext */
@font-face{
  font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;
  src:url('/assets/fonts/ibm-plex-mono-600-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ============================================================
   APEX CONTRACTING LLC — "STRUCK JOINTS"
   Direction locked Aug 7 2026. See DESIGN-BRIEF.md.
   (This header carried the predecessor trading name until Aug 15 2026.
   It is on the dead-fact list in DEMO-NOTES.md and does not come back.)

   The signature is the mortar line itself: cream stops being the
   background and becomes the LINE. Dark fired-clinker ground, fresh
   cream pointing struck through it.

   TOKENS FIRST. Everything below derives from this block.
============================================================ */
:root{
  /* --- palette (locked) --- */
  --ink:#191412;          /* page ground, warm near-black (fired clinker) */
  --wall:#231C17;         /* raised wall/card surfaces on dark */
  --mortar:#EFE8D9;       /* the line + display text on dark; light-section bg */
  --limewash:#E7E0D2;     /* light body-section bg */
  --joint:#CBBFA9;        /* hairline joints on light sections */
  --sand:#B0A492;         /* secondary text on dark */
  --soot:#3A322C;         /* secondary text on light, borders on light */
  --oxide:#C6472E;        /* brick oxide accent: CTAs, key highlights ONLY */
  --oxide-deep:#8E2F1C;   /* hover/pressed, small accents */

  /* --- derived, contrast-checked (ratios measured, not guessed) ---
     oxide #C6472E is 3.78:1 on ink and 3.68:1 on limewash: it FAILS 4.5:1
     and is therefore never used for text of any size. It is a graphic
     colour only (bead, tick markers, hover fills). Small accent text uses
     these two instead. */
  --accent-text:#8E2F1C;      /* = oxide-deep. 6.21:1 on limewash, 6.69:1 on mortar */
  --accent-on-dark:#CBBFA9;   /* = joint. 10.05:1 on ink. accent text on dark is bone, not red */
  --line-strong:#6E6155;      /* form control borders: 4.6:1 on mortar, well over the 3:1 floor */
  --rule-dark:rgba(239,232,217,.24);  /* mortar joint drawn on the dark ground */
  --wall-shadow:#14100E;      /* the recess a joint sits in */

  /* --- contextual tokens. Light is the default; the dark-section block
         further down re-points these, so components never hard-code a colour. --- */
  --bg:var(--limewash);
  --fg:var(--ink);          /* 13.90:1 on limewash */
  --fg-2:var(--soot);       /* 9.56:1 on limewash, 10.29:1 on mortar */
  --slab:var(--mortar);     /* card / panel surface */
  --rule:var(--joint);      /* struck joint lines */
  --accent-fg:var(--accent-text);
  --focus:var(--oxide-deep);

  /* The button label colour NEVER changes on hover. --btn-fill is the deeper
     layer that sweeps in underneath it, chosen so the label keeps a verified
     ratio against BOTH the resting fill and the swept fill, which means every
     frame of the sweep is legible, not just the two end states. */
  --btn-bg:var(--oxide-deep);  --btn-bd:var(--oxide-deep);  --btn-fg:var(--mortar); /* 6.69:1 */
  --btn-fill:var(--ink);       --btn-bd-h:var(--ink);                               /* 14.97:1 */

  /* --- type --- */
  /* One family, two widths. Display rules add font-stretch:125% for the expanded
     cut; body inherits normal width, so nothing extra is needed there. */
  --font-display:'Archivo',system-ui,sans-serif;
  --font-body:'Archivo',system-ui,sans-serif;
  /* 110%, not Archivo's 125% maximum. Measured: at 115% and above the homepage H1
     wraps to seven lines instead of six and pushes the hook and the CTA off the
     fold. The H1 is long on purpose (seo-spec P0-6 keeps the service terms and
     both geo terms in it), so the type dosage gives way, not the copy. */
  --display-stretch:110%;
  --font-mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;

  --h1:clamp(2.3rem,5.6vw,4.15rem);
  --h2:clamp(1.65rem,3.6vw,2.6rem);
  --h3:1.18rem;
  --body:1.05rem;

  /* --- space / shape --- */
  --maxw:1160px;
  --gutter:clamp(18px,4vw,40px);
  --sec:clamp(60px,9vw,116px);
  --rad:2px;              /* masonry is struck square. no soft corners. */
  --ease:cubic-bezier(.22,.61,.36,1);
  --ease-fill:cubic-bezier(.645,.045,.355,1);  /* the grow-fill / underline curve */
}

*{margin:0;padding:0;box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:88px}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
/* overflow-x:clip, never hidden. hidden on body breaks position:sticky */
html,body{overflow-x:clip}
body{
  font-family:var(--font-body);
  font-size:var(--body);
  line-height:1.62;
  color:var(--fg);
  background:var(--bg);
  -webkit-font-smoothing:antialiased;
}
img{max-width:100%;display:block}
a{color:inherit}
ul{list-style:none}
/* Drops the ~300ms double-tap-to-zoom wait before a tap becomes a click, so a
   phone tap on a call button fires immediately. Deliberately not on inputs:
   .ba__range sets its own touch-action:pan-y so a vertical swipe over the
   comparator still scrolls the page. */
a,button,summary{touch-action:manipulation}
::selection{background:var(--oxide);color:var(--mortar)}

h1,h2,h3{font-family:var(--font-display);font-stretch:var(--display-stretch);line-height:1.06;letter-spacing:-.025em}
h1{font-size:var(--h1);font-weight:800;letter-spacing:-.032em}
h2{font-size:var(--h2);font-weight:800}
h3{font-size:var(--h3);font-weight:700;letter-spacing:-.01em;line-height:1.2}
h1,h2{text-wrap:balance}
h1{overflow-wrap:break-word}
a[href^="tel:"]{white-space:nowrap}

.wrap{max-width:var(--maxw);margin-inline:auto;padding-inline:var(--gutter)}
.sec{padding-block:var(--sec)}
/* Two light tones alternate, so a card is always the OTHER brick to its band. */
.sec--tint{background:var(--mortar);--bg:var(--mortar);--slab:var(--limewash)}

/* Spec labels. The trade voice: masons work to spec, so every eyebrow,
   phone number, city and counter is set in mono. */
.kicker,.h1-kicker{
  font-family:var(--font-mono);font-size:.76rem;font-weight:600;
  letter-spacing:.13em;text-transform:uppercase;line-height:1.5;
  color:var(--accent-fg);margin-bottom:1rem;display:block;
}
.kicker::before,.h1-kicker::before{
  content:"";display:inline-block;vertical-align:.22em;
  width:2.1em;height:2px;margin-right:.85em;background:var(--oxide);
}
.lede{font-size:1.16rem;color:var(--fg-2);max-width:56ch}

/* ---------- Buttons: two tiers ----------
   TIER 1 (.btn) — grow-fill. A background-IMAGE of --btn-fill sweeps
   background-size 0%->100% from the left while the background-COLOUR and the
   label colour both hold still. Because nothing cross-fades, the label sits
   on a contrast-verified ground for the whole 220ms, not only at the ends.
   TIER 2 (.btn--ghost, .card__go, .tlink) — quiet underline that scales in
   from the left in the section's accent-on-that-ground colour.
   Every hover rule below also answers to :focus-visible. */
.btn{
  display:inline-block;color:var(--btn-fg);
  background-color:var(--btn-bg);
  background-image:linear-gradient(var(--btn-fill),var(--btn-fill));
  background-repeat:no-repeat;background-position:left center;background-size:0% 100%;
  font-family:var(--font-body);font-weight:700;text-decoration:none;
  padding:.9em 1.55em;border-radius:var(--rad);border:2px solid var(--btn-bd);
  transition:background-size .22s var(--ease-fill),border-color .22s var(--ease-fill),transform .18s ease;
}
.btn:hover,.btn:focus-visible{--btn-bd:var(--btn-bd-h);background-size:100% 100%;transform:translateY(-2px)}

/* Ghost = secondary tier: no fill at all, just the underline and an oxide edge.
   Label stays --fg on --bg (13.90:1 light, 14.97:1 dark) in every state. */
.btn--ghost{
  --btn-bg:transparent;--btn-bd:var(--fg);--btn-fg:var(--fg);--btn-bd-h:var(--oxide);
  background-image:none;position:relative;
}
.btn--ghost::after{
  content:"";position:absolute;left:1.55em;right:1.55em;bottom:.62em;height:2px;
  background:var(--accent-fg);transform:scaleX(0);transform-origin:0 50%;
  transition:transform .25s var(--ease-fill);
}
.btn--ghost:hover::after,.btn--ghost:focus-visible::after{transform:scaleX(1)}

/* Text links. .card__go is driven by its parent card (the card IS the link, so
   the row needs no resting underline of its own).
   .tlink is the standalone hook for an inline link inside body copy: it keeps a
   permanent 1px rule and the accent colour, because a link sitting in a
   sentence has to be identifiable without hovering and without relying on
   colour alone. The ::after bar is the hover flourish on top of that, not the
   only thing marking it as a link. */
.card__go,.tlink{position:relative;display:inline-block;text-decoration:none}
.tlink{color:var(--accent-fg);border-bottom:1px solid currentColor}
.card__go::after,.tlink::after{
  content:"";position:absolute;left:0;right:0;bottom:-3px;height:2px;
  background:var(--accent-fg);transform:scaleX(0);transform-origin:0 50%;
  transition:transform .25s var(--ease-fill);
}
/* on .tlink the bar lands ON the resting rule, so the underline thickens
   rather than a second line appearing below it */
.tlink::after{bottom:-1px}
.card:hover .card__go::after,.card:focus-visible .card__go::after,
.tlink:hover::after,.tlink:focus-visible::after{transform:scaleX(1)}

.cta-row{display:flex;gap:14px;flex-wrap:wrap;margin-top:1.7rem}
.cta-note{font-size:.92rem;color:var(--fg-2);margin-top:.9rem}
@media (prefers-reduced-motion:reduce){
  .btn{transition:none}
  .btn:hover,.btn:focus-visible{transform:none}
  .btn--ghost::after,.card__go::after,.tlink::after{transition:none}
}

/* ---------- Dark ground ----------
   Every dark surface re-points the contextual tokens once. Components
   below never need a dark-mode variant of their own. */
.site-head,
.hero--wall,
.proofbar,
.close-cta,
.callbar,
.site-foot{
  --bg:var(--ink);
  --fg:var(--mortar);        /* 14.97:1 */
  --fg-2:var(--sand);        /* 7.45:1 */
  --slab:var(--wall);
  --rule:var(--rule-dark);
  --accent-fg:var(--accent-on-dark);  /* 10.05:1 */
  --focus:var(--mortar);
  --btn-bg:var(--mortar);  --btn-bd:var(--mortar);  --btn-fg:var(--ink);        /* 14.97:1 */
  /* The dark-ground button deepens mortar -> joint instead of flipping to
     oxide-deep: the ink label then reads 14.97:1 on the resting fill and
     10.05:1 on the swept fill, so there is no low-contrast moment mid-sweep
     (ink on oxide-deep is only 2.24:1). Same tokens, better floor. */
  --btn-fill:var(--joint);      --btn-bd-h:var(--joint);                            /* 10.05:1 */
  background:var(--bg);
  color:var(--fg);
}

/* ---------- Struck-joint section dividers ----------
   Every band boundary is a bed joint with one head joint dropping off it.
   Drawn on scroll, one shot. Base state is FULLY DRAWN so no-JS and
   reduced-motion users see finished linework. */
.sec,.proofbar,.site-foot{position:relative}
.sec::before,.proofbar::before,.site-foot::before{
  content:"";position:absolute;left:0;right:0;top:0;height:2px;
  background:var(--rule);transform-origin:0 50%;
}
.sec::after,.proofbar::after,.site-foot::after{
  content:"";position:absolute;top:0;left:clamp(26px,11vw,168px);
  width:2px;height:26px;background:var(--rule);transform-origin:50% 0;
}
.js .sec::before,.js .proofbar::before,.js .site-foot::before{
  transform:scaleX(0);transition:transform .9s var(--ease);
}
.js .sec::after,.js .proofbar::after,.js .site-foot::after{
  transform:scaleY(0);transition:transform .4s var(--ease) .45s;
}
.js .sec.struck::before,.js .proofbar.struck::before,.js .site-foot.struck::before,
.js .sec.struck::after,.js .proofbar.struck::after,.js .site-foot.struck::after{transform:none}
@media (prefers-reduced-motion:reduce){
  .js .sec::before,.js .proofbar::before,.js .site-foot::before,
  .js .sec::after,.js .proofbar::after,.js .site-foot::after{transform:none;transition:none}
}

/* ---------- Header ---------- */
.site-head{position:sticky;top:0;z-index:50;border-bottom:2px solid var(--rule)}
.site-head__row{display:flex;align-items:center;gap:26px;padding-block:15px}
.brand{
  white-space:nowrap;font-family:var(--font-display);font-stretch:var(--display-stretch);font-weight:800;font-size:1.16rem;
  text-decoration:none;letter-spacing:-.025em;color:var(--fg);
}
.nav{margin-left:auto}
.nav__links{display:flex;gap:24px}
.nav__links a{
  text-decoration:none;font-weight:600;font-size:.95rem;color:var(--fg-2);
  padding-block:4px;border-bottom:2px solid transparent;transition:color .15s ease,border-color .15s ease;
}
.nav__links a:hover{color:var(--fg);border-bottom-color:var(--oxide)}
.nav__burger{display:none;background:none;border:0;cursor:pointer;padding:12px}
.nav__burger span{display:block;width:22px;height:2px;background:var(--fg);margin:5px 0}
.btn--head{padding:.55em 1em;font-family:var(--font-mono);font-weight:600;font-size:.88rem;letter-spacing:-.01em}
/* Burger breakpoint stays at 960px: brand (~250px) + 5 nav links (~380px) +
   gaps (~52px) + phone CTA (~165px) + gutters (~80px) ≈ 927px of real width. */
@media (max-width:960px){
  .nav__burger{display:block}
  .nav__links{
    display:none;position:absolute;top:100%;left:0;right:0;background:var(--ink);
    border-bottom:2px solid var(--rule);flex-direction:column;gap:0;padding:6px 0;
  }
  .nav__links.open{display:flex}
  .nav__links a{display:block;padding:13px var(--gutter);border-bottom:0;border-left:2px solid transparent}
  .nav__links a:hover{border-left-color:var(--oxide)}
}
@media (max-width:560px){.btn--head{display:none}}

/* ============================================================
   HERO — scroll-scrub before/after (Best Floors pattern, no video).
   Film hero: cinematic loop over its own poster frame (Turner grammar).
   Scroll progress drives --p (set by site.js): a soft diagonal mask
   sweeps the restored-wall photo across the cracked-wall photo, so the
   visitor repairs the wall by scrolling. No JS -> after image unmasked
   (finished state). Reduced motion -> track collapses, finished state.
   The SVG struck-joints wall paints instantly under the photos.
============================================================ */
.hero{position:relative;padding-block:clamp(52px,7vw,88px)}
.hero--wall{
  isolation:isolate;
  display:grid;align-items:center;
  min-height:clamp(560px,74vh,860px);
  padding-block:clamp(72px,9vw,124px);
  --scrim:70%;
}
@media (min-width:1180px){.hero--wall{--scrim:50%}}

/* Film hero: 100vh(ish) single viewport, video fades in over its own
   poster frame (identical pixels), SVG wall paints instantly underneath. */
.hero--film{min-height:clamp(600px,92vh,980px)}
.hero__video{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  object-position:68% center;
  opacity:0;transition:opacity .9s ease;z-index:1;
}
.hero__video.is-ready{opacity:1}
.hero__poster-img{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  object-position:68% center;
}
/* Built by site.js only once the clip is genuinely playing (WCAG 2.2.2). */
.hero__pause{
  position:absolute;z-index:4;right:var(--gutter);bottom:18px;
  font-family:var(--font-mono);font-weight:500;font-size:.7rem;letter-spacing:.06em;
  text-transform:uppercase;cursor:pointer;
  background:var(--ink);color:var(--mortar);border:2px solid var(--mortar);
  border-radius:var(--rad);padding:.5em .9em;
}
.hero__pause:hover{background:var(--mortar);color:var(--ink)}
/* Turner-style scroll cue: mono whisper + a joint-line dropping from it */
.hero__cue{
  position:absolute;z-index:4;left:50%;transform:translateX(-50%);bottom:0;
  display:flex;flex-direction:column;align-items:center;gap:10px;
  font-family:var(--font-mono);font-weight:500;font-size:.66rem;
  letter-spacing:.14em;text-transform:uppercase;color:var(--sand);
}
.hero__cue::after{
  content:"";width:2px;height:56px;
  background:linear-gradient(180deg,var(--joint),transparent);
  animation:cueDrop 2.2s var(--ease) infinite;
}
@keyframes cueDrop{
  0%{transform:scaleY(0);transform-origin:top}
  45%{transform:scaleY(1);transform-origin:top}
  55%{transform:scaleY(1);transform-origin:bottom}
  100%{transform:scaleY(0);transform-origin:bottom}
}
@media (prefers-reduced-motion:reduce){
  /* Reduced motion never calls play(), so 'playing' never fires, so .is-ready
     is never added and the video would sit at opacity 0 forever — a black-ish
     hole where the hero image should be. Show it: a video that is not playing
     paints its poster, which is the loop's own first frame, so this is the
     designed still with no motion at all. This block sits AFTER the base
     .hero__video rule, which is what lets an equal-specificity override win —
     a media query adds no specificity of its own. */
  .hero__video{opacity:1;transition:none}
  .hero__cue::after{animation:none;transform:none}
}

/* Set by site.js when the hero leaves the viewport: an infinite animation on
   an off-screen element is pure waste, so the cue's drop-line pauses. */
.hero--parked .hero__cue::after{animation-play-state:paused}

.hero__media{position:absolute;inset:0;overflow:hidden;background:var(--wall-shadow);z-index:0}
.wall{position:absolute;inset:0;width:100%;height:100%;display:block}
.wall--narrow{display:none}
.joint{fill:none;stroke:var(--mortar);stroke-width:4;opacity:.4}
/* the freshly pointed patch: this is what Apex sells, so it gets the weight */
.joint--fresh{opacity:.95;stroke-width:6}

/* Guarantees cream text keeps 4.5:1 no matter what the photos show:
   the text zone sits on FLAT ink, and the wall reads in the open
   right-hand two thirds. */
.hero__scrim{
  position:absolute;inset:0;z-index:2;
  background:linear-gradient(90deg,
    var(--ink) 0,
    var(--ink) var(--scrim),
    rgba(25,20,18,.6) calc(var(--scrim) + 9%),
    rgba(25,20,18,.18) calc(var(--scrim) + 20%),
    rgba(25,20,18,0) calc(var(--scrim) + 31%));
}
.hero > .wrap{position:relative;z-index:3;width:100%}
.hero__copy{max-width:33rem}
.hero__copy .lede{max-width:42ch;margin-top:1.1rem}

/* the struck mortar line under the key word: the one hit of oxide in the hero */
.key{position:relative;display:inline-block}
.bead{
  position:absolute;left:-.06em;width:calc(100% + .12em);bottom:-.06em;height:.32em;
  overflow:visible;pointer-events:none;
}
.bead path{
  fill:none;stroke:var(--oxide);stroke-width:.115em;stroke-linecap:round;
  vector-effect:non-scaling-stroke;
}

/* Hero entrance: transform-only in spirit — opacity never starts below .2,
   the base state IS the finished state, and fill-mode both means a stalled
   clock leaves readable text rather than a blank box. */
@keyframes heroIn{from{opacity:.2;transform:translateY(16px)}to{opacity:1;transform:none}}
.hero--wall .hero__copy > *{animation:heroIn .6s var(--ease) both}
.hero--wall .hero__copy > :nth-child(1){animation-delay:.02s}
.hero--wall .hero__copy > :nth-child(2){animation-delay:.1s}
.hero--wall .hero__copy > :nth-child(3){animation-delay:.18s}
.hero--wall .hero__copy > :nth-child(4){animation-delay:.26s}
.hero--wall .hero__copy > :nth-child(5){animation-delay:.34s}
@media (prefers-reduced-motion:reduce){
  .hero--wall .hero__copy > *{animation:none}
}

/* Mobile: the film shows as a TOP band, immediately visible under the header,
   with the copy on flat ink below it. The old bottom-band scrim put the only
   visible media below the fold of a phone viewport, so the first screen read
   as a plain dark text page — like the hero media had failed to load. Media
   first, then words: the photo is the proof the site is alive. */
@media (max-width:860px){
  .hero--wall{
    /* svh, not vh. On iOS Safari vh is the LARGE viewport (toolbar hidden), so
       38vh sized the band against a taller box than the phone actually shows and
       the copy got pushed under the fold. svh is the small viewport, which is
       what the visitor sees at rest. Verified on an iPhone 17 simulator, which is
       the only place this was visible: it looked fine at the same pixel width in
       a desktop browser. 38vh also cost too much outright, band plus header was
       51% of the screen before a single word. */
    --band:clamp(190px,30svh,280px);
    min-height:0;
    padding-top:calc(var(--band) + clamp(28px,6vw,48px));
    padding-bottom:clamp(48px,9vw,72px);
  }
  .hero__video,.hero__poster-img{object-position:62% center}
  .hero__cue{display:none}
  .hero--wall .hero__scrim{
    background:linear-gradient(180deg,
      rgba(25,20,18,.16) 0,
      rgba(25,20,18,.16) calc(var(--band) - 96px),
      rgba(25,20,18,.62) calc(var(--band) - 34px),
      var(--ink) var(--band),
      var(--ink) 100%);
  }
  .wall--wide{display:none}
  .wall--narrow{display:block}
  .wall--narrow .joint{stroke-width:6}
  .wall--narrow .joint--fresh{stroke-width:9}
  .hero__copy{max-width:none}
  .hero__copy .lede{max-width:54ch}

  /* Expanded display type is a wide-screen luxury. At 402px the extra width buys
     nothing and costs a line on every heading, which on the homepage H1 was a
     fifth line it had no room for. Normal width on phones. */
  :root{--display-stretch:100%}

  /* Declutter the copy stack. The kicker repeats the brand that sits in the
     header directly above it — on a phone that is the same words twice within
     200px, so it hides here (still in the page for desktop and crawlers; the
     H1 carries the geo terms). The hook lede drops from display-weight to a
     strong subline so the block has ONE headline, and the body lede tightens
     a step. Pure presentation: not a word of copy changes. */
  .hero--wall .h1-kicker{display:none}
  .hero--wall .hero__copy .lede--hook{
    font-family:var(--font-body);font-weight:700;
    font-size:1.1rem;line-height:1.5;margin-bottom:.4rem;
  }
  .hero--wall .hero__copy .lede{font-size:1.02rem}
  .hero--wall .hero__copy > h1{margin-bottom:1rem}
  /* The pause control is a VIDEO control: on mobile it sits on the media band
     (bottom-right corner of the film), not stacked after the text like a CTA. */
  .hero__pause{
    top:calc(var(--band) - 44px);bottom:auto;right:12px;
    font-size:.6rem;padding:.45em .75em;border-width:1px;
  }
}

/* ============================================================
   HERO SCROLL-SCRUB — DORMANT UNTIL THE FRAMES EXIST
   ------------------------------------------------------------
   The canvas ships in the markup on every load but paints nothing:
   `.hero__canvas{display:none}` is the only unscoped rule in this block, and
   everything else is scoped to `.hero--scrub-live`, a class site.js adds ONLY
   after /assets/hero/frames/manifest.json resolves and frame 1 decodes. With
   no frames on disk this whole block is inert and the hero renders byte-for-
   byte identical to the static-poster build — verified by a DOM fingerprint
   (every rect + computed style in the hero subtree) at 1280x800 and 375x812.

   This block sits AFTER the base hero rules and after the max-width:860px
   block on purpose: media queries add no specificity, so a scrub rule written
   above them would silently lose to an identical-specificity base rule below
   (docs/lessons.md).

   WHAT SCRUB MODE DOES TO THE LAYOUT
   The section becomes a tall track; `.hero__sticky2` (injected by site.js
   around the media, scrim and cue) is the pinned viewport the canvas fills.
   The COPY IS DELIBERATELY NOT PINNED. Measured: `.hero__copy` is 816px tall
   at 1280px wide (the h1 is 66px in a 33rem column), so a pinned 100vh
   composition would push the CTA row and the phone number off the bottom of
   the viewport with no way to scroll to them. So the copy scrolls normally
   over the pinned media — on desktop the scrim is a 90deg gradient, constant
   down the whole column, so cream text sits on flat ink at every scroll
   position; on mobile the copy slides UNDER the pinned band (z-index) and
   disappears into ink exactly where the band gradient already ends.

   TRACK LENGTH: pinned travel is sized so the pin releases at about the moment
   the copy has finished leaving, plus a short tail where the finished chimney
   holds the screen alone. Desktop lands at ~240vh, mobile at ~155vh.
============================================================ */

/* The dormant switch. Nothing else in the stylesheet references this class. */
.hero__canvas{display:none}

.hero--scrub-live{
  /* --head-h is written by site.js (the header is sticky and opaque, so the
     pinned viewport starts below it rather than under it — on mobile that is
     what keeps the full --band strip visible). */
  --vph:100vh;
  --stickyh:calc(var(--vph) - var(--head-h,0px));
  display:block;          /* the grid centring moves onto .wrap, below */
  min-height:0;
  padding:0;
  /* --track is the PINNED TRAVEL and site.js measures it: copy height minus the
     header plus a short beat on the finished frame, so the pin releases just as
     the copy finishes leaving. The vh value here is only the fallback. */
  height:calc(var(--stickyh) + var(--track,150vh));
}
/* svh, not vh: the pinned box must fit the SMALL viewport (mobile URL bar
   showing) or its bottom edge is unreachable. */
@supports (height:100svh){.hero--scrub-live{--vph:100svh}}

.hero--scrub-live .hero__sticky2{
  position:sticky;top:var(--head-h,0px);
  height:var(--stickyh);
  overflow:clip;          /* clip, never hidden: hidden makes a scroll container */
}
/* Cover-crop is done in the draw math (site.js reads the poster's computed
   object-position so canvas and poster always crop identically). */
.hero--scrub-live .hero__canvas{
  display:block;position:absolute;inset:0;width:100%;height:100%;z-index:1;
}
/* STANDARD composition (Marc, Aug 15): the copy PINS inside the sticky
   viewport with the media — one fixed layout for the whole scrub, nothing
   scrolls away, no dynamic layers. site.js moves the .wrap into
   .hero__sticky2 at engage and restores it at teardown. */
.hero--scrub-live .hero__sticky2 > .wrap{
  position:relative;z-index:3;width:100%;height:100%;
  display:grid;align-items:center;
  padding-block:clamp(40px,5vw,72px);
}
/* The copy column is taller than a laptop viewport at its normal scale, so
   in scrub mode it compacts. Same words, smaller clothes. */
.hero--scrub-live .hero__copy{max-width:29rem}
.hero--scrub-live .hero__copy h1{font-size:clamp(1.7rem,2.5vw,2.5rem);margin-bottom:.7rem}
.hero--scrub-live .hero__copy .lede--hook{font-size:clamp(1rem,1.4vw,1.2rem);margin-bottom:.4rem}
.hero--scrub-live .hero__copy .lede{font-size:1rem;max-width:44ch}
.hero--scrub-live .hero__copy .cta-note{font-size:.85rem}
.hero--scrub-live .h1-kicker{margin-bottom:.6rem}
/* A standard hero gradient, sized to the pinned copy column: solid behind
   the text, clear by 60% so the animation owns the right of the frame for
   the entire scrub. Replaces the heavier static-hero scrim in scrub mode. */
.hero--scrub-live .hero__scrim{
  background:linear-gradient(90deg,
    var(--ink) 0,
    rgba(25,20,18,.94) 26%,
    rgba(25,20,18,.6) 44%,
    rgba(25,20,18,0) 60%);
}
/* Progress affordance: one oxide hairline across the foot of the pinned
   viewport, scaleX driven by scrub progress. Transform only — no layout. */
.hero--scrub-live .hero__prog{
  position:absolute;left:0;right:0;bottom:0;height:3px;z-index:5;
  background:var(--oxide);transform:scaleX(0);transform-origin:0 50%;
}
/* The cue has done its job once the visitor is scrubbing. */
.hero--scrub-live .hero__cue{transition:opacity .45s var(--ease)}
.hero--scrub-live.is-scrubbing .hero__cue{opacity:0}
@media (prefers-reduced-motion:reduce){
  /* Belt and braces: the engine never engages under reduced motion, so this
     can only fire if the preference changes after engagement. */
  .hero--scrub-live .hero__cue{transition:none}
  .hero--scrub-live .hero__prog{transition:none}
}

/* No ≤860px scrub rules ON PURPOSE: the engine's probe gate is desktop-only,
   and site.js remeasure() enforces it for the window's whole life — resizing
   below 861px tears the scrub down to the designed mobile hero. A mobile
   block here would style a state that cannot exist (and the one that shipped
   briefly targeted `.hero--scrub-live > .wrap`, dead since the copy moved
   inside .hero__sticky2 at engage — Round 11). */

/* ---------- Proof bar: four bricks in one course ---------- */
.proofbar__row{
  display:grid;grid-template-columns:repeat(2,1fr);gap:2px;
  background:var(--rule);background-clip:content-box;
}
@media (min-width:900px){.proofbar__row{grid-template-columns:repeat(4,1fr)}}
.proofbar__item{background:var(--ink);padding:26px 20px;text-align:center}
.proofbar__num{
  font-family:var(--font-display);font-stretch:var(--display-stretch);font-weight:800;font-size:1.55rem;display:block;
  letter-spacing:-.025em;color:var(--fg);line-height:1.1;margin-bottom:.3rem;
  font-variant-numeric:tabular-nums;  /* the count-up must not reflow the cell */
}
.proofbar__label{font-size:.86rem;color:var(--fg-2);display:block}
.proofbar__item a{text-decoration:none}
/* "Wentzville" is one unbreakable 10-character word, so at 1.55rem it sets a
   min-content width of ~162px against a 141px column share: the 1fr track has
   to grow past its share, which pushes the right-hand column 6.6px off the
   viewport where overflow-x:clip cuts it. 360px is exactly the width where the
   word starts fitting, so the correction is scoped strictly below it and the
   layout at 360 and up is untouched. Shrinking the number is the right lever
   rather than letting it wrap: a broken "Wentz/ville" reads as a typo. */
@media (max-width:359.98px){
  .proofbar__num{font-size:1.2rem}
}

/* ---------- Cards: struck-joint borders (2px joint, 1px inner offset) ---------- */
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:18px;margin-top:2.4rem}
.card,.quote,.form,.faq details{
  position:relative;background:var(--slab);border:2px solid var(--rule);border-radius:var(--rad);
}
/* Measured Aug 15: --slab against its own section is 1.08:1 on both grounds.
   That is not a fill, it is a rounding error, and a box that is almost there is
   what reads as template. These are drawn frames, so let them be drawn frames
   on the section ground. .form overrides this with a real dark fill: it is the
   conversion moment and it earns the weight. */
.card,.quote,.faq details{background:transparent}
.card::after,.quote::after,.form::after,.faq details::after{
  content:"";position:absolute;inset:5px;border:1px solid var(--rule);
  border-radius:1px;pointer-events:none;
}
.card{padding:28px 26px;text-decoration:none;display:block;transition:border-color .18s ease,transform .18s ease}
.card:hover{transform:translateY(-3px);border-color:var(--oxide)}
.card h3{margin-bottom:.55rem}
.card p{color:var(--fg-2);font-size:.97rem}
.card__go{
  display:inline-block;margin-top:1rem;font-family:var(--font-mono);font-weight:600;
  font-size:.82rem;letter-spacing:.04em;color:var(--accent-fg);
}
@media (prefers-reduced-motion:reduce){.card:hover{transform:none}}

/* ---------- Split ---------- */
.split{display:grid;grid-template-columns:1fr 1fr;gap:clamp(28px,5vw,64px);align-items:center;margin-top:2.4rem}
@media (max-width:860px){.split{grid-template-columns:1fr}}
.split .ph{min-height:320px}
.ticks{margin-top:1.5rem;display:grid;gap:.9rem}
.ticks li{padding-left:1.7rem;position:relative}
.ticks li::before{content:"";position:absolute;left:0;top:.62em;width:1rem;height:3px;background:var(--oxide)}

/* ---------- Before/after comparator ----------
   The wipe that used to live in the hero, relocated to Our Work as PROOF and
   handed to the visitor. --ba is the share of the frame showing the repaired
   wall; site.js writes it from the range input, which is the real control:
   invisible, full-bleed over the frame, so a pointer drags anywhere on the
   picture AND arrow keys move it. The 16:9 box is reserved by aspect-ratio,
   so the two lazy images cost zero layout shift. */
.ba{margin-top:2.4rem;--ba:35%}
.ba__frame{
  position:relative;aspect-ratio:1600/900;overflow:hidden;
  background:var(--wall-shadow);border:2px solid var(--rule);border-radius:var(--rad);
  cursor:ew-resize;
}
.ba__img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
/* The repaired wall is clipped to the left of the divider. Same inset() clip
   the reveal system uses, driven by a pointer instead of a scroll position. */
.ba__after{position:absolute;inset:0;clip-path:inset(0 calc(100% - var(--ba)) 0 0)}

.ba__line{
  position:absolute;top:0;bottom:0;left:var(--ba);width:2px;margin-left:-1px;
  background:var(--mortar);z-index:4;pointer-events:none;
  /* an ink hairline either side, so the divider still reads over a blown-out
     sky or a white soffit where mortar-on-photo would vanish */
  box-shadow:0 0 0 1px rgba(25,20,18,.55);
}
.ba__grip{
  position:absolute;top:50%;left:50%;width:48px;height:48px;margin:-24px 0 0 -24px;
  border:2px solid var(--mortar);border-radius:var(--rad);
  background-color:var(--ink);
  /* a struck joint in miniature: one bed joint, two head joints off it */
  background-image:
    linear-gradient(var(--mortar),var(--mortar)),
    linear-gradient(var(--mortar),var(--mortar)),
    linear-gradient(var(--mortar),var(--mortar));
  background-size:100% 2px,2px 11px,2px 11px;
  background-position:0 50%,32% calc(50% - 11px),68% calc(50% + 2px);
  background-repeat:no-repeat;
  transition:border-color .18s ease;
}
.ba__range:focus-visible ~ .ba__line .ba__grip,
.ba__frame:hover .ba__grip{border-color:var(--oxide)}

/* mono spec labels, ink chips so they hold 14.97:1 over any photo */
.ba__tag{
  position:absolute;top:14px;z-index:3;
  font-family:var(--font-mono);font-weight:500;font-size:.68rem;letter-spacing:.14em;
  text-transform:uppercase;background:var(--ink);color:var(--mortar);
  padding:5px 10px;border-radius:var(--rad);pointer-events:none;
  transition:opacity .25s ease;
}
.ba__tag--after{left:14px}
.ba__tag--before{right:14px}
/* Each label hides once the divider has swept over its side of the frame.
   No-JS keeps both on: the .js gate is what turns the contextual fade on. */
.js .ba__tag{opacity:0}
.js .ba.is-past-l .ba__tag--after{opacity:1}
.js .ba.is-past-r .ba__tag--before{opacity:1}

/* The control. Transparent and full-bleed: the visible handle is .ba__grip,
   which tracks the same --ba. touch-action:pan-y keeps a vertical swipe on a
   phone scrolling the page instead of being eaten by the slider. */
.ba__range{
  position:absolute;inset:0;z-index:5;width:100%;height:100%;margin:0;padding:0;
  -webkit-appearance:none;appearance:none;background:transparent;border:0;
  cursor:ew-resize;touch-action:pan-y;
}
.ba__range::-webkit-slider-runnable-track{height:100%;background:transparent;border:0}
.ba__range::-moz-range-track{height:100%;background:transparent;border:0}
/* A narrow thumb keeps value and pointer position within a couple of px of
   each other; the browser insets a range track by half the thumb width. */
.ba__range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:6px;height:100%;background:transparent;border:0}
.ba__range::-moz-range-thumb{width:6px;height:100%;background:transparent;border:0;border-radius:0}
@media (max-width:560px){
  .ba__grip{width:40px;height:40px;margin:-20px 0 0 -20px}
  .ba__tag{font-size:.62rem;padding:4px 8px;top:10px}
  .ba__tag--after{left:10px}
  .ba__tag--before{right:10px}
}
@media (prefers-reduced-motion:reduce){
  .ba__tag,.ba__grip{transition:none}
}

/* ---------- Gallery: courses ----------------------------------------
   What this replaces: a repeat(auto-fit,minmax()) grid that put every
   photo in an identical box and cover-cropped mixed-aspect originals to
   fit it. Billy's photos are all PORTRAIT (3000x4000 off a phone), so a
   uniform landscape tile threw away the subject in every one of them and
   the section read as filler.

   The system is a 16-column modular grid laid on a 2px joint. Photos get
   deliberately DIFFERENT sizes, and each size class has ONE fixed aspect
   ratio, so a photo is never squeezed — each file is cropped to the ratio
   of the frame it lands in. The seam is the proofbar's joint language:
   the container's --rule shows through the 2px gaps and rings the panel,
   so a gallery reads as one struck wall, not scattered tiles.

   TWO compositions, ONE language. Both hang a slender feature down the
   left across two rows; the right column carries a wide on top and then
   either two sups (homepage) or one band (service pages):

     homepage             service page
     +------+---------+   +------+---------+
     |      |  wide   |   |      |  wide   |
     | feat +----+----+   | feat +---------+
     |      |sup |sup |   |      |  band   |
     +------+----+----+   +------+---------+

   The column arithmetic closes on purpose. At any width the right stack
   comes to 1.6x the feature's width in height, which IS a 5:8 frame, so
   both compositions hand the feature the same proportion (within 0.3%).
   The feature therefore declares no aspect-ratio at all: the course sets
   its height, its 5:8 crop matches, and object-fit:cover eats the
   rounding. Do not add aspect-ratio to --feature; it would fight the
   row span and reintroduce a squeeze.

   No data-px here. Parallax inside a 2px-joint mosaic is six frames
   drifting at six different rates against a composition whose whole point
   is that its edges line up — it read as noise. The .ph slots elsewhere
   keep it.
------------------------------------------------------------------- */
.gal{
  display:grid;
  grid-template-columns:repeat(16,minmax(0,1fr));
  gap:2px;
  margin-top:2.8rem;
  background:var(--rule);
  border:2px solid var(--rule);
  border-radius:var(--rad);
}
.gal__i{position:relative;overflow:hidden;min-width:0;background:var(--slab)}
.gal__i--feature{grid-column:span 6;grid-row:span 2}
.gal__i--wide{grid-column:span 10;aspect-ratio:8/5}
.gal__i--sup{grid-column:span 5;aspect-ratio:3/2}
.gal__i--band{grid-column:span 10;aspect-ratio:3/1}
.gal__img{
  position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:1;
  transition:transform .55s var(--ease);
}
/* Hover: ONE treatment, the same restraint as the two-tier buttons. The
   frame holds still and the picture eases up inside it. No lift, no
   shadow, no caption swap. hover:hover so a phone tap never sticks. */
@media (hover:hover){
  .gal__i:hover .gal__img{transform:scale(1.035)}
}
@media (prefers-reduced-motion:reduce){
  .gal__img{transition:none}
  .gal__i:hover .gal__img{transform:none}
}
/* Spec-label plate, struck flush into the bottom-left corner of the
   frame — the same mono trade voice as the eyebrows and the step
   numerals. Colours are HARD-CODED rather than taken from the contextual
   tokens: the ground behind this chip is a photograph, not a section, so
   the pair is verified once and holds on every image. mortar #EFE8D9 on
   ink #191412 = 14.97:1. */
.gal__cap{
  position:absolute;left:0;bottom:0;z-index:2;max-width:calc(100% - 20px);
  font-family:var(--font-mono);font-weight:500;font-size:.68rem;
  letter-spacing:.02em;line-height:1.34;
  color:var(--mortar);background:var(--ink);
  padding:6px 12px 5px;border-radius:0 var(--rad) 0 0;
}
/* The deliberate empty cell. /brick-repair/ promises lintel work twice
   and Billy's set has no lintel photo, so that slot stays empty on
   purpose (DEMO-NOTES). In a tight mosaic an empty cell has to be a
   designed panel and not a hole, so it shows the wall itself, drawn in
   the running bond the rest of the site uses. It carries no label: the
   data-tag notes are a build sheet, not visitor copy. */
.gal__i--slot{
  background-color:var(--slab);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='104' height='60'%3E%3Cg stroke='%23CBBFA9' stroke-opacity='.62' stroke-width='2' fill='none'%3E%3Cpath d='M0 1h104M0 31h104'/%3E%3Cpath d='M52 1v30M0 31v30M104 31v30'/%3E%3C/g%3E%3C/svg%3E");
}
/* Tablet: the composition holds, the cells get small. Step the plate down
   so a label still lands on ONE line inside a 218px sup — a two-line chip
   in a small frame stops reading as a spec label and starts reading as a
   caption block sitting on the picture. */
@media (max-width:980px){
  .gal__cap{font-size:.63rem;padding:5px 10px 4px}
}
/* Phone: the courses stack into a single column and every class keeps
   its OWN ratio, so the column alternates 5:8 / 8:5 / 3:2 / 3:2 rather
   than becoming a squeezed copy of the desktop grid. */
@media (max-width:719px){
  .gal{grid-template-columns:1fr;margin-top:2.2rem}
  .gal__i--feature{grid-row:auto;aspect-ratio:5/8}
  .gal__i--feature,.gal__i--wide,.gal__i--sup,.gal__i--band{grid-column:1/-1}
  .gal__cap{font-size:.66rem;padding:5px 10px 4px}
}

/* ---------- Reviews ---------- */
.quotes{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,270px),1fr));gap:18px;margin-top:2.4rem}
.quotes:has(> .quote:only-child){max-width:600px}
.quote{padding:28px 26px}
.quote p{font-size:1rem}
.quote footer{margin-top:1.1rem;font-weight:700;font-size:.88rem}
.quote footer span{color:var(--fg-2);font-weight:400}
.quote .stars{color:var(--accent-text);letter-spacing:3px;margin-bottom:.75rem}
.quote--sample p{color:var(--fg-2);font-style:italic}
.quote--sample footer{color:var(--fg-2)}

/* ---------- Service area: city chips, mono ---------- */
.arealist{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-top:2.2rem}
@media (min-width:620px){.arealist{grid-template-columns:repeat(3,1fr)}}
.arealist a,.arealist span{
  display:block;padding:13px 16px;background:var(--slab);border:2px solid var(--rule);
  border-radius:var(--rad);text-decoration:none;
  font-family:var(--font-mono);font-weight:500;font-size:.85rem;letter-spacing:.01em;
}
.arealist a{transition:border-color .18s ease,color .18s ease}
.arealist a:hover{border-color:var(--oxide);color:var(--accent-fg)}

/* ---------- Steps + contact ---------- */
/* list-style:none matters: this is an <ol>, and without it the browser's own
   "1. 2. 3." renders alongside the designed 01/02/03 chips. It stays an <ol>
   because the order is real, so the marker is suppressed rather than the
   semantics. */
.steps{counter-reset:step;list-style:none;padding-left:0;display:grid;gap:1.5rem;margin-top:2.2rem;max-width:520px}
.steps li{counter-increment:step;padding-left:3.6rem;position:relative}
.steps li::before{
  content:counter(step,decimal-leading-zero);position:absolute;left:0;top:.1em;
  width:2.5rem;height:2.5rem;background:var(--ink);color:var(--mortar);
  font-family:var(--font-mono);font-weight:600;font-size:.85rem;
  display:grid;place-items:center;border-radius:var(--rad);
}
.steps li strong{display:block;font-weight:700}
.steps li span{color:var(--fg-2);font-size:.95rem}
.contact__grid{display:grid;grid-template-columns:1fr 1fr;gap:clamp(28px,5vw,64px);margin-top:2.4rem}
@media (max-width:860px){.contact__grid{grid-template-columns:1fr}}

/* Form: keep fields to the minimum that lets the client follow up.
   Every field costs conversions.

   It is also the ONE dark object on a cream section, deliberately. A cream box
   on a cream ground has no presence at the exact moment we ask for the
   conversion, which is what made this section read cheap. Inverting it to the
   fired-clinker ground carries the signature idea inside the form: every field
   is a cream pointing line struck across the dark, not a boxed rectangle.
   Boxes are what template builders ship. */
.form{
  display:grid;gap:22px;padding:clamp(28px,4vw,40px);
  background:var(--wall);border-color:var(--soot);color:var(--mortar);
}
.form::after{border-color:var(--rule-dark)}
/* Mono spec-voice on the labels, same trade register as the eyebrows. */
.form label{
  font-family:var(--font-mono);font-weight:600;font-size:.72rem;
  letter-spacing:.12em;text-transform:uppercase;color:var(--joint);
  display:grid;gap:10px;
}
.form input,.form textarea{
  font-family:var(--font-body);font-size:1rem;font-weight:400;
  letter-spacing:normal;text-transform:none;color:var(--mortar);
  background:transparent;border:0;border-bottom:2px solid var(--rule-dark);
  border-radius:0;padding:.6em 0;
  /* Underline fields lose the height that box padding used to give them. 44px
     is the floor for a tap target, and 16px is the floor that stops iOS
     zooming the page on focus. Both are minimums, not preferences. */
  min-height:44px;
  transition:border-color .2s cubic-bezier(.4,0,.2,1);
}
.form textarea{resize:vertical;min-height:6.5em}
/* Pointer focus gets the oxide joint line only, quiet and precise. Keyboard
   focus still gets a real ring: focus-visible sits after :focus so it wins. */
.form input:focus,.form textarea:focus{outline:none;border-bottom-color:var(--oxide)}
.form input:focus-visible,.form textarea:focus-visible{outline:3px solid var(--oxide);outline-offset:4px}
.form input::placeholder,.form textarea::placeholder{color:var(--sand);opacity:1}
.form button{font:inherit;cursor:pointer;width:100%;margin-top:4px}
.form .cta-note{margin-top:0;color:var(--joint)}

/* Call panel: what stands in the form's slot until the Portal form embed lands.

   It deliberately reuses .form rather than inventing a lighter card. The comment above
   explains why .form is the one dark object on a cream section: a cream box on a cream
   ground has no presence at the exact moment we ask for the conversion. That argument does
   not stop being true because the contents changed from fields to a phone number, so the
   ground stays and only what sits on it changes.

   .phone-big is written for the dark close-cta and takes its colour from --fg, which is the
   wrong end of the palette inside this inverted card. Repainting it to --mortar is the whole
   adaptation. */
.form--call{align-content:start;gap:12px}
.form--call .kicker{margin:0;color:var(--joint)}
.form--call .call-lede{margin:0;color:var(--mortar);font-size:1.02rem}
.form--call .phone-big{color:var(--mortar);margin-top:.5rem}
.form--call .cta-row{margin-top:1.4rem}
.form--call .btn{width:100%;text-align:center}
.form--call .cta-note{margin-top:0;color:var(--joint)}

/* ---------- Final close ---------- */
.close-cta{
  text-align:center;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='160'%3E%3Cg stroke='%23EFE8D9' stroke-opacity='.09' stroke-width='2' fill='none'%3E%3Cpath d='M0 0h320M0 80h320M0 160h320'/%3E%3Cpath d='M160 0v80M0 80v80M320 80v80'/%3E%3C/g%3E%3C/svg%3E");
}
.close-cta .cta-row{justify-content:center}
.close-cta .phone-big,.phone-big{
  font-family:var(--font-mono);font-weight:600;font-size:clamp(1.5rem,4.2vw,2.5rem);
  letter-spacing:-.02em;position:relative;display:inline-block;margin-top:1.2rem;
  text-decoration:none;color:var(--fg);
}
/* the same struck line, under the number */
.phone-big::after{
  content:"";position:absolute;left:0;right:0;bottom:-.18em;height:.11em;
  background:var(--oxide);border-radius:999px;
}

/* ---------- Footer ---------- */
.site-foot{font-size:.94rem}
.site-foot a{color:var(--fg);text-decoration:none;border-bottom:1px solid transparent}
.site-foot a:hover{border-bottom-color:var(--oxide)}
.site-foot__grid{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:clamp(24px,4vw,56px);padding-block:60px}
@media (max-width:760px){.site-foot__grid{grid-template-columns:1fr}}
.site-foot .site-foot__h{
  color:var(--fg);font-family:var(--font-display);font-stretch:var(--display-stretch);font-weight:700;font-size:1rem;
  letter-spacing:-.015em;margin-bottom:1rem;
}
.site-foot li{margin-bottom:.55rem;color:var(--fg-2)}
.site-foot__legal{
  border-top:1px solid var(--rule);padding-block:20px;display:flex;gap:16px;flex-wrap:wrap;
  justify-content:space-between;font-family:var(--font-mono);font-weight:500;
  font-size:.75rem;letter-spacing:.02em;color:var(--fg-2);
}

/* ---------- Placeholder image system ----------
   Drop <img class="ph__img" src="..." alt="..." loading="lazy" decoding="async"
   onerror="this.remove()"> inside any .ph and the label disappears. */
.ph{
  position:relative;border-radius:var(--rad);overflow:hidden;
  background:repeating-linear-gradient(45deg,#DDD4C1 0 15px,#E6DECB 15px 30px);
  border:2px solid var(--rule);
}
/* content: <string> / "" — the empty ALT text after the slash keeps the tag
   visible but takes it out of the accessibility tree. data-tag holds internal
   production notes ("owner photo: Billy on a job"); a screen reader reading
   those out is leaking our build sheet to the visitor. */
.ph::after{
  content:attr(data-tag) / "";position:absolute;left:12px;bottom:11px;
  font-family:var(--font-mono);font-weight:500;font-size:.68rem;letter-spacing:.02em;
  color:var(--soot);background:#EFE8D9;padding:4px 9px;border-radius:var(--rad);
}
.ph__img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:1}
.ph:has(>.ph__img)::after{content:none}

/* ---------- Image parallax ----------
   Add data-px to any .ph and site.js writes --px on it (0 until the element is
   actually on screen). ONLY the media layer inside the reserved box moves, by
   at most 6% of the box height, so layout never shifts and the effect is
   transform-only. The layer is oversized 116% and hung at -8% so a +/-6% drift
   can never expose an edge: 8 - 6*1.16 = 1.04% of slack at both ends.
   The ::before carries the placeholder weave so the drift is visible before
   Billy's photos land; it retires itself the moment a real .ph__img is in. */
.ph[data-px]{--px:0px}
.ph[data-px]::before{
  content:"";position:absolute;left:0;right:0;top:-8%;height:116%;
  background:repeating-linear-gradient(45deg,#DDD4C1 0 15px,#E6DECB 15px 30px);
  transform:translate3d(0,var(--px),0);
  will-change:transform;   /* a handful of nodes: keeps the --px update on the compositor */
}
.ph[data-px] .ph__img{top:-8%;height:116%;transform:translate3d(0,var(--px),0);will-change:transform}
.ph[data-px]:has(>.ph__img)::before{content:none}
@media (prefers-reduced-motion:reduce){
  .ph[data-px]::before,.ph[data-px] .ph__img{transform:none}
}

/* ---------- Reveal on scroll ----------
   - NEVER on above-the-fold content.
   - .js gate means no-JS users see everything instantly.
   - opacity starts at .15 not 0, and a timed failsafe exists in the JS, so a
     stalled animation clock can never leave content invisible. */
.reveal{opacity:1}
.js .reveal{opacity:.15;transform:translateY(16px);transition:opacity .55s ease,transform .55s ease}
.js .reveal.in{opacity:1;transform:none}

/* Typed variant for media: the frame holds still and a clip edge RISES up it,
   so the picture is uncovered rather than faded. Same safety hedge as the
   opacity:.15 above — the base state leaves the bottom 15% showing, so a slot
   is never a blank rectangle even if the observer and the failsafe both die.
   Requires .reveal alongside it, which is also what the JS failsafe looks for. */
.js .reveal--mask{
  opacity:1;transform:none;
  clip-path:inset(85% 0 0 0);
  transition:clip-path .8s var(--ease);
}
.js .reveal--mask.in{clip-path:inset(0 0 0 0)}

/* ---------- Clock-independent failsafe ----------
   site.js adds .settled at the 6s backstop. Flipping to the TARGET state is not
   enough there: the tab that needs rescuing is exactly the tab whose animation
   clock is suspended, so a class that only changes the end value still queues a
   0.55s fade or a 0.8s clip sweep that does not begin running until the tab is
   foregrounded — the visitor watches the page assemble itself on arrival.
   .settled writes the finished values AND switches the clocks off, so the first
   painted frame is the finished frame.
   !important because this is the last-resort recovery path: it has to beat every
   state rule regardless of where they land in source order.
   Selectors are kept narrow (.reveal / the three divider hosts / .hero__copy) so
   the blanket transform:none cannot reach the .ph[data-px] parallax layers, which
   live on ::before and .ph__img and must keep moving. */
.js .reveal.settled{
  opacity:1!important;transform:none!important;clip-path:none!important;
  transition:none!important;
}
.js .sec.settled::before,.js .proofbar.settled::before,.js .site-foot.settled::before,
.js .sec.settled::after,.js .proofbar.settled::after,.js .site-foot.settled::after{
  transform:none!important;transition:none!important;
}
/* The hero backstop. heroIn starts at opacity .2, so a suspended clock could
   leave every line of hero copy at one fifth opacity indefinitely; dropping the
   animation returns the children to their natural state, which is the finished
   one. */
.js .hero__copy.settled > *{animation:none!important}

@media (prefers-reduced-motion:reduce){
  .js .reveal{opacity:1;transform:none;transition:none}
  .js .reveal--mask{clip-path:none;transition:none}
}

/* ---------- Sticky mobile call bar ----------
   Hidden state uses inert, never aria-hidden: aria-hidden would leave the
   links in the tab order while invisible. */
.callbar{
  position:fixed;left:0;right:0;bottom:0;z-index:60;display:none;
  grid-template-columns:1fr 1fr;gap:2px;background:var(--mortar);
  transform:translateY(102%);transition:transform .25s ease,visibility 0s .25s;
  visibility:hidden;border-top:2px solid var(--mortar);
  /* Safe areas on all three edges it can touch. The bottom inset keeps the
     buttons clear of the home indicator; the side insets matter on a phone
     held in landscape, where the notch eats the left or right edge. --gutter
     is the floor so the bar is framed by its own cream ground at every width
     rather than jumping inward only on notched hardware. */
  padding-bottom:env(safe-area-inset-bottom);
  padding-left:max(var(--gutter),env(safe-area-inset-left));
  padding-right:max(var(--gutter),env(safe-area-inset-right));
}
.callbar.on{transform:none;visibility:visible;transition:transform .25s ease}
.callbar a{
  display:block;text-align:center;padding:16px 10px;text-decoration:none;
  font-family:var(--font-mono);font-weight:600;font-size:.92rem;
  background:var(--oxide-deep);color:var(--mortar);  /* 6.69:1 */
}
.callbar a.alt{background:var(--ink);color:var(--mortar)}  /* 14.97:1 */
/* The bottom padding is the callbar's own clearance, so it belongs only to
   pages that have one (thank-you and 404 do not, and were reserving 60px of
   dead space under the footer). Written as a plain rule plus a :not(:has())
   subtraction on purpose: if :has() is unsupported the SECOND selector is the
   invalid one, so it drops out and every page keeps the padding — the old
   behaviour, never a callbar sitting on top of content. */
@media (max-width:760px){
  .callbar{display:grid}
  body{padding-bottom:calc(60px + env(safe-area-inset-bottom))}
  body:not(:has(.callbar)){padding-bottom:0}
}
/* Below ~380px "Free estimate" wraps at .92rem, making the bar taller than
   the 60px the body reserves and overlapping the last content. Keep both
   labels to one line instead of reserving more. */
@media (max-width:379.98px){
  .callbar a{font-size:.78rem;padding:17px 6px}
}
@media (prefers-reduced-motion:reduce){.callbar{transition:visibility 0s}}

/* ============================================================
   Subpages (service pages, area pages, utility pages)
   These files still link this sheet, so every shared class above already
   restyles them. Sub-heroes stay LIGHT: the dark header sits directly on
   a light page, and the dark band returns at .close-cta + footer, so the
   dark -> light -> dark rhythm holds without a header/crumbs/hero sandwich.
============================================================ */
/* No texture here, on purpose. This section carried a running-bond hairline
   background, and its lines cut straight through the lede on every service
   page and the blog: body text NEVER shares its ground with linework
   (docs/lessons.md). The struck top rule keeps the joint language. */
.hero.hero--sub{
  min-height:0;display:block;isolation:auto;
  padding-block:clamp(38px,5vw,68px) clamp(46px,6vw,80px);
  border-top:2px solid var(--rule);
  background-color:var(--limewash);
}
.hero.hero--sub .lede{margin-top:1.1rem;max-width:60ch}

.crumbs{
  font-family:var(--font-mono);font-weight:500;font-size:.75rem;letter-spacing:.03em;
  color:var(--fg-2);padding-top:18px;
}
.crumbs a{color:var(--fg-2);text-decoration:none;border-bottom:1px solid transparent}
.crumbs a:hover{color:var(--fg);border-bottom-color:var(--oxide)}
.crumbs span{margin-inline:.5em}

.prose{max-width:68ch}
.prose p{margin-bottom:1.1rem}
.prose h2{margin:2.5rem 0 1rem}
.prose ul{list-style:disc;padding-left:1.3em;margin-bottom:1.1rem}
.prose li{margin-bottom:.4rem}

/* FAQ: mono spec numbers, struck-joint panels, no dash glyph on the toggle */
.faq{margin-top:2.2rem;max-width:820px;counter-reset:faq}
/* An FAQ is a list, not a stack of cards. Three barely-there boxes in a column
   read as a template; hairline rules read as editorial, and it is the same
   restraint the reference sites use at ten times the budget. The struck-joint
   frame belongs on things you click through to, not on things you open in place. */
.faq details{
  border:0;border-radius:0;border-bottom:1px solid var(--rule);
  padding:20px 0;margin-bottom:0;counter-increment:faq;
}
.faq details:first-of-type{border-top:1px solid var(--rule)}
.faq details::after{content:none}
.faq summary{
  font-family:var(--font-display);font-stretch:var(--display-stretch);font-weight:700;font-size:1.05rem;cursor:pointer;
  list-style:none;display:flex;align-items:baseline;gap:.7em;padding-right:2rem;position:relative;
}
.faq summary::-webkit-details-marker{display:none}
.faq summary::before{
  content:counter(faq,decimal-leading-zero);
  font-family:var(--font-mono);font-weight:600;font-size:.72rem;letter-spacing:.06em;
  color:var(--accent-fg);flex:0 0 auto;
}
.faq summary::after{
  content:"+";position:absolute;right:0;top:-.1em;
  font-family:var(--font-mono);font-weight:500;font-size:1.2rem;line-height:1;
  color:var(--fg-2);transition:transform .2s ease;
}
.faq details[open] summary::after{transform:rotate(45deg)}
.faq details p{margin-top:.9rem;color:var(--fg-2)}
@media (prefers-reduced-motion:reduce){.faq summary::after{transition:none}}

.util{min-height:55vh;display:grid;place-items:center;text-align:center;position:relative}
.util .cta-row{justify-content:center}

/* ---------- a11y ---------- */
/* The skip link is the one focus ring that cannot inherit the root --focus.
   It always lands on the dark sticky header, and oxide-deep on ink is 2.27:1,
   under the 3:1 floor for a non-text indicator — and it was also the chip's own
   background, so the ring was drawn in the chip's colour on the chip. It gets
   its own ground and its own double ring instead: cream chip with an ink label
   (14.97:1), an ink hairline separating chip from ring, and a cream halo that
   reads 14.97:1 against the ink header. Whichever side you look from, one of
   the two rings is high contrast. Higher specificity than a:focus-visible
   below, so this wins on keyboard focus. */
.skip{position:absolute;left:-9999px;z-index:100}
.skip:focus{
  left:12px;top:12px;background:var(--mortar);color:var(--ink);
  padding:11px 18px;border-radius:var(--rad);font-weight:700;
  outline:3px solid var(--mortar);outline-offset:3px;
  box-shadow:0 0 0 3px var(--ink);
}
a:focus-visible,button:focus-visible,summary:focus-visible,
input:focus-visible,textarea:focus-visible{outline:3px solid var(--focus);outline-offset:2px}

/* ---------- Spacing utilities (replace one-off inline styles) ---------- */
.mt-1{margin-top:1.2rem}
.mt-2{margin-top:1.4rem}
.mt-3{margin-top:1.6rem}
.push-right{margin-left:auto}
.narrow-center{margin:1.2rem auto 0;max-width:52ch}

/* ============================================================
   ---------- Blog ----------
   Ported from the GoodLeed client template and re-tokenised to the Apex
   palette (--oxide where the template said --accent). ADDITIVE ONLY: nothing
   above this line is edited, so every rule here either names a class that did
   not exist on this site (.postcard, .bloglist, .post-*, .panel) or re-states
   an existing block for a wider media query.

   See client-template/blog/PUBLISH-CONTRACT.md: the listing cards and the
   sitemap entries are written by automation, so nothing here may depend on
   card ORDER, on a card COUNT, or on any wrapper the automation would have to
   create. Every rule below works on zero cards, on one, or on fifty.
============================================================ */

/* Burger breakpoint, RE-MEASURED for the six-item nav.
   Header content at the narrowest desktop width (brand + nav + phone CTA +
   two 26px flex gaps + two gutters), measured in the browser, not guessed:
     5 items, webfonts loaded ....... 897px
     6 items, webfonts loaded ....... 951px   (the Blog item costs 53.5px)
     6 items, DURING font-display:swap fallback ... 990px
   The old 960px breakpoint left only 9px of slack in the first row and went
   NEGATIVE in the swap window, and body has overflow-x:clip, so the phone CTA
   would have silently clipped off-screen on the first paint of a cold visit
   between 961 and 1000px (docs/lessons.md, the burger-arithmetic lesson).
   1024px clears the worst measured case by 34px. Same declarations as the
   960px block above, so at <=960px the two agree and there is nothing to win. */
@media (max-width:1024px){
  .nav__burger{display:block}
  .nav__links{
    display:none;position:absolute;top:100%;left:0;right:0;background:var(--ink);
    border-bottom:2px solid var(--rule);flex-direction:column;gap:0;padding:6px 0;
  }
  .nav__links.open{display:flex}
  .nav__links a{display:block;padding:13px var(--gutter);border-bottom:0;border-left:2px solid transparent}
  .nav__links a:hover{border-left-color:var(--oxide)}
}

/* Struck-joint border, the same 2px joint + 1px inner offset the cards use.
   Re-stated rather than added to the .card,.quote,.form,.faq details selector
   list, which is above this line and stays untouched. */
.postcard,.panel{
  position:relative;background:var(--slab);border:2px solid var(--rule);border-radius:var(--rad);
}
.postcard::after,.panel::after{
  content:"";position:absolute;inset:5px;border:1px solid var(--rule);
  border-radius:1px;pointer-events:none;
}
.panel{padding:28px 26px}

/* Explicit column counts, not auto-fit: with auto-fit a single post would
   collapse the empty tracks and stretch that one card the full 1160px, which
   is exactly what a brand new blog looks like on launch day. Fixed columns
   keep the first card card-sized and let later posts fill in beside it. */
.bloglist{display:grid;grid-template-columns:1fr;gap:18px;margin-top:2.4rem}
@media (min-width:620px){.bloglist{grid-template-columns:repeat(2,1fr)}}
@media (min-width:940px){.bloglist{grid-template-columns:repeat(3,1fr)}}
.postcard{display:block;text-decoration:none;overflow:hidden;transition:border-color .18s ease,transform .18s ease}
.postcard:hover{transform:translateY(-3px);border-color:var(--oxide)}
@media (prefers-reduced-motion:reduce){.postcard:hover{transform:none}}
.postcard .ph{border:0;border-bottom:2px solid var(--rule);border-radius:0;aspect-ratio:16/9}
.postcard__body{padding:22px 24px 26px}
.postcard__meta{
  font-family:var(--font-mono);font-weight:600;font-size:.7rem;letter-spacing:.11em;
  text-transform:uppercase;color:var(--accent-fg);display:block;margin-bottom:.7rem;
}
.postcard__title{font-family:var(--font-display);font-stretch:var(--display-stretch);font-size:var(--h3);font-weight:700;letter-spacing:-.01em;line-height:1.25}
.postcard__excerpt{color:var(--fg-2);font-size:.95rem;margin-top:.5rem}
/* Empty state. Pure CSS, so the automation never has to remove it: the moment
   one card exists inside .bloglist, this panel takes itself off the page. */
.bloglist__empty{grid-column:1/-1}
.bloglist:has(.postcard) .bloglist__empty{display:none}

.post-meta{
  font-family:var(--font-mono);font-weight:500;font-size:.75rem;letter-spacing:.08em;
  text-transform:uppercase;color:var(--fg-2);margin-top:1.2rem;
  display:flex;flex-wrap:wrap;gap:.9em;
}
.post-meta > span + span::before{content:"/";margin-right:.9em;color:var(--rule)}
.post-figure{margin-top:2.2rem;aspect-ratio:16/9}
.post-body{max-width:68ch;margin-top:2.4rem}
.post-body > :first-child{font-size:1.16rem;color:var(--fg-2)}  /* standfirst */
.post-cta{margin-top:3rem}
.backlink{
  display:inline-block;margin-top:2rem;font-family:var(--font-mono);font-weight:600;
  font-size:.82rem;letter-spacing:.04em;color:var(--accent-fg);text-decoration:none;
  border-bottom:1px solid currentColor;
}

/* .prose on this site was written for service-page copy and only styles p, h2,
   ul and li. The publish contract lets a post body use h3, ol, a, blockquote,
   figure and img as well, so those get their rules here, scoped to .post-body
   so no existing page changes. */
.post-body h3{margin:1.8rem 0 .7rem}
.post-body ol{list-style:decimal;padding-left:1.3em;margin-bottom:1.1rem}
.post-body ol li{margin-bottom:.4rem}
.post-body a{color:var(--accent-fg);border-bottom:1px solid currentColor;text-decoration:none}
.post-body blockquote{
  border-left:3px solid var(--oxide);padding-left:1.1rem;margin:1.6rem 0;
  color:var(--fg-2);font-size:1.1rem;
}
.post-body figure{margin:1.8rem 0}
.post-body figure img{width:100%;height:auto;border:2px solid var(--rule);border-radius:var(--rad)}
.post-body figcaption{
  font-family:var(--font-mono);font-weight:500;font-size:.72rem;letter-spacing:.03em;
  color:var(--fg-2);margin-top:.6rem;
}
