/* King Exterior - mobile-first stylesheet.
   No business facts here. No colours here. Every value reads a token from
   variables.css, which is generated from site.config.json.
   Breakpoints: 480 / 768 / 992 / 1200. */

/* ---------- fonts (self-hosted: no third-party request, no consent question) ---------- */
@font-face{font-family:'Inter';src:url('/fonts/inter-300.woff2') format('woff2');font-weight:300;font-style:normal;font-display:swap}
@font-face{font-family:'Inter';src:url('/fonts/inter-600.woff2') format('woff2');font-weight:600;font-style:normal;font-display:swap}
@font-face{font-family:'Roboto Slab';src:url('/fonts/roboto-slab-400.woff2') format('woff2');font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:'DM Sans';src:url('/fonts/dm-sans-700.woff2') format('woff2');font-weight:700;font-style:normal;font-display:swap}
/* Bodoni Moda, SIL OFL 1.1, Owen Earl / indestructible type. Verified at
   source before shipping: google/fonts ofl/bodonimoda/METADATA.pb reads
   license: "OFL". Latin subset, and it carries the VARIABLE OPTICAL-SIZE
   axis - which is why it is 26.7KB against the static faces' 7-12KB, and
   why it is the right didone for headings: opsz thickens the hairlines as
   the size drops, so a Bodoni set at 28px on a phone does not go spindly
   the way a single-master revival would. */
@font-face{font-family:'Bodoni Moda';src:url('/fonts/bodoni-moda-400.woff2') format('woff2');font-weight:400;font-style:normal;font-display:swap}

/* ---------- reset ---------- */
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg-color);color:var(--text-color);
  font-family:var(--body-font);font-weight:var(--body-weight);
  font-size:var(--body-size);line-height:var(--body-line-height);
  overflow-x:hidden}
img{max-width:100%;height:auto;display:block}
/* INTERACTION CONTRACT - every link, button and card in this sheet obeys it:
     :hover         recolours
     :focus-visible draws the ring
     :active        presses 1px
   :focus-visible rather than :focus is the load-bearing part. The old rules
   paired :hover with :focus, so a mouse click left a ring stuck on the element
   until you clicked elsewhere, and keyboard users got the same weak treatment
   as mouse users. Do not reintroduce a bare :focus here. */
a{color:var(--text-color);text-decoration:underline;text-underline-offset:2px;
  transition:color .15s ease}
a:hover{color:var(--accent-deep)}
a:active{color:var(--accent-border)}
:focus-visible{outline:3px solid var(--accent-color);outline-offset:2px}

h1,h2,h3,h4,h5,h6{font-family:var(--heading-font);font-weight:var(--heading-weight);
  line-height:var(--heading-line-height);color:var(--text-color);margin:0 0 var(--space-sm)}
h1{font-size:var(--h1-size-mobile);line-height:var(--heading-tight)}
h2{font-size:var(--h2-size-mobile);line-height:var(--heading-tight)}
h3{font-size:var(--fs-2xl)}
h4{font-size:var(--fs-xl)}
p{margin:0 0 var(--space-sm)}
ul,ol{margin:0 0 var(--space-sm);padding-left:1.25em}
li{margin-bottom:var(--space-xs)}

/* A GUARANTEED OUTER GUTTER, not just a max-width.
   ---------------------------------------------------------------------------
   max-width alone leaves the page flush against the window between roughly
   1180 and 1260, where the container has hit its cap but the viewport has not
   yet opened a margin - at a 1250 window the content sat 35px off each edge and
   read as jammed into the corner rather than as a centred measure. That is the
   "pushed too far to the left" report, and it is a WINDOW-WIDTH band, not a
   left-alignment bug: everything in it is correctly aligned to a container that
   is itself too close to the edge.

   min() keeps the 1180 measure wherever there is room for it and otherwise
   holds a 32px gutter on each side, so the content never touches the frame at
   any width. --container-padding still supplies the inner padding for narrow
   screens; the two do not fight because min() governs the BOX and the padding
   governs the inset within it. */
.container{width:min(100%, var(--container-width));margin:0 auto;
  padding:var(--container-padding)}
@media (min-width:768px){
  .container{width:min(100% - 64px, var(--container-width))}
}
.section{padding:var(--section-y) 0}
.section-alt{background:var(--bg-alt)}
.section-dark{background:var(--primary-dark);color:#fff}
/* :not(.btn) matters. '.section-dark a' is (0,1,1) and '.btn-accent' is
   (0,1,0), so without it the dark section wins and paints the gold button's
   label white - the same button reads black-on-gold at the top of the page and
   white-on-gold at the bottom. Buttons carry their own palette. */
.section-dark h1,.section-dark h2,.section-dark h3,
.section-dark a:not(.btn){color:#fff}
.text-center{text-align:center}
.lede{font-size:var(--fs-lg);max-width:62ch}
/* ONE LEFT EDGE PER PAGE.
   .container.measure used to cap the CONTAINER at 70ch, and because containers
   are margin:0 auto that re-centred the whole block: measured on a city page,
   .container content started at 70px and .container.measure at 345px, so
   headings jumped 275px sideways down the page.

   The typographic answer is that reading measure applies to PROSE, not to
   section furniture. So the container keeps its full width and one left edge,
   and only the text children are constrained. Headings, media and anything
   that is its own layout opt out.

   Done in CSS rather than by rewrapping ~25 sections in the templates: same
   result, far less to get wrong. */
.measure{max-width:70ch}
.container.measure{max-width:var(--container-width)}
.container.measure > p,
.container.measure > ul,
.container.measure > ol,
.container.measure > blockquote{max-width:70ch}
/* A capped block defaults to margin-left:0, so inside a CENTRED container the
   cap silently pins prose and button rows to the left while the heading above
   them is centred. That is what pushed the CTA buttons off-centre. */
.container.measure.text-center > p,
.container.measure.text-center > ul,
.container.measure.text-center > ol{margin-inline:auto}
.eyebrow{font-family:var(--display-font);font-weight:var(--display-weight);
  font-size:var(--fs-meta);letter-spacing:.12em;text-transform:uppercase;
  color:var(--text-muted);margin:0 0 var(--space-xs)}

.skip-link{position:absolute;left:-9999px;top:0;background:var(--accent-color);
  color:#000;padding:12px 20px;z-index:200;font-weight:600}
.skip-link:focus{left:0}

/* ---------- chevron (inline SVG, rebuilt from the raster h2-1.png) ----------
   Aspect is pinned by the viewBox (327x72), so only width is ever set - a
   fixed height here would squash the taper. */
.chevron{display:block;width:118px;height:auto;margin:0 0 var(--space-sm)}
.chevron path{fill:var(--accent-color)}
.text-center .chevron{margin-left:auto;margin-right:auto}

/* ---------- buttons ---------- */
.btn{display:inline-block;border-radius:var(--btn-radius);padding:var(--btn-padding);
  font-family:var(--body-font);font-weight:600;font-size:var(--btn-font-size);
  line-height:var(--btn-line-height);text-decoration:none;text-align:center;
  cursor:pointer;
  transition:background .15s ease,color .15s ease,border-color .15s ease,transform .1s ease}
.btn-primary{background:var(--primary-dark);color:#fff;border:1px solid var(--accent-border)}
.btn-primary:hover{background:var(--accent-color);color:#000;border-color:#000}
.btn-accent{background:var(--accent-color);color:#000;border:1px solid var(--accent-border)}
.btn-accent:hover{background:#000;color:#fff;border-color:#000}
/* GOLD ON GOLD: THE FOCUS RING WAS INVISIBLE ON THE PRIMARY CTA.
   The global rule is outline:3px solid var(--accent-color), which is #eac92d -
   the exact background of .btn-accent. So the site's most important button,
   the one every hero and band ends on, showed NO visible focus indicator to a
   keyboard user. That is a real failure, not a polish item: the ring existed,
   passed a "do we have focus styles" check, and disappeared on the one control
   that most needed it.
   Black is already in the palette as --primary-dark, and the offset keeps it
   clear of the gold fill. Scoped to the accent button so every other control
   keeps the gold ring, which has contrast everywhere else. */
.btn-accent:focus-visible{outline-color:var(--primary-dark)}
.sticky-call a:focus-visible{outline-color:var(--primary-dark)}
.btn:active{transform:translateY(1px)}
.link-button:hover{color:var(--accent-deep)}
.link-button:active{color:var(--accent-border)}
.btn-lg{padding:16px 32px;font-size:var(--fs-base)}
.link-button{background:none;border:0;padding:0;font:inherit;color:inherit;
  text-decoration:underline;cursor:pointer}

/* ---------- header ---------- */
.site-header{position:sticky;top:0;z-index:100;background:var(--bg-color);
  border-bottom:1px solid var(--border-color)}
/* The header is sticky, so an in-page jump lands UNDER it and the heading it
   was aiming at is the part that gets hidden. Every anchor target carries its
   own offset rather than the jump being corrected at the link end, because the
   header height is a property of the header, not of whoever links to it.

   THE VALUE IS THE MEASURED HEADER, not a round number. It is logo height plus
   10px padding top and bottom plus the 1px border, and the logo is 132px wide
   below 992 and 180px above it against a 1.699 aspect:
     mobile   132/1.699 = 78  + 21 =  99  ->  104
     desktop  180/1.699 = 106 + 21 = 127  ->  136
   A single 96px value cleared the HEADING at desktop only because the section's
   own 80px of top padding happened to make up the 31px shortfall. That is right
   by coincidence, and it stops being right on the first anchor added to a
   section with less padding or a visible top edge. */
[id]{scroll-margin-top:104px}
.header-inner{display:flex;align-items:center;justify-content:space-between;
  gap:var(--space-sm);padding-top:10px;padding-bottom:10px}
.brand{display:block;line-height:0;text-decoration:none}
.brand img{width:132px;height:auto}
.menu-toggle{display:flex;flex-direction:column;justify-content:center;gap:5px;
  width:44px;height:44px;background:none;border:1px solid var(--border-color);
  border-radius:var(--btn-radius);cursor:pointer;padding:0 10px}
.menu-bar{display:block;height:2px;background:var(--text-color)}
.site-nav{display:none;position:absolute;left:0;right:0;top:100%;
  background:var(--bg-color);border-bottom:1px solid var(--border-color)}
.site-nav.is-open{display:block}
/* THE OPEN MENU NEEDS ITS OWN SCROLLPORT, and this is not a nicety.
   .site-nav is position:absolute inside .site-header, which is position:sticky
   top:0. The header is pinned to the viewport, so its absolutely-positioned
   child is pinned too: SCROLLING THE PAGE DOES NOT MOVE THE DROPDOWN AT ALL.
   With Service Areas expanded the menu is ~990px tall against a ~745px visible
   viewport on an iPhone 12-15 in portrait, so ~245px of it - the last four
   service areas, everything after Gap PA - was unreachable by any gesture.
   Reported as "stops at Gap PA and will not scroll"; the arithmetic puts the
   sticky bar's top edge inside the Gap row at exactly 723-767px of viewport.

   --header-h is MEASURED, and it is only correct because the logo does not
   change size anywhere this rule applies: .brand img is 132px for every width
   below 992 (132/1.6981 = 77.7, + 10 + 10 padding + 1px border = 98.7). Above
   992 the header grows to 108px then 127px as the logo steps to 148 and 180 -
   which is why this whole block is scoped to max-width:991.98px. Above that
   .site-nav is position:static and horizontal, there is no dropdown, and the
   token must not follow it there. Re-measure if the logo width changes. */
@media (max-width:991.98px){
  .site-nav{--header-h:99px}
  .site-nav.is-open{max-height:calc(100vh - var(--header-h));
    max-height:calc(100dvh - var(--header-h));
    overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}
}
.nav-links{list-style:none;margin:0;padding:var(--space-sm) 20px}
.nav-links li{margin:0}
.nav-links a{display:block;padding:12px 0;text-decoration:none;font-weight:600}
.nav-links a.is-current{color:var(--accent-deep)}
.nav-cta{display:none}

/* ---------- hero ---------- */
.hero{background:var(--primary-dark);color:#fff;padding:var(--section-y) 0}
/* FULL-BLEED HERO PHOTO, only on .hero-with-form.
   The photo and the scrim are children of .hero and SIBLINGS of .container, not
   children of it. The grid lives on .container, so a sibling is never one of
   its items at any width - .hero .container still gets exactly two children.
   See the note at the grid rule below: a third grid child is how this broke
   once already. Absolute positioning below buys the full-bleed backdrop at
   >=992; it is NOT what keeps these out of the grid.
   isolation:isolate keeps the scrim's stacking local, so nothing downstream has
   to know about these z-indexes.
   BELOW 992 THIS ALL DE-LAYERS: the photo returns to normal flow above the
   stacked form and the scrim is removed, because a background photo behind a
   full-width form is unreadable at phone widths. */
.hero-with-form{position:relative;isolation:isolate}
.hero-with-form .container{position:relative;z-index:2}
/* Below 992 the photo LEADS the hero, then copy, then form. It stays a sibling
   of .container rather than moving inside it between the copy and the form:
   .hero .container is allowed exactly two in-flow children, and a third that
   only behaves because it is absolutely positioned is precisely the fragile
   arrangement the note at the grid rule warns about. No top margin - the hero's
   own padding already separates it from the header, and doubling that read as a
   black band above the photo. */
.hero-bg{margin-bottom:var(--space-lg);line-height:0}
/* No border-radius, unlike .hero-media img. This photo is FULL-BLEED at the
   hero's top edge rather than inset inside the container gutter, and a radius
   on an edge-to-edge image only shows as clipped corners against the panel. */
.hero-photo{display:block;width:100%;height:auto;aspect-ratio:3/2;object-fit:cover}
.hero-scrim{display:none}
/* Hidden below 992, and it carries no source there either - main.js attaches
   sources only at >=992, so this rule is presentation, not the gate. Both are
   needed: CSS alone would still download a video that had a src. */
.hero-video{display:none}
.hero h1{color:#fff;margin-bottom:var(--space-sm)}
.hero p{color:#f2f2f2}
.hero .chevron path{fill:var(--accent-color)}
.hero-actions{display:flex;flex-wrap:wrap;gap:var(--space-sm);margin-top:var(--space-md)}
/* FULL-WIDTH CTA PAIR ON MOBILE - hero and cta_band, which share this class.
   Measured at 390 they were 238px and 226px in a 350px column: two different
   widths, both short of the measure, so the pair read as ragged rather than as
   a decision. Stretched they agree with each other and with the text block.
   The LABEL stays centred inside the button, which is not the mobile-centring
   being retired elsewhere in this file - that is about block alignment, and a
   full-bleed button with a left-shoved label reads as a bug. */
@media (max-width:767px){
  .hero-actions{flex-direction:column;align-items:stretch;flex-wrap:nowrap}
  .hero-actions .btn{display:block;width:100%;text-align:center}
}
/* THE HERO PHOTO RUNS TO THE DARK PANEL'S BOTTOM EDGE ON MOBILE.
   Below 992 the hero stacks copy-then-photo, so the hero's own bottom padding
   sat UNDER the photo as a black band between it and whatever followed - read
   on the phone as a gap in the page rather than as part of the hero. Scoped to
   the stacked layout only: from 992 up the photo is a grid column beside the
   copy and zeroing the padding there would crop the panel against the text. */
@media (max-width:991.98px){
  .hero:has(.hero-media){padding-bottom:0}
  .hero:has(.hero-media) .hero-media{margin-bottom:0;line-height:0}
  /* THE SAME PRIMITIVE AT THE OTHER EDGE. The form hero stacks photo-then-copy,
     so its photo leads the panel instead of closing it, and the hero's own TOP
     padding sat above it as the identical black band this rule was written to
     remove. Same rule, same reason, mirrored to the edge the photo touches. */
  .hero:has(.hero-bg){padding-top:0}
}

/* ---------- cards ---------- */
.grid{display:grid;gap:var(--space-md);grid-template-columns:1fr}
.grid-2{grid-template-columns:1fr}
/* THE CARD LIFTS OFF EITHER GROUND. Six of the ten card grids sit on a WHITE
   section, where `.section-alt .card` never fires, so the card was #fff on #fff
   separated by one #e2e2e2 hairline - which is the cheap look this pass exists
   to remove. The reference site solves it with a grey ground, but the
   alternating white/grey band rhythm is the only vertical structure the page
   has and two grey bands in a row would destroy it. Elevation fixes all ten
   grids with one rule and no section restructuring, and keeps the reference's
   load-bearing move: bordered white boxes that read as OBJECTS, not regions.
   background is #fff explicitly rather than var(--bg-color) so the card does
   not follow the surface token if that is ever re-themed. */
.card{background:#fff;border:1px solid var(--border-color);
  border-radius:0;padding:32px;
  box-shadow:0 1px 2px rgba(0,0,0,.04), 0 8px 24px rgba(0,0,0,.06)}
/* PADDING IS DERIVED FROM THE WIDTH IT HAS TO FILL, not picked. At two columns
   in an 1180 container with 20px gutters and a 24px grid gap the card measures
   558px at 1280, 464 at 992 and 388 at 840. 40px is right for those. Below 840
   the card is 268-388px and 40px would leave 188px of content, so it stays 24.
   840 is where both grid families reach two columns. Both values are existing
   tokens - 32px would be a new one, and the primitives are locked. */
/* SQUARE CORNERS AND 32px ARE ERIE'S CARD GEOMETRY, adopted deliberately and
   WITHOUT its figure/ground system - no grey ground, no borderless cards. The
   border and the lift below stay; the band rhythm above stays. Erie's cards read
   as panels because they sit on grey, and King has no grey to sit on.
   32px IS A NEW VALUE AND THE PRIMITIVES ARE LOCKED. It is added as a documented
   exception, not an oversight: the scale steps 24 -> 40 with nothing between, and
   the instruction was to raise the small end. Desktop was ALREADY 40 - above
   Erie's 32 - so this raises the mobile end to 32 and leaves 40 where the card is
   wide enough to carry it. Applying 32 everywhere would have REDUCED desktop. */
@media (min-width:840px){ .card{padding:var(--space-lg)} }
.card h3{margin-top:0}
/* Card links: a deliberate affordance, not an underlined string of body text.
   The blanket a{text-decoration:underline} is right for links sitting INSIDE
   prose, where an underline is the only thing distinguishing them. In a card
   the link is already the last line, already bold, already the only
   interactive element - the underline adds noise and reads as unstyled.
   Affordance carries on weight + colour + an arrow that moves on hover. */
/* AFFORDANCE. These read as gold body text rather than links: no underline,
   and the arrow was the only signal. width:max-content stops the anchor
   stretching to its container, which was parking the arrow far from the words
   it belongs to. The underline is the actual fix - an underline is what says
   "link" to someone who is not looking for one. */
/* fit-content, NOT max-content. max-content is the unwrapped intrinsic width,
   which is right for a short signpost and catastrophic for the .faq-teaser
   sentence: measured 867px inside a 350px column, which gave /roofing/,
   /siding/, /gutters/ and /storm-damage/ a horizontally scrolling page on
   every phone. fit-content is min(max-content, available), so a short link
   still hugs its text and a long one wraps. */
.card-link{display:inline-flex;align-items:center;gap:6px;width:fit-content;
  margin-top:var(--space-sm);font-weight:600;
  text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px;
  color:var(--accent-deep);transition:gap .15s ease,color .15s ease}
.card-link::after{content:"";width:7px;height:7px;flex:0 0 auto;
  border-right:2px solid currentColor;border-bottom:2px solid currentColor;
  transform:rotate(-45deg);transition:transform .15s ease}
.card-link:hover{gap:10px;color:var(--text-color);text-decoration-thickness:2px}
.card-link:active{color:var(--accent-border)}
/* Underline returns on keyboard focus: the ring alone is not enough signal
   when the surrounding text is also dark and bold. */
.card-link:focus-visible{text-decoration:underline;text-underline-offset:3px}
.section-alt .card{background:#fff}

/* ---------- icons ----------------------------------------------------------
   ONE source, ONE stroke weight, ONE size. Before this there were three
   unrelated icon hacks in this sheet: a clip-path polygon tick, a clip-path
   polygon cross, and a rotated-border caret. None shared a weight, a size or a
   grid, and none could be reused.

   All icons are 24x24, fill:none, stroke-width 1.75, round caps and joins, and
   are applied as MASKS so the colour comes from the element (background) rather
   than being baked into the asset. Adding an icon means adding one token here
   and nothing else. Geometry only - no brand values, so these belong in this
   sheet rather than variables.css. */
:root{
  --icon-size:20px;
  --icon-check:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5l5 5L20 6.5'/%3E%3C/svg%3E");
  --icon-x:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 6l12 12M18 6L6 18'/%3E%3C/svg%3E");
  --icon-chevron-down:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9.5l6 6 6-6'/%3E%3C/svg%3E");
  --icon-shield:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l7.5 3v5.5c0 4.4-3.1 8-7.5 9.5-4.4-1.5-7.5-5.1-7.5-9.5V6z'/%3E%3C/svg%3E");
}
.icon{display:inline-block;flex:0 0 auto;
  width:var(--icon-size);height:var(--icon-size);background:currentColor;
  -webkit-mask-size:contain;mask-size:contain;
  -webkit-mask-position:center;mask-position:center;
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}
.icon-check{-webkit-mask-image:var(--icon-check);mask-image:var(--icon-check)}
.icon-x{-webkit-mask-image:var(--icon-x);mask-image:var(--icon-x)}
.icon-shield{-webkit-mask-image:var(--icon-shield);mask-image:var(--icon-shield)}

/* ---------- lists ---------- */
/* Markers are the icon set above, masked, so a tick in a list and a tick
   anywhere else are the same artwork at the same weight. */
.check-list,.x-list{list-style:none;padding-left:0}
.check-list li,.x-list li{position:relative;
  padding-left:calc(var(--icon-size) + var(--space-xs) + 2px)}
.x-list li{color:var(--text-muted)}
.check-list li::before,.x-list li::before{content:"";position:absolute;left:0;
  top:calc((var(--body-line-height) * 1em - var(--icon-size)) / 2);
  width:var(--icon-size);height:var(--icon-size);
  -webkit-mask-size:contain;mask-size:contain;
  -webkit-mask-position:center;mask-position:center;
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}
.check-list li::before{background:var(--accent-deep);
  -webkit-mask-image:var(--icon-check);mask-image:var(--icon-check)}
.x-list li::before{background:var(--text-muted);
  -webkit-mask-image:var(--icon-x);mask-image:var(--icon-x)}

/* ---------- faq ---------- */
.faq-item{border-bottom:1px solid var(--border-color);padding:var(--space-sm) 0}
.faq-item h3{font-size:var(--fs-lg);margin-bottom:var(--space-xs)}
.faq-item p:last-child{margin-bottom:0}

/* ---------- forms + consent ---------- */
.form-card{background:var(--bg-color);border:1px solid var(--border-color);
  border-radius:var(--btn-radius);padding:var(--space-md)}
.field{margin-bottom:var(--space-sm)}
.field label{display:block;font-weight:600;font-size:var(--fs-sm);margin-bottom:6px}
.field input,.field textarea,.field select{width:100%;padding:11px 12px;
  border:1px solid var(--border-color);border-radius:var(--btn-radius);
  font-family:var(--body-font);font-size:var(--fs-sm);background:#fff;color:var(--text-color)}
.field textarea{min-height:96px;resize:vertical}
.field-required{color:var(--text-muted);font-weight:400}
.hp-field{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}

/* Consent block. Sized so both checkboxes AND their full disclosure text fit in
   one 1280x800 viewport with no scrolling. That screenshot is the artifact that
   approves the A2P campaign, so this is an acceptance criterion, not styling. */
.consent-block{margin-top:var(--space-sm);padding-top:var(--space-sm);
  border-top:1px solid var(--border-color)}
.consent-item{display:flex;gap:10px;align-items:flex-start;margin-bottom:12px}
.consent-item input[type="checkbox"]{flex:0 0 auto;width:20px;height:20px;margin:2px 0 0;
  accent-color:var(--accent-color)}
.consent-item label{font-size:var(--fs-fine);line-height:1.45;color:var(--text-color)}
.consent-note{font-size:var(--fs-fine);line-height:1.45;color:var(--text-muted);margin:0}
.form-status{margin-top:var(--space-sm);padding:12px;border-radius:var(--btn-radius);
  font-size:var(--fs-sm);display:none}
.form-status.is-error{display:block;background:#fdecea;border:1px solid #d93025;color:#8c1d16}
.form-status.is-ok{display:block;background:#e9f6ec;border:1px solid #1e7e34;color:#155724}

/* ---------- footer ---------- */
/* NO margin-top. The footer already carries section-y-lg of PADDING, which is
   inside its dark background. The margin sat outside it, so between the dark
   CTA band and the dark footer it rendered as a band of page background - a
   white stripe across the bottom of every page. Padding separates; margin
   here just leaked the body colour between two dark blocks. */
.site-footer{background:var(--primary-dark);color:#e8e8e8;
  padding:var(--section-y-lg) 0 var(--space-md)}
/* Footer links: same reasoning as .card-link. These are navigation in a list,
   not links inside a sentence, so the underline is doing no work and a column
   of underlined items reads as unstyled markup. Applied uniformly, legal links
   included: they sit in a labelled <nav aria-label="Legal">, so they are
   discoverable by position, and the underline returns on hover and focus. */
.site-footer a{color:#e8e8e8;text-decoration:none}
.site-footer a:hover{color:var(--accent-color);text-decoration:underline;
  text-underline-offset:3px}
.site-footer a:active{color:#fff}
.site-footer a:focus-visible{text-decoration:underline;text-underline-offset:3px}
.footer-grid{display:grid;gap:var(--space-lg);grid-template-columns:1fr}
.footer-heading{font-size:var(--fs-meta);letter-spacing:.08em;text-transform:uppercase;
  color:var(--accent-color);font-family:var(--body-font);font-weight:600;
  margin-bottom:var(--space-sm)}
.footer-list{list-style:none;padding-left:0;margin:0}
.footer-list li{margin-bottom:10px}
.site-footer p{margin:0 0 8px;font-size:var(--fs-sm)}
.footer-legalname{font-weight:600;color:#fff}
.footer-hours{color:#bdbdbd}
.footer-bottom{margin-top:var(--space-lg);padding-top:var(--space-md);
  border-top:1px solid rgba(255,255,255,.15)}
.footer-legal{display:flex;flex-wrap:wrap;gap:var(--space-sm);margin-bottom:var(--space-sm)}
.footer-legal a,.footer-legal .link-button{font-size:var(--fs-meta);color:#bdbdbd}
.copyright{font-size:var(--fs-meta);color:#9a9a9a;margin:0}
/* THE FOOTER CENTRES ON MOBILE. Restored 2026-08-30 at Ted's call, after being
   removed earlier the same day under a blanket "nothing centres on mobile".
   That blanket still holds for the page body; the footer and the areas block
   are the two named exceptions, so this is a scoped reversal rather than the
   rule being abandoned - see the body reset further down, which is unchanged.

   The img rule is not optional decoration: an <img> is display:block here, so
   it centres by auto margins whatever text-align says. Without it the logo
   would sit hard left inside an otherwise centred footer. */
@media (max-width:767px){
  .site-footer{text-align:center}
  .footer-legal{justify-content:center}
  .site-footer img{margin-inline:auto}
  /* The gap above the logo was the footer's OWN padding-top and nothing else -
     measured, no stray margin on the container, the grid, the cell or the img.
     It read --section-y-lg, so it was still 44px on a phone directly under the
     cta_band's own bottom padding, and both bands are black so the two sum into
     one dead run. --space-md keeps a visible boundary without the footer
     opening with a void. */
  .site-footer{padding-top:var(--space-md)}
}

/* ---------- sticky mobile call bar ---------- */
.sticky-call{position:fixed;left:0;right:0;bottom:0;z-index:120;
  display:flex;flex-direction:column;align-items:center;justify-content:center;
  gap:2px;padding:10px 16px;background:var(--accent-color);color:#000;
  text-decoration:none;font-weight:600;box-shadow:0 -2px 10px rgba(0,0,0,.18)}
.sticky-call-label{font-size:var(--fs-meta);text-transform:uppercase;letter-spacing:.06em}
.sticky-call-number{font-size:var(--fs-base)}
/* THE BAR IS REMOVED WHILE THE MENU IS OPEN. It is a fixed sibling of the
   header at z-index 120; the header is a stacking context at 100, so the open
   nav is trapped underneath and cannot be lifted over it by any z-index. The
   only fix at the cause is to take the bar out of the way. main.js puts
   .nav-open on body, not on the nav, because the bar is outside the nav. */
body.nav-open .sticky-call{display:none}
/* Room for the sticky call bar, which is display:none from 992 up - so the
   padding is scoped to the widths that actually have the bar. Unscoped it left
   64px of blank page under the footer on desktop.

   THE VALUE IS THE MEASURED BAR, not a round number. Below 768 the bar is a
   COLUMN: 10 padding + label (--fs-meta 14 x 1.6 = 22.4) + 2 gap + number
   (--fs-base 18 x 1.6 = 28.8) + 10 padding = 73.2. The old 64 under-reserved
   by 9.2px, so the bar covered the last ~9px of the footer on every phone.
   From 768 the bar turns row (see below) and measures 48.8, so 74 is generous
   there rather than wrong, and one value beats a second breakpoint. */
body{padding-bottom:74px}
/* The 992 reset lives with the .sticky-call display:none rule below, so the
   padding and the thing it makes room for stay together. */

/* ---------- breakpoints ---------- */
@media (min-width:480px){
  .hero{padding:var(--space-xl) 0}
}
/* CARD GRID THRESHOLDS, set per grid from what its cards actually hold.

   .grid-2 carries CHECK LISTS (what we install / what we do not do), and a
   list item needs roughly 390px before it stops ragging to two or three words
   per line - so two-up starts at 840, not the 480 it used to, which produced
   measured cells of 218px.

   .grid-3 and .grid-4 carry short spec and link cards - a bold label and one
   sentence - which read fine at ~270px. They can go multi-column much earlier,
   and .grid-4 needed to: it stayed a single column to 992 and gave every
   tablet an eight-card vertical stack. */
@media (min-width:600px){
  .grid-3,.grid-4{grid-template-columns:repeat(2,1fr)}
}
@media (min-width:840px){
  .grid-2{grid-template-columns:repeat(2,1fr)}
}
/* NO THREE-COLUMN RULE, AND NO FOUR. Four narrow cards is the cheap tell; two
   large ones is the premium one, which is the whole point of the reference.
   Everything now tops out at the two-column rule above.
   THE CLASS NAMES ARE HISTORICAL: .grid-3 and .grid-4 both render TWO columns
   now. They are kept because renaming means touching ten call sites in
   pages.py for no rendered difference - but do not read the number as a
   promise. The old rules were `.grid-3 -> 3 at 900` and `.grid-4 -> 4 at 992`,
   both deleted 2026-08-30. */
@media (min-width:768px){
  h1{font-size:var(--h1-size-tablet)}
  h2{font-size:var(--h2-size-tablet)}
  .section{padding:var(--section-y-lg) 0}
  .footer-grid{grid-template-columns:repeat(2,1fr)}
  .sticky-call{flex-direction:row;gap:10px}
  /* The split used to collapse at 992, so every 768-991 viewport got a
     full-width left-aligned column of prose with a 420px frame centred under
     it - two different horizontal axes in one section, which is what read as
     "everything moves left" on a narrowed window. 768 is wide enough for two
     ~352px tracks, so the tablet band now matches desktop instead. */
  .split{display:grid;grid-template-columns:1.05fr .95fr;gap:var(--space-xl);align-items:start}
  /* Contact is the exception: its form column is pinned to 600px, which does
     not fit in half of a 768-991 container. It stays stacked until 992. */
  .page-contact .split{display:block}
}
@media (min-width:992px){
  h1{font-size:var(--h1-size)}
  h2{font-size:var(--h2-size)}
  .brand img{width:180px}
  /* header grows with the logo above; keep the anchor offset with it */
  [id]{scroll-margin-top:136px}
  .menu-toggle{display:none}
  .site-nav{display:block;position:static;border:0;background:none}
  .nav-links{display:flex;gap:var(--space-md);padding:0}
  .nav-links a{padding:0;font-size:var(--fs-sm)}
  .nav-cta{display:inline-block}
  /* A NAV ITEM AND A PHONE NUMBER NEVER BREAK MID-LABEL.
     The horizontal nav starts at 992, where eight links, a 180px logo and the
     CTA have to share the row. "Storm Damage" and "Service Areas" were folding
     onto two lines and the CTA was breaking "(800) 420-" / "2134", which reads
     as a broken header rather than as a tight one. nowrap makes the row's real
     width honest; the band rule below is what makes that width fit. */
  .nav-links a,.nav-cta{white-space:nowrap}
  .hero{padding:var(--space-2xl) 0}
  .footer-grid{grid-template-columns:repeat(4,1fr)}
  /* Sticky bar is a mobile affordance. Desktop has the header CTA. */
  .sticky-call{display:none}
  body{padding-bottom:0}
}
/* THE TIGHT BAND, 992 TO 1279. The header carries a fixed logo, eight links and
   a phone CTA; between those widths the row is genuinely short of space, and
   with nowrap applied above it would overflow instead of wrapping. So the row
   is made narrower rather than allowed to break: a smaller logo, the small gap
   token, and the meta type size for the links. The CTA keeps its own size -
   it is the one element in the header that has to stay tappable and legible,
   and shrinking a phone number to buy 12px is the wrong trade. */
@media (min-width:992px) and (max-width:1279.98px){
  .brand img{width:148px}
  .nav-links{gap:var(--space-sm)}
  .nav-links a{font-size:var(--fs-meta)}
}
/* ---------- section entry -------------------------------------------------
   Sections lift and fade in as they arrive. The move traces to weiklesroofing,
   where the images are laid in on scroll; this is the restrained version of
   that - opacity and a 16px rise, nothing parallaxed and nothing that moves
   while the visitor is reading.

   THE DEFAULT IS VISIBLE, NOT HIDDEN, and that is the load-bearing part. The
   hidden state is applied by JS only after it has confirmed it can observe and
   reveal, so a visitor with JS off, a failed script, or an IntersectionObserver
   that never fires sees the page exactly as it is now rather than a blank
   column. A reveal that fails closed hides the content it was decorating.

   The reduce block below already kills transitions globally with !important, so
   a reduced-motion visitor gets the end state with no animation - the .is-in
   class still lands, it just does not tween. */
.reveal{opacity:0;transform:translateY(16px);
  transition:opacity .5s ease,transform .5s ease}
.reveal.is-in{opacity:1;transform:none}

@media (prefers-reduced-motion:reduce){
  *{transition:none !important;animation:none !important;scroll-behavior:auto !important}
  /* Belt and braces: if the class is applied and the transition is suppressed,
     the element must still be visible rather than frozen at opacity 0. */
  .reveal{opacity:1;transform:none}
}

/* ---------- imagery ---------- */
.hero-media{margin-top:var(--space-lg)}
/* Fixed hero ratio. Without it the photo's own proportions drove the hero
   height, so a landscape team shot sat at 200px while a tall shingle close-up
   stretched to 461px and swamped the copy beside it. 3:2 matches the majority
   of the library (1.36-1.50) and cover crops the rest rather than letterboxing
   them. */
.hero-media img{border-radius:var(--btn-radius);aspect-ratio:3/2;object-fit:cover}
/* THE OPT-OUT, for a source WIDER than the box. Same shape as
   .page-figure.figure-natural below, and for the same reason: cover crops what
   does not fit rather than letterboxing it, so a 1.78 source in a 1.5 box loses
   15.94% of its WIDTH off the two sides. On team-indoor, a six-person group
   portrait, that is the person at each end of the row.
   Applied by hero_natural_cls() off the registered dimensions, never by hand.
   The 3/2 above is NOT touched: sixteen other slots sit between 1.3605 and
   1.5000 and every one of them would be re-cropped by moving it. */
.hero-media.hero-natural img{aspect-ratio:auto}
/* Lead figure on a page whose photo is a detail shot: capped to the prose
   measure above it so the two share a left edge and the picture does not
   dominate the section. */
/* SERVICE LEAD PHOTO: bigger, and centred under the copy above it.
   720 rather than the 420 a frame gets elsewhere, because here the photo is
   the section rather than a companion to a column of text.

   Written as .frame-col.lead-frame (0,2,0) on purpose: .frame-col and its
   min-width:992px margin-inline:0 override both sit LATER in this file, so an
   equal-specificity .lead-frame rule would lose on source order. */
/* .page-figure sets the `margin` SHORTHAND, which resets margin-inline to 0,
   and it appears later in this file - so an equal-specificity .lead-figure
   rule lost and the photo hugged the left edge. Compound selector wins
   regardless of order. */
.page-figure.lead-figure{max-width:720px;margin-inline:auto}
.lead-figure figcaption{text-align:center}
/* Caption under the framed lead photo. Matches .page-figure figcaption so a
   framed picture and a plain one caption identically. */
.frame-col.lead-frame{max-width:720px;margin-inline:auto;
  margin-top:var(--space-md)}
.frame-caption{margin:var(--space-xs) 0 0;font-size:var(--fs-meta);
  color:var(--text-muted);text-align:center}
.page-figure{margin:var(--space-md) 0}
/* aspect-ratio box + cover. These had NO object-fit, so they defaulted to
   fill, and were only undistorted because display ratios happened to match the
   naturals (576x423 and 576x874 in the same row). The next upload that does
   not match would have stretched. Fixing the class rather than re-cropping
   sources, because a re-crop is undone by the next upload. */
.page-figure img{width:100%;aspect-ratio:4/3;object-fit:cover;
  border-radius:var(--btn-radius)}
.page-figure figcaption{margin-top:var(--space-xs);font-size:var(--fs-meta);color:var(--text-muted)}
/* Opt out of the 4/3 box for an image whose own proportions carry meaning.
   The service-area map is 1.06:1, so cover-cropping it to 4/3 would slice the
   top and bottom off the drawn boundary - which is the only reason the image
   is on the page. Anything with a natural_ratio flag in images.assets uses
   this. */
.page-figure.figure-natural img{aspect-ratio:auto}
.figure-grid{display:grid;gap:var(--space-md);grid-template-columns:1fr}
.figure-grid .page-figure{margin:0}
@media (min-width:768px){.figure-grid{grid-template-columns:repeat(2,1fr)}}
@media (min-width:992px){
  /* Two children only: .hero-copy and EITHER .hero-media or the form block.
     Anything else added directly to .hero .container becomes a third grid cell
     and breaks the column split - that is exactly how this was broken before.
     The full-bleed .hero-bg is deliberately NOT a child of .container for this
     reason; it hangs off .hero, so it is never one of these grid items. */
  .hero .container{display:grid;grid-template-columns:1.05fr .95fr;gap:var(--space-xl);align-items:center}
  .hero-media{margin-top:0}
  /* The form column is a FIXED 500, not a fraction: the reserve height in
     config is measured at exactly 500 and a fluid column would invalidate it
     at every viewport width. The copy takes whatever is left. */
  .hero-with-form .container{grid-template-columns:1fr 500px}
  /* Photo out of the grid and behind everything, with a left-weighted scrim so
     the copy keeps its contrast over a bright sky. object-position is
     left-biased because the form card covers the right half. */
  /* margin:0, NOT margin-top:0. The base .hero-bg rule carries
     margin-bottom:var(--space-lg) for the stacked layout, where the photo is
     in normal flow and needs separating from the copy below it. Here the
     element is absolutely positioned with inset:0, and an abspos box with both
     top and bottom resolved and height:auto SUBTRACTS its margins from the
     used height - so that 40px pulled the video's bottom edge 40px up from the
     hero's, exposing a 40px band of --primary-dark under the video on desktop.
     Reported as empty black space at the bottom of the hero.
     The original override reset the top and left the bottom, which is why the
     band was at the bottom rather than the top. Resetting the shorthand closes
     both sides and cannot half-reset again. */
  .hero-with-form .hero-bg{position:absolute;inset:0;z-index:0;margin:0}
  .hero-with-form .hero-photo{width:100%;height:100%;aspect-ratio:auto;
    object-fit:cover;object-position:25% center}
  /* Above the still, below the scrim. Same object-position as the photo so
     the poster and the first video frame land on the identical crop and the
     handover is invisible. */
  .hero-with-form .hero-video{display:block;position:absolute;inset:0;
    width:100%;height:100%;object-fit:cover;object-position:25% center}
  /* MEASURED, NOT EYEBALLED. The left stop was .94 - 94 percent black over a
     black panel, so roughly six percent of the photograph survived where the
     copy sits, which is why the hero read as murk no matter what the source
     was. That was not a contrast requirement, it was overshoot: at .94 the
     lede measured 9.77:1 against a 4.5 requirement, more than double.

     Landed on .80 by sampling the ACTUAL composited pixels behind the text -
     source through object-fit:cover at object-position 25%, then the gradient,
     then the worst (lightest) pixel in the measured h1 and lede rectangles -
     across all 19 unique frames of the clip, not against black and not against
     a mean.

       left stop   lede CR (video)   lede CR (photo A)
         .86            6.26               7.79
         .82            5.35               6.73
         .80            4.95               6.26   <- here
         .78            4.59               5.83
         .76            4.27               5.43   FAIL

     THE LEDE IS THE BINDING CONSTRAINT, not the h1: at 45px the h1 is WCAG
     large text and needs 3:1, while the lede at 20px is normal text and needs
     4.5:1. Sizing the scrim to the headline would have shipped a lede that
     fails. At .80 the h1 measures 5.55:1 and the lede 4.95:1.

     .78 also passes but by two percent, and the background MOVES - a frame
     between the ones sampled could cross it. .80 keeps ten percent of margin
     on video and considerably more on photo A, which is the fallback source if
     the NJ answer comes back no. Re-measure if either source changes. */
  .hero-with-form .hero-scrim{display:block;position:absolute;inset:0;
    background:linear-gradient(90deg,
      rgba(0,0,0,.80) 0%, rgba(0,0,0,.72) 34%,
      rgba(0,0,0,.50) 62%, rgba(0,0,0,.36) 100%)}
}

/* ===========================================================================
   BRAND FRAMES
   Derived from the crown in the logo, which is three house silhouettes with
   pitched rooflines. Every element below is CSS or inline SVG: no new assets,
   no extra requests, no image weight.
   =========================================================================== */

/* --- 1. house-cut photo. The signature treatment. -------------------------
   Matched against his real asset (Union-1.png on the live site), not built
   from a description. Measured from that file:
     roof/wall junction  49.8% of height
     eave overhang       walls inset 7.2% of width, so the roof OVERHANGS
     bottom corners      radius ~6.4% of width
   The overhang is the part that matters: without it the shape reads as a
   pentagon crop rather than a house.

   Two elements:
     a) the photo, masked to the house silhouette
     b) OPTION E2 - a full white outline on that same silhouette, replacing the
        roofline-only chevron that shipped before it.

   The frame carries a fixed aspect ratio so the overlay SVG can use a matching
   viewBox and keep genuinely round caps and joins, rather than the ellipses
   preserveAspectRatio="none" would produce. */
/* ONE silhouette, declared once. The photo mask and the E2 outline both read
   --house-mask, so the geometry cannot drift between them. This path is byte-
   identical to HOUSE_PATH in pages.py; test_house_path_is_shared asserts it. */
.frame-house{position:relative;display:block;aspect-ratio:1240/954;
  padding-bottom:0;
  --house-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1240 954' preserveAspectRatio='none'%3E%3Cpath fill='white' d='M620 0L1240 475L1150.7 475L1150.7 875A79 79 0 0 1 1071.7 954L168.3 954A79 79 0 0 1 89.3 875L89.3 475L0 475Z'/%3E%3C/svg%3E")}
/* The silhouette is a MASK, not clip-path + border-radius. The earlier pairing
   was inert: the walls inset 7.2% of width (89.3px) and the corner radius is
   6.4% of width (79.4px), so every pixel border-radius would have rounded had
   already been removed by the polygon. The rendered bottom corners were square.
   An SVG path carries the arcs inside the same geometry that cuts the walls, so
   the rounding survives. preserveAspectRatio='none' is safe here only because
   .frame-house pins aspect-ratio:1240/954, so the scale is always uniform. */
.frame-house img,
.frame-house .roof-chevron{
  -webkit-mask-image:var(--house-mask);mask-image:var(--house-mask);
  -webkit-mask-size:100% 100%;mask-size:100% 100%;
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
  mask-mode:alpha}
.frame-house img{width:100%;height:100%;object-fit:cover}
.frame-house .roof-chevron{position:absolute;inset:0;width:100%;height:100%;
  pointer-events:none}
/* E2 outline. TWO deliberate choices, both of which have already failed once:

   1. stroke is --frame-outline, a FIXED value, never a background token. The
      old roofline chevron stroked var(--bg-color) and switched to var(--bg-alt)
      inside .section-alt so the background appeared to slice the roofline. A
      full outline that inherited that would go grey on the grey panels and
      vanish - the exact failure this replaces. There is deliberately no
      .section-alt override below. Do not add one.

      It is WHITE, and the reason is choice 2 below. Because the stroke is
      clipped inside the silhouette it never renders anywhere except on top of
      the photograph, so "does it survive the panel colour" is the wrong
      question. The right one is whether it reads against roofing photography:
      dark shingle, dark asphalt, shadowed eaves. White is the strongest
      contrast available on that subject. Ink reads well on light photos and
      disappears on precisely what this company sells. White also matches the
      roofline stroke over the hero photo, so the site carries one stroke
      colour rather than three.

   2. the stroke is CLIPPED to the inside of the silhouette, by the same mask
      the photo uses. An unclipped stroke is centred on the path, so half of it
      renders OUTSIDE the silhouette: invisible on white sections, a white halo
      on .section-alt, and a 27px enlargement of the measured eave overhang in
      both. Clipping keeps the outer bound exactly where Union-1.png put it and
      makes the frame render identically on every background. Only the inner
      half survives, so the DECLARED width is double the visible band.

      72 lands a visible 36, which is 2.9% of frame width. It was 108 (visible
      54, 4.35%). The band is declared in viewBox units, so its SHARE of the
      frame is fixed at every rendered size: enlarging the frame gives no
      photograph back, only stroke-width does. At the 276px this actually
      renders at, 4.35% read as an icon rather than as a photograph. */
.frame-house .roof-chevron path{fill:none;stroke:var(--frame-outline);
  stroke-width:44;stroke-linecap:round;stroke-linejoin:round}

/* --- 2. roofline over a rounded rect. His About / Trusted Experts pattern. --- */
.frame-roofline{position:relative;display:block}
.frame-roofline img{border-radius:6px;width:100%;display:block}
.frame-roofline .roofline{position:absolute;top:-1px;left:50%;transform:translateX(-50%);
  width:78%;height:auto;pointer-events:none}
.frame-roofline .roofline path{fill:none;stroke:#fff;stroke-width:14;
  stroke-linecap:square;stroke-linejoin:miter;vector-effect:non-scaling-stroke}
.section-dark .frame-roofline .roofline path,
.hero .frame-roofline .roofline path{stroke:var(--bg-color)}

/* --- 3. asymmetric composition: large photo, smaller overlapping its lower
       left, offset solid gold bar behind. The layering is what reads as
       art-directed rather than stacked. --- */
/* margin:0 IS LOAD-BEARING. <figure> carries a UA default of `margin:1em 40px`,
   and nothing here had ever reset it, so the collage AND its caption sat 40px
   inside their column at every width while the heading and stats beside them
   sat on the container edge. That is the misalignment, and it was invisible in
   review because 40px of indent reads as deliberate art direction. */
/* THE COLLAGE HAS A WIDTH OF ITS OWN. Added 2026-08-30.
   ---------------------------------------------------------------------------
   It had none, so it took whatever its parent gave it and the same component
   rendered 2.4x apart: 459px of main image inside the home .split column, 1088
   inside a bare .container on every city page and /about/. Nothing chose 1088 -
   it is the container's content width minus the 52px offset, inherited.

   Two things show it was never tuned for that size. The art direction is FIXED
   PX: .compose-bar is 34px, which is 7.4% of the main image at 459 and 3.1% at
   1088, so the offset-bar composition the component exists to make is more than
   halved in relative terms. And .container.measure deliberately caps its <p>
   children at 70ch while capping nothing else, so on /about/ the prose sits at
   a reading measure and the collage escapes to full width beside it - the
   measure rule was written for text and the figure slipped through it.

   720px is not a new number: .frame-col.lead-frame already caps at 720 in this
   file, so the two large-figure treatments now agree. It leaves the collage on
   the container's left edge, in line with the prose above it, and it is what
   lets the widest derivative be ~150KB instead of 411KB. */
/* THE FOURTH TIME THIS BUILD HAS BEEN BITTEN BY A CAPPED BLOCK BOX WITH ZEROED
   INLINE MARGINS. See .page-figure.lead-figure above, whose comment records the
   same failure: a `margin` SHORTHAND resets margin-inline to 0, the box is
   narrower than its container, and it hugs the left edge. text-align on an
   ancestor cannot move it, because a block box is not inline content.

   HERE: .compose is capped at 720px and sat in a .container whose content is
   1140px at any viewport from 1244 up, so 420px of empty space accumulated on
   the RIGHT. The cap binds from a 824px viewport, which is the width at which
   container content first reaches 720 - the compose() docstring already states
   that number for its `sizes` attribute, so the geometry was known and only
   the centring was missed.

   TEN PAGES were affected: /about/ and the nine city pages, where .compose is a
   direct child of .container. The homepage is NOT, and never was: there
   .compose is a grid item inside .split from 768 up, so it fills its ~511px
   column and the 720 cap never binds. That is why this only ever showed on the
   pages nobody was looking at while the homepage read as correct.

   REPLACING THE PHOTOGRAPH DOES NOT FIX THIS. The off-centring is the block
   geometry of the figure, not the aspect ratio of anything inside it. A native
   3:2 asset fills .compose-main exactly and leaves the figure sitting just as
   far left as before. */
.compose{position:relative;margin:0 auto;padding:0 0 var(--space-xl) 0;max-width:720px}
.compose-bar{position:absolute;left:0;top:8%;width:26px;height:34%;
  background:var(--accent-color);z-index:0}
.compose-bar-2{position:absolute;left:0;top:46%;width:44px;height:18%;
  background:var(--accent-color);opacity:.85;z-index:0;
  clip-path:polygon(0 0,100% 0,100% 78%,0 100%)}
.compose-main{position:relative;z-index:1;margin-left:38px}
.compose-main img{border-radius:6px;width:100%}
.compose-inset{position:relative;z-index:2;width:62%;margin:-18% 0 0 0;
  border:6px solid var(--bg-color);border-radius:6px;overflow:hidden;
  box-shadow:0 6px 22px rgba(0,0,0,.14)}
.compose-inset img{width:100%;display:block}
/* NO INSET, NO RESERVE. .compose's bottom padding exists solely to hold the
   inset's negative-margin overhang; with no inset it is dead space under the
   caption. Same measurement argument as the mobile reserve already recorded in
   BUILD-STATE - the overhang is what the padding is for, so when the overhang
   is gone the padding is not "spacing", it is a gap.

   Compound selector (0,2,0) rather than an equal-specificity rule, because the
   768 and 992 blocks below both re-set .compose padding-bottom and would win on
   source order. Exactly the trap .page-figure.lead-figure documents.

   Emitted by compose() whenever inset_key is None, so it covers the two city
   pages that already lost their inset when roof-vent-boot-detail was withdrawn
   (willow-street and bucks-county) and have been carrying the gap since. */
.compose.compose-solo{padding-bottom:0}
.section-alt .compose-inset{border-color:var(--bg-alt)}
/* ONE HORIZONTAL AXIS PER SECTION. Centring is right when .compose stands
   alone in its container, which is the nine city pages - there is nothing for
   it to line up with. It is WRONG on /about/, where the figure follows prose:
   .container.measure > p is capped at 70ch and starts at x=0, so a centred
   720px figure starts at x=210 and its photo, past the 52px bar gutter, at
   x=262. Text on one axis and pictures on another, with the corner under the
   text empty - which reads as a two-column layout that failed to engage.

   IT IS NOT ONE. There is no grid in that section and there never was: the
   only .split on /about/ is under "Where we work from". The story section is a
   plain block container, so nothing failed to apply.

   This is the same failure the .split comment above already records for the
   768-991 band - "a full-width left-aligned column of prose with a 420px frame
   centred under it - two different horizontal axes in one section". Same shape,
   different section.

   Scoped by the parent rather than by a modifier class because the parent IS
   the condition: .container.measure is the layout that carries prose. City
   pages use a bare .container, the homepage puts .compose inside .split, so
   neither matches. */
.container.measure > .compose{margin-inline:0}
/* The caption describes BOTH photographs, so it lines up with the MAIN image
   rather than with the inset. Left at 0 it started under the inset's left edge,
   90px below the main and immediately below the inset, which reads as the
   inset's caption. Matching .compose-main's gutter puts it under the pair.
   The 768 breakpoint is not arbitrary: it is where .compose-main takes its
   52px margin, so the two move together by construction. */
.compose figcaption{margin-top:var(--space-sm);font-size:var(--fs-meta);color:var(--text-muted)}
@media (min-width:768px){.compose figcaption{margin-left:52px}}

/* MOBILE: the collage sits ON the container edge, in line with the heading and
   the stat row, and the offset composition is retired rather than shrunk.
   The 38px indent exists to expose the gold bars behind the photo; at 390 that
   indent is the only thing breaking the section's left edge, and the bars have
   nowhere to sit once the photo is flush. Hiding them is honest - the
   asymmetric bar treatment is a desktop composition, not a thing to miniaturise.

   padding-bottom goes to 0 because it reserves room for the inset's overhang,
   and at this width the inset does NOT overhang: measured at 390 its bottom
   lands ABOVE the caption, so the reserve was pure dead space under the
   caption. */
@media (max-width:767px){
  .compose{padding-bottom:0}
  .compose-main{margin-left:0}
  .compose-bar,.compose-bar-2{display:none}
}
@media (min-width:768px){
  .compose-bar{width:34px;height:44%}
  .compose-main{margin-left:52px}
  .compose-inset{width:46%;margin-top:-14%}
}
@media (min-width:992px){
  .compose{padding-bottom:var(--space-xl)}
  .compose-inset{width:42%;margin-top:-16%}
}

/* --- section rhythm helpers so page types do not share one sequence --- */
.band-rule{border-top:3px solid var(--accent-color);padding-top:var(--space-md)}
.section-tight{padding-top:var(--space-md);padding-bottom:var(--space-md)}
/* space-lg above, not space-md. The gap was set when the stat values were
   --fs-display; they are now h1-size on desktop, and a 24px gap under a 45px
   number reads as crowding rather than grouping. Next step on the existing
   scale, not a hand-picked value. */
.stat-row{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-md);
  list-style:none;padding:0;margin:var(--space-lg) 0 0}
.stat-row li{margin:0}
/* Scaled up to fill the space beside the compose block. Ladder tokens only -
   a bare px literal here fails test_no_bare_font_size_literals_outside_the_ladder,
   and rightly: this is the one place on the page allowed to be big, and it
   should still come from the same scale as everything else.
   Mobile steps back to the h2 mobile size so three stats still fit a 375
   viewport without wrapping mid-number. */
.stat-row .stat-value{display:block;font-family:var(--display-font);
  font-weight:var(--display-weight);font-size:var(--h2-size-mobile);
  line-height:1;color:var(--accent-deep)}
@media (min-width:768px){.stat-row .stat-value{font-size:var(--h2-size-tablet)}}
@media (min-width:992px){.stat-row .stat-value{font-size:var(--h1-size)}
  /* A long value keeps the tablet size at desktop. Measured: "$1,000,000"
     is 225px at --h1-size against a 172px cell, so it ran into the tile
     beside it; at --h2-size-tablet it measures 150px and fits. */
  .stat-row .stat-value.is-long{font-size:var(--h2-size-tablet)}}
.stat-row .stat-label{display:block;font-size:var(--fs-sm);color:var(--text-muted);margin-top:6px}
/* auto-fit, not repeat(4): the row is called with 2, 3 and 4 items across the
   site, and a hard 4 left a dead column wherever it was called with fewer. */
@media (min-width:768px){
  .stat-row{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}}

/* --- nav submenu -------------------------------------------------------- */
.sub-menu{list-style:none;margin:0;padding:0 0 0 var(--space-md);display:none}
.has-sub.is-open>.sub-menu{display:block}
.sub-menu a{font-weight:400;font-size:var(--fs-sm);padding:9px 0}
.sub-toggle{position:absolute;right:0;top:2px;width:40px;height:40px;
  background:none;border:0;cursor:pointer;padding:0}
/* Same icon source as the lists - was a rotated-border caret on its own grid. */
.sub-toggle span{display:block;margin:0 auto;
  width:var(--icon-size);height:var(--icon-size);background:var(--text-color);
  -webkit-mask:var(--icon-chevron-down) center/contain no-repeat;
  mask:var(--icon-chevron-down) center/contain no-repeat;
  transition:transform .15s ease}
.has-sub.is-open>.sub-toggle span{transform:rotate(180deg)}
.has-sub{position:relative}
@media (min-width:992px){
  .sub-toggle{display:none}
  .has-sub>.sub-menu{display:block;position:absolute;left:0;top:100%;
    min-width:250px;padding:var(--space-xs) 0;background:var(--bg-color);
    border:1px solid var(--border-color);border-radius:var(--btn-radius);
    box-shadow:0 8px 26px rgba(0,0,0,.13);z-index:110;
    opacity:0;visibility:hidden;transform:translateY(-4px);
    transition:opacity .13s ease,transform .13s ease,visibility .13s}
  .has-sub:hover>.sub-menu,
  .has-sub:focus-within>.sub-menu{opacity:1;visibility:visible;transform:translateY(0)}
  .sub-menu li{margin:0}
  .sub-menu a{display:block;padding:8px var(--space-sm);white-space:nowrap}
  .sub-menu a:hover,.sub-menu a:focus-visible{background:var(--bg-alt)}
}

/* ---------- card interaction ----------------------------------------------
   Cards had no states at all: static rectangles that happened to contain a
   link. The whole card now responds, and :focus-within carries focus from the
   inner link out to the card, so keyboard tabbing looks like hovering. */
.card{transition:border-color .15s ease,box-shadow .15s ease,transform .1s ease}
/* HOVER RISES. The old hover (0 6px 20px / .09) had SMALLER blur and HIGHER
   alpha than the new resting shadow, so against it the card read as shifting
   sideways rather than lifting. Every layer now increases in both: blur 2->4
   and 24->40, alpha .04->.06 and .06->.10. */
.card:hover,.card:focus-within{border-color:var(--accent-border);
  box-shadow:0 2px 4px rgba(0,0,0,.06), 0 16px 40px rgba(0,0,0,.10)}
.card:active{transform:translateY(1px)}

/* ---------- chevron as section divider ------------------------------------
   His brand mark, rebuilt from the raster h2-1.png. It was rendering in
   exactly ONE place on the entire site. Used as a divider it punctuates
   section boundaries with the one motif that is unambiguously his, and costs
   no request because it is already inline SVG.

   No flanking rules: on his live homepage the chevron carries the transition
   on its own, so adding hairlines would be inventing a treatment he does not
   have. */
/* The divider's bottom padding stacks with the following .section's top
   padding, which put roughly 104px between the chevron and the heading under
   it. The chevron belongs TO that heading, so the gap below it is tightened
   and the space above kept. */
.section-divider{display:flex;justify-content:center;
  padding:var(--space-lg) 0 0;
  margin:0}
.section-divider .chevron{width:225px;margin:0}
@media (min-width:768px){.section-divider .chevron{width:300px}}

/* ---------- trust bar ------------------------------------------------------
   The PA, NJ and CT registrations, BBB and Owens Corning were scattered across
   four unrelated places and four formats. Grouped here so they read as one
   credential set. Every entry still routes through credential(), so nothing
   renders that is not verified or explicitly client-attested. Five rows since
   NJ joined on 2026-10-08, so the widest layout is five across, not four. */
.trust-bar{list-style:none;margin:var(--space-lg) 0 0;padding:var(--space-md) 0 0;
  border-top:1px solid var(--border-color);
  display:grid;gap:var(--space-md);grid-template-columns:1fr}
.trust-item{display:flex;align-items:flex-start;gap:var(--space-xs);margin:0}
.trust-item .icon{color:var(--accent-deep);margin-top:2px}
.trust-label{display:block;font-weight:600;font-size:var(--fs-sm)}
.trust-value{display:block;font-size:var(--fs-meta);color:var(--text-muted)}
@media (min-width:768px){.trust-bar{grid-template-columns:repeat(2,1fr)}}
@media (min-width:992px){.trust-bar{grid-template-columns:repeat(5,1fr)}}
.section-dark .trust-bar{border-top-color:rgba(255,255,255,.2)}
.section-dark .trust-item .icon{color:var(--accent-color)}
.section-dark .trust-value{color:#d8d8d8}

/* ---------- before and after ----------------------------------------------
   Built empty, deliberately. photo_agent_v2 already buckets into
   exterior_before / exterior_during / exterior_after, so pairs arrive as DATA.
   When they land this needs a config entry and nothing else - no build.
   Zero pairs exist today and before_after() renders nothing rather than
   shipping an empty shell, so this block is currently unused by design. */
.ba{margin:var(--space-lg) 0 0}
.ba-pair{position:relative;display:grid;grid-template-columns:1fr;
  gap:var(--space-sm);align-items:start}
.ba-side{margin:0}
.ba-side img{width:100%;border-radius:var(--btn-radius);display:block}
.ba-tag{display:inline-block;margin-bottom:var(--space-xs);
  font-family:var(--display-font);font-weight:var(--display-weight);
  font-size:var(--fs-meta);letter-spacing:.12em;text-transform:uppercase;
  color:var(--text-muted)}
.ba-side figcaption{margin-top:var(--space-xs);font-size:var(--fs-meta);
  color:var(--text-muted)}
.ba-rule{display:none}
@media (min-width:768px){
  .ba-pair{grid-template-columns:1fr 1fr;gap:var(--space-md)}
  .ba-rule{display:block;position:absolute;left:50%;top:0;bottom:0;width:1px;
    background:var(--border-color);transform:translateX(-50%)}
}

/* --- link aligned under the house frame (edit 7) --------------------------
   It used to sit in a <p> AFTER the .split, so it spanned the full
   .container.measure and left-aligned to the CONTAINER edge, while the frame
   sits in the left column with its silhouette walls inset 7.2% of width. That
   inset is why it read as "slightly left of centre" - the link was correctly
   aligned to the wrong thing.

   Now it lives inside the frame's own column and centres on the frame, so it
   tracks the frame at every breakpoint instead of needing a magic margin. */
/* The cap lives on the COLUMN, not on .frame-house. .frame-caption-link
   centres inside this element, so capping the frame while leaving the column
   full width would push the caption off-centre again - the exact defect edit 7
   fixed. margin-inline:auto matters below 992px, where .split is not a grid and
   the frame would otherwise fill a 728px tablet container. */
.frame-col{display:flex;flex-direction:column;
  margin-inline:auto;width:100%}
/* The 420 cap only applies once the frame has its own grid track. Below 768
   the split is stacked, and a 420px frame centred under a 660px column of
   left-aligned prose gives the section two horizontal axes - measured at 700:
   text L20 w660, frame L140 w420. Uncapped, the stacked frame shares the
   container's left and right edge with everything above it. */
@media (min-width:768px){.frame-col{max-width:var(--frame-max)}}
/* Stacked, the frame lands directly under the copy with only the last
   paragraph's margin above it, so the picture reads as attached to the
   sentence before it. Same shape as the .detail-mark gap. From 768 up it
   sits in its own grid track beside the copy and must stay level with the
   heading, so the margin is scoped to the stacked widths only. */
@media (max-width:767px){.frame-col{margin-top:var(--space-lg)}}
/* On desktop the frame shares a section with a heading and prose on the
   container edge, so centring it in its grid track left it hanging ~70px
   inside everything else. Centred below 992, where it is the only thing in
   its row. */
@media (min-width:768px){.frame-col{margin-inline:0}}
.frame-caption-link{margin:var(--space-sm) 0 0;text-align:center}


/* ---------------------------------------------------------------------------
   DEFERRED EMBEDS

   The placeholder reserves the embed's height so mounting it from JS does not
   move the page. It is deliberately EMPTY and unstyled: there is no button to
   press any more, so a bordered card would just be a box that looks broken for
   the split second before the iframe arrives.

   min-height is set INLINE per instance, from config, because the form and the
   map need different measured values.
   --------------------------------------------------------------------------- */
.ctl{position:relative;display:block;min-height:var(--ctl-h,420px)}
/* The FORM's reserved height is responsive, because the column width drives it:
   the long consent paragraphs re-wrap, so a narrow column is a TALLER form.
   Measured from the widget's own iFrameSizer message - 335 wide reports 1150,
   728 reports 841, 565 reports 901 - which is why tablet is shorter than
   desktop despite the smaller viewport. A flat value left 339px of dead space
   under the form at 768. Set in CSS rather than inline so the media queries can
   win; an inline custom property would beat them. */
/* Cap the form column. On /contact/ the split already gives it 565, but on the
   home page it sits in a full-width .container and would stretch to 1140 - and
   GHL centres its own 800px card inside whatever box it gets, so the fields
   ended up indented from the heading above them with dead margin either side.
   Capping the container makes the card fill it and align to the same left edge
   as everything else in the section. */
.ctl-form{--ctl-h:var(--ctl-h-mobile);max-width:600px}
/* The SHORT form (home hero + /about/). Its own class because it is a different
   form in a different column: fewer fields and a 500 cap, so the long form's
   reserve would over-reserve it and reintroduce the dead white space main.js's
   comment describes. Capped at 500 to match the fixed hero grid column exactly,
   which is the width its heights were measured at. */
.ctl-form-short{--ctl-h:var(--ctl-h-short-mobile);max-width:500px;
  margin-inline:auto}
/* CENTRED BY DEFAULT, LEFT-ALIGNED IN THE HERO. A capped block defaults to
   margin-left:0, so inside /about/'s .center-col (640 wide, centred) a 500-wide
   form sat 140px off-centre under its own centred heading. The long form hid
   this: at max-width 600 in the same 640 column there were only 40px of slack.
   The hero opts OUT because its copy is left-aligned, so the form belongs on
   that same left edge - and at >=992 the form IS the 500px grid column, where
   auto margins would be a no-op anyway. Resetting at every width rather than
   only >=992 keeps the tablet hero, where the form is capped 500 inside a 728
   container, aligned with the copy instead of drifting to the middle. */
.hero-with-form .ctl-form-short{margin-inline:0}
/* FORM EDGE LIGHT WITHDRAWN 2026-09-07, on Ted's call: it read as an outline
   around the form rather than as a rim light. Was
   `.hero .ctl{box-shadow:rgba(255,255,255,0.65) 0 0 8px 0}`, added in f464a0e
   and measured against a reference that put rgba(255,255,255,0.79) 0 0 15px 1px
   over its own #1a1a1a panel.

   Recorded rather than deleted silently, because the measurement was sound and
   the reference is still there to be ported from again. What the numbers could
   not account for is that King's .ctl has NO background of its own, so the glow
   sat directly against the hero PHOTOGRAPH - and a white rim over a photograph
   reads as a border on the form, which is the thing it was supposed not to be.
   Do not re-derive it from the reference without that in mind.

   Nothing replaces it. .ctl-frame is already border:0, so the form now has no
   edge treatment at all, which is the intent. Only the homepage was ever
   affected: it is the one page with a .ctl inside a .hero. */
/* Contact's form sits in a .split column that renders 565, while home and about
   render it at its 600 cap. Two widths mean two real heights - 901 and 881 -
   and one reserve cannot be right for both, so the column is pinned to 600 and
   the reserve is measured once. */
@media (min-width:992px){.page-contact .split{display:grid;grid-template-columns:600px 1fr}}
/* Contact's "Prefer to call" column. Its heading used to carry an inline
   margin-top:0 so it lined up with the top of the form beside it. That is
   right at 992 and up - and below 992 the column STACKS, so the zero margin
   dropped the heading straight onto the consent paragraph above it with only
   that paragraph's own margin between them. An inline style cannot be
   overridden by a media query, which is why this moved into CSS rather than
   being patched around. */
.contact-aside{margin-top:var(--space-md)}
.contact-aside h2{margin-top:0}
@media (min-width:992px){.contact-aside{margin-top:0}}

/* Home page: the intro copy and the form stack under the roofline chevron, one
   centred column. 640px keeps prose near a comfortable reading measure even
   centred, which full-width centred text is not.

   The fine print stays LEFT-aligned inside the centred column: centred small
   print is measurably harder to read, and this block has to survive a carrier's
   manual A2P review. */
.center-col{max-width:640px;margin-inline:auto;text-align:center}
/* A button following prose needs the paragraph rhythm broken; p carries a
   bottom margin only, so without this the button crowds the sentence above it.
   Matches .hero-actions' top margin so the two CTAs are spaced alike. */
.section-cta{margin-top:var(--space-md)}
.center-col + .center-col{margin-top:var(--space-xl)}
@media (min-width:768px){.ctl-form{--ctl-h:var(--ctl-h-tablet)}}
@media (min-width:992px){.ctl-form{--ctl-h:var(--ctl-h-desktop)}}
/* Base is MOBILE and the overrides step up, mirroring .ctl-form above. The
   tablet and desktop values are equal on purpose - the 500 cap is below the
   container's inner width from 768 up, so the column stops changing there and
   one measurement covers both. They are kept as two tokens so a future change
   to either breakpoint's column width has somewhere to land. */
/* FOUR MOBILE BANDS before tablet takes over. The column is viewport-40 capped
   at 500, so the form's content height swings from about 1504 to 783 across
   320-767 and a single floor cannot serve it: 1223 is right at the narrow end
   and left 447px of dead space at the wide end. Each band carries the content
   height at its WIDEST viewport, which is the shortest content that band meets,
   so the floor is never above real content and the box grows past it wherever
   content is taller. Breakpoints are the measurement points, not round numbers.
   The step back UP to 820 at 768 is not a mistake - that is the measured 500px
   column plus the original buffer, and tablet was not in scope for this pass. */
@media (min-width:390px){.ctl-form-short{--ctl-h:var(--ctl-h-short-mobile-390)}}
@media (min-width:414px){.ctl-form-short{--ctl-h:var(--ctl-h-short-mobile-414)}}
@media (min-width:480px){.ctl-form-short{--ctl-h:var(--ctl-h-short-mobile-480)}}
@media (min-width:768px){.ctl-form-short{--ctl-h:var(--ctl-h-short-tablet)}}
@media (min-width:992px){.ctl-form-short{--ctl-h:var(--ctl-h-short-desktop)}}
/* Only the no-JS fallback renders inside the placeholder. */
.ctl-note{margin:0;font-size:var(--fs-sm);color:var(--text-muted)}
/* GHL injects .ep-wrapper BETWEEN .ctl and the iframe, so the iframe's parent
   is not our container. That is why height:100% and min-height:inherit both
   failed: they resolved against .ep-wrapper, which has no height, and the frame
   fell back to the 150px iframe default. --ctl-h is a custom property set
   inline on .ctl, so it inherits through any wrapper GHL cares to insert. */
.ctl .ep-wrapper{display:block}
/* height, not min-height: when the resizer DOES fire, GHL sets an inline
   height and inline wins, so the frame fits its content exactly. When the
   resizer does not fire - which is a race we lose often - the frame still
   renders at the configured height instead of collapsing to 150px. Worst case
   is now some dead space, never a blank box. */
.ctl-frame{display:block;width:100%;height:var(--ctl-h,420px);border:0;
  border-radius:var(--btn-radius)}

/* UN-PARK. THIS IS WHY THE FORM WAS INVISIBLE ON ABOUT HALF OF LOADS.
   form_embed.js parks the iframe the moment it mounts, with the inline
   declaration:

     opacity:0; visibility:hidden; pointer-events:none;
     left:-9999px; position:absolute; overflow:auto

   and it un-parks only after ITS OWN handler records the child's resize
   message in window.resizedIframes. Measured on the deployed site, two
   consecutive loads at 1280 sat parked at 9 seconds with resizedIframes
   empty - while OUR listener had already received the same message and set
   the height to 881. The message is not being lost in transit. GHL's handler
   is simply not registered in time to see it, and once missed there is no
   retry, so the frame stays offscreen indefinitely. Ted measured one still
   parked at 57 seconds.

   The height work did not and could not fix this: the box was the correct
   881 tall the whole time it was sitting at left:-9929 with opacity 0.

   Author !important outranks a non-important inline style in the cascade, so
   these five declarations neutralise the park without JavaScript, without a
   MutationObserver, and without a race to lose. Nothing here depends on the
   resize message arriving.

   Safe because we no longer need GHL's sizing for anything: --ctl-h reserves
   a correct height from CSS before the iframe mounts, so un-parking cannot
   expose an unsized frame. It also does not interfere on the loads where GHL
   DOES un-park, because the child measures its own content and posts the
   height - the parent's position is not part of that measurement. */
.ctl .ctl-frame{
  opacity:1 !important;
  visibility:visible !important;
  pointer-events:auto !important;
  position:static !important;
  left:auto !important;
  /* THE FRAME IS SIZED BY OUR RESERVE, NOT BY GHL'S MESSAGE VALUE.
     form_embed.js writes height into the SAME inline declaration as the park
     above - measured live: "opacity:1; visibility:visible; pointer-events:auto;
     position:relative; overflow:auto; height:788px; display:block". A plain
     author rule loses to that inline every time, which is how the container sat
     at the clamped 820 while the frame sat at GHL's number with the form
     scrolling inside it.

     GHL's number is not even stable: 788 here, 781 on the deployed preview, for
     the same 783 message. It is their own measurement and it drifts, so sizing
     to it is unsafe by construction. main.js does no arithmetic on it at all.

     !important HERE rather than an inline !important from JS, deliberately:
     assigning el.style.height later CLEARS a priority flag set inline, so GHL's
     next write would take the frame back. An author !important cannot be
     overwritten by a non-important inline declaration no matter who writes last.

     Never clips, because --ctl-h is clamped in main.js to at least the measured
     reserve, and the reserve is the MAX observed height for that column. */
  height:var(--ctl-h) !important;
  /* Pairs with scrolling="no" on the element. GHL's inline sets overflow:auto,
     which is what turned a 2px shortfall into a silent scrollbar. With the
     frame pinned to the reserve there is nothing left to scroll, and any
     residual overflow now fails VISIBLY instead of hiding behind a scrollbar. */
  overflow:hidden !important;
}

/* ---------------------------------------------------------------------------
   SMS CONSENT DISCLOSURE

   Page HTML, below the embed. Not decoration: deploy_guards.check_sms_consent
   reads the raw file text for "Message and data rates", and iframe content is
   never in the file. Styled quietly but NOT hidden and NOT shrunk below the
   A2P floor - it has to survive a carrier's manual read.
   --------------------------------------------------------------------------- */
/* The .sms-consent-disclosure block was retired 2026-08-19: every required
   A2P element moved into the transactional checkbox label inside the GHL
   widget. Its rules are removed rather than left orphaned. */

/* ---------------------------------------------------------------------------
   BBB ACCREDITATION BADGE

   Built from type and the existing palette rather than carrying BBB's raster
   badge image: no extra request, no third-party asset, and it scales. Links to
   the profile the claim was verified against.
   --------------------------------------------------------------------------- */
/* THE FOOTER PREFIX IS LOAD-BEARING AND THIS RULE WAS DEAD WITHOUT IT.
   .footer-badge is a <p>, and `.site-footer p{margin:0 0 8px}` is (0,1,1)
   against this selector's (0,1,0), so the more specific rule won no matter
   which came later in the file. The badge's margin computed to 0 and had done
   since the class was written - the 8px above the seal was simply the previous
   paragraph's margin-BOTTOM. Measured before changing anything: computed
   margin-top 0px, not the var(--space-sm) the old rule appeared to set.
   --space-md. Asked for twice, because the first fix was never deployed - it
   sat behind an unrelated hold while the live preview still showed the
   original 8px, so the request read as "still tight" when nothing had shipped.
   8px -> 24px. The seal is a separate credential, not another line of the
   address block, and at 8px it collided with the hours line above it. */
.site-footer .footer-badge{margin:var(--space-md) 0 0}
/* OUR WHITE BOX IS GONE. BBB'S IS NOT, AND MUST NOT BE.
   ---------------------------------------------------------------------------
   This class was built for a TYPE-SET badge (.bbb-badge-mark + .bbb-badge-text
   below) that was later replaced by BBB's real dynamic seal image. The chrome
   stayed behind: background:#fff, a 1px border, 8/12px padding and a radius,
   all wrapped around an image that already carries its own white plate.

   Checked before deleting: the seal PNG is 200x65 RGBA and its CORNERS are
   fully transparent (alpha 0), but composited on black it shows a white
   rounded rectangle baked into the artwork. So there were TWO whites stacked -
   a white box of ours sitting a few pixels outside BBB's white box.

   Only ours is removed. BBB's plate is part of a certification mark served
   from their host under their terms, and it is not ours to edit, mask, knock
   out or restyle. If it ever needs to sit on dark without a white plate, that
   is a request to BBB, not a CSS change. */
.bbb-badge{display:inline-flex;align-items:center;gap:10px;
  text-decoration:none;color:var(--text-color)}
.bbb-badge-mark{font-family:var(--display-font);font-weight:700;
  font-size:var(--fs-lg);letter-spacing:.02em;line-height:1}
.bbb-badge-text{font-size:var(--fs-fine);line-height:1.3;color:var(--text-muted)}
.bbb-badge-text strong{color:var(--text-color)}

/* ---------------------------------------------------------------------------
   VIDEO FACADE

   16:9 box reserved up front so pressing play cannot shift the page. The
   poster is a normal responsive img, self-hosted, so nothing from Google is
   requested until the visitor presses.
   --------------------------------------------------------------------------- */
.video-block{max-width:880px;margin-inline:auto}
.video-facade{display:block;position:relative;width:100%;aspect-ratio:16/9;
  padding:0;border:0;background:none;cursor:pointer;
  border-radius:var(--btn-radius);overflow:hidden}
.video-facade img{width:100%;height:100%;object-fit:cover;display:block}
.video-facade::after{content:"";position:absolute;inset:0;
  background:rgba(0,0,0,.18);transition:background .15s ease}
.video-facade:hover::after{background:rgba(0,0,0,.05)}
.video-play{position:absolute;top:50%;left:50%;z-index:1;
  width:76px;height:76px;margin:-38px 0 0 -38px;border-radius:50%;
  background:var(--accent-color);border:1px solid var(--accent-border);
  transition:transform .15s ease}
/* Triangle drawn with borders: no icon file, no extra request. */
.video-play::before{content:"";position:absolute;top:50%;left:54%;
  margin:-13px 0 0 -9px;border-style:solid;
  border-width:13px 0 13px 21px;border-color:transparent transparent transparent #000}
.video-facade:hover .video-play{transform:scale(1.06)}
.video-facade:focus-visible{outline:3px solid var(--accent-border);outline-offset:3px}
.video-frame{display:block;width:100%;aspect-ratio:16/9;border:0;
  border-radius:var(--btn-radius)}

/* Sitemap page: the footer list styling reads correctly on a dark footer, so
   reset the colours for use on a light card. */
.sitemap-list a{color:var(--accent-deep)}
.sitemap-list a:hover{color:var(--text-color)}
.page-sitemap .card h2{font-size:var(--fs-xl);margin-bottom:var(--space-xs)}

/* The service lead is the one split whose columns are deliberately uneven: a
   framed photo runs much taller than two paragraphs, so align-items:start left
   the copy hanging at the top with a column of empty space under it. Centred
   against the photo instead.

   Scoped to .lead-split rather than changed on .split: every other split on the
   site pairs blocks that should share a top edge, and centring those would
   misalign headings. Only applies where the grid exists, from 992 up. */
@media (min-width:992px){.split.lead-split{align-items:center}}

/* THE INTRO SPLIT LEADS WITH ITS HEADING ON MOBILE.
   ---------------------------------------------------------------------------
   .split is a grid from 768 up, and the intro puts the photo div FIRST so the
   photo lands left on desktop. Below 768 the grid is gone and source order
   wins, so the page read chevron -> photograph -> heading: the divider chevron
   introduced a picture, and the heading it actually belongs to arrived after
   it.

   Scoped to .split-intro, NOT to .split. The areas split is photo-RIGHT, where
   source order already puts its text first, and the service pages share this
   same component - reordering .split globally would flip those too.

   flex, not `order` on a grid: below 768 .split has no grid to order within,
   so the container has to become a formatting context before order means
   anything. */
@media (max-width:767px){
  .split-intro{display:flex;flex-direction:column}
  .split-intro > div:first-child{order:2}
}
/* The card grid under the "Why homeowners pick us" split. Was an INLINE
   margin-top:var(--space-xl), which a media query cannot override - same trap
   as the contact aside heading - so the mobile value could not be set at all.

   MEASURED at 390 and 430: section padding is 48px and the photo-to-cards gap
   was 64px, so the space INSIDE the section was larger than the space between
   sections and the boundary stopped reading. 24px is half the section padding,
   which is the ratio the rest of the page uses.

   Desktop keeps 64px: there the section padding is 80px, so 64 is already
   below it and the ratio holds. */
.why-cards{margin-top:var(--space-xl)}
@media (max-width:767px){.why-cards{margin-top:var(--space-md)}}

/* Brand mark beside a detail section. Sized well below the header logo so it
   reads as a mark in the margin rather than a second header, and alt=""
   because it carries no information the copy does not - a screen reader
   announcing the company name again mid-page is noise, not access. */
.detail-mark{min-height:153px;background:url("/images/logo.webp?v=81f9ace0") center/contain no-repeat;
  opacity:.9}
/* Below the split breakpoint the mark stacks directly under the copy with only
   the paragraph's own margin between them, so the logo read as part of the
   text block. Scoped to max-width because from 768 up it sits in its own
   track and a top margin would just drop it out of line with the heading. */
@media (max-width:767px){.detail-mark{margin-top:var(--space-lg)}}

/* Signpost link between sections. .card-link is inline-flex, which is
   inline-level, so text-align on the paragraph centres it. */
/* Stacked-layout centring. Below the split breakpoint a lone button sitting
   under left-aligned copy has nothing to align WITH - the figure beneath it is
   full-width and the caption link under that is centred - so it reads as
   stranded. Applied per element, never to prose: centred body copy is harder
   to read, which is the whole reason the sections are not simply centred
   wholesale. */
/* .center-on-mobile CENTRES AGAIN. Its only call site is the phone button in the
   home "Where we work" block, which is one of the two named mobile-centring
   exceptions, so the class does exactly what its name says there. It is NOT a
   general licence - the body reset below still left-aligns everything else. */
@media (max-width:767px){.center-on-mobile{text-align:center}}

/* NOTHING CENTRES ON MOBILE - ONE RESET, NOT A HUNT THROUGH CALL SITES.
   ---------------------------------------------------------------------------
   Centred blocks are a DESKTOP device: they work when a narrow measure sits in
   a wide field and the centring is visibly deliberate. At 390 the measure IS
   the viewport, so centring only produces a ragged left edge on every line,
   against a page whose headings, cards, stats and now footer all sit on the
   container edge.

   Every centring hook in this file is listed, so a block that gains one of
   these classes later is covered without anybody remembering this rule. Swept
   from the rendered pages rather than from memory: .text-center (the cta_band
   container), .center-col, .faq-teaser, and the three caption classes.

   .btn IS DELIBERATELY ABSENT. A button label centres inside its own box at
   every width - that is the control's internal alignment, not page layout, and
   a full-width CTA with a left-shoved label reads as broken. */
@media (max-width:767px){
  .text-center,
  .center-col,
  .faq-teaser,
  .lead-figure figcaption,
  .frame-caption,
  .frame-caption-link{text-align:left}
  /* EXCEPTION, NAMED: the home "Where we work" block centres on mobile. Scoped
     to .split-areas because .split, .frame-col and .frame-caption-link are all
     shared with the service pages, which stay left. The frame-caption-link
     override has to be explicit - it is left-aligned by the rule directly
     above, and an inherited text-align cannot beat a direct one. */
  .split-areas{text-align:center}
  .split-areas .frame-caption-link{text-align:center}
  /* EXCEPTION, NAMED: /roofing/ centres on mobile. Scoped to .page-roofing,
     the body class, so the other three service pages are untouched - they
     share every component on this page and a .page-service rule would take all
     of them. The caption classes need explicit overrides for the same reason
     as above: they are left-aligned by the reset and inheritance cannot beat a
     direct rule. Lists stay LEFT - a centred bullet list has no edge to track
     down and is materially harder to scan than centred prose. */
  .page-roofing main{text-align:center}
  .page-roofing .lead-figure figcaption,
  .page-roofing .frame-caption,
  .page-roofing .frame-caption-link{text-align:center}
  .page-roofing .frame-col.lead-frame,
  .page-roofing .page-figure.lead-figure{margin-inline:auto}
  .page-roofing .chevron{margin-inline:auto}
  .page-roofing main ul,
  .page-roofing main ol{text-align:left}
  /* The chevron is a display:block SVG with margin-left:0, so text-align on the
     ancestor cannot reach it - a block box centres by auto side margins or not
     at all. It sat hard left while the heading, copy, hours and button beside it
     all centred. Same trap the footer comment names for its <img>, and the same
     shape as the existing .text-center .chevron rule. */
  .split-areas .chevron{margin-inline:auto}
  .center-col{margin-inline:0;max-width:none}
  .page-figure.lead-figure,
  .frame-col.lead-frame{margin-inline:0}
}

.faq-teaser{text-align:center;margin:0}
.faq-teaser .card-link{margin-top:0}
/* The teaser carries a SENTENCE, not a two-word signpost, so below ~890px it
   wraps - and an inline-flex chevron beside a wrapped block centres itself
   vertically against all three lines instead of following the last word.
   Inline display puts the arrow back in the text flow. margin-top is already
   0 here, so dropping the flex box costs nothing. Scoped to .faq-teaser: the
   chevron in a CARD sits beside a two-word link that never wraps, and those
   links need the flex box for their margin-top. */
.faq-teaser .card-link{display:inline}
.faq-teaser .card-link::after{display:inline-block;margin-left:6px;
  vertical-align:middle}
