/* site.css -- hand-written, no framework, no preprocessor.
 *
 * Mobile-first. The site must be fully usable at 360px wide, INCLUDING WITH
 * JAVASCRIPT OFF. The mobile call bar shows below the breakpoint.
 *
 * THE COLLAPSED NAV IS OPT-IN, NOT THE DEFAULT. Every rule that hides the nav
 * or shows the Menu toggle is gated behind the `js` class that the inline
 * script in _head.html.j2 stamps on <html>. Without JavaScript the nav stays
 * expanded and the toggle -- which nothing could then operate -- stays hidden.
 * The base stylesheet used to hide the nav unconditionally and reveal it only
 * via a class site.js sets, which made six nav links, the header phone button
 * and the header contact CTA unreachable with JS off (Reviewer 2026-08-26).
 *
 * Those gates are written `:where(.js) ...` rather than `.js ...` on purpose.
 * `:where()` contributes nothing to specificity, so the gated rules stay at
 * the same weight as their un-gated counterparts and the `min-width: 48rem`
 * block at the bottom of this file still wins by source order. Writing them as
 * `.js .site-nav` would outrank the desktop rules and leak the mobile layout
 * into desktop. It also keeps the breakpoint in exactly one place: no
 * complementary `max-width` query to drift out of sync.
 *
 * NO WEBFONT HOST. No @import and no @font-face src pointing anywhere off this
 * origin -- not Google's font hosts, not a CDN, not the old Zyro asset host.
 * Nothing is fetched at render time (docs/spec/website-migration.md sec. 4).
 * The hostnames themselves are deliberately not written out here: this file is
 * copied verbatim into dist/, and the sec. 12 check greps the built tree for
 * them.
 * The stack below names Montserrat and Poppins first, matching the live site's
 * typography where a visitor has them, and falls back to a system sans. The
 * page must look correct on the fallback. Self-hosting the real woff2 files is
 * still outstanding and has NO open-item number (tracking-and-ads-spec 3.2).
 *
 * Buttons are real elements, not the live site's text-baked PNGs (User
 * decision 2026-08-26). The pixel-art border treatment below is CSS
 * reproducing that art: a hard 3px dark outline, a bright inner highlight, a
 * flat offset shadow, square-ish corners, no gradients.
 */

:root {
  /* Sampled from the actual brand art. --navy matches qrgen's brand navy. */
  --navy: #1E3A5F;
  --sky: #4A90D9;
  --orange: #E8712B;
  /* THE SITE'S ONE ACCESSIBLE ORANGE: button fills AND accent text on light
     backgrounds (sec.7 / sec.0.11). White labels on --orange measure 3.08:1,
     below WCAG AA's 4.5:1 body-text floor -- .btn--lg's 18px/700 text is
     0.66px short of the 18.66px bold large-text boundary, so the 3:1
     large-text threshold never applied. --orange-cta holds brand Warm
     Orange's hue and saturation (22.3deg / 0.807 read back off this hex,
     --orange's 22.2deg / 0.804 to within 8-bit rounding) and lowers lightness
     from 0.539 to 0.407. MEASURED: 4.85:1 on #FFFFFF, 4.55:1 on --paper
     (#F5F8FB), 4.00:1 on --pale (#DCEBFA).

     IT IS DELIBERATELY DARKER THAN WHITE ALONE WOULD REQUIRE, AND THAT IS THE
     WHOLE POINT. WHITE IS NOT THE BINDING CONSTRAINT -- --paper IS. The
     .section--bundle and .section--promise bands paint --paper behind accent
     text, and --paper costs about 0.3 of a ratio point against white. The
     value this token held until 2026-09-11 was chosen as the MINIMUM
     darkening that reached 4.5:1 on white, clearing it by two hundredths --
     but on --paper that same colour measured only 4.25:1, i.e. still under
     AA, silently, on two whole sections (see CHANGELOG / git history for the
     hex). THIS token's value is the one that clears 4.5:1
     on --paper, and 4.85:1 on white is the consequence, not slack to spend.
     If you are reading 4.85:1 and reaching for a lighter, prettier orange,
     you will re-break the --paper sections and nothing on white will tell you.

     Since the sec.7 palette pass (2026-09-11) this token also carries every
     accent-TEXT rule, having absorbed the retired --orange-dark (#D1520E,
     4.26:1 on white and 4.00:1 on --paper -- BELOW AA for normal-size text).
     The earlier "buttons only -- do not use for borders/rules/icons/text"
     constraint was correct only while buttons were the sole consumer.
     On --pale 4.00:1 clears the 3:1 large-text threshold but NOT 4.5:1 --
     normal-size text on --pale uses --navy (9.48:1) instead. */
  --orange-cta: #BB5214;
  --orange-cta-hover: #A64912;
  --orange-bright: #FF823A;
  --ink: #2D2D2D;
  --white: #FFFFFF;

  --pale: #DCEBFA;
  --paper: #F5F8FB;
  --shadow: rgba(45, 45, 45, 0.9);

  --font-head: Montserrat, "Segoe UI", system-ui, -apple-system, sans-serif;
  --font-body: Poppins, "Segoe UI", system-ui, -apple-system, sans-serif;

  --wrap: 68rem;
  /* THE HEADER'S OWN CEILING, WIDER THAN THE BODY'S ON PURPOSE. `.wrap` caps
     body prose at `--wrap` for readability and that is unchanged; the header
     bar is the one `.wrap` that is not prose, and capping it at 68rem was
     what crushed the logo (see the header-width block at the end of this
     file for the measurements). Only `.site-header__bar` may read this. */
  --header-wrap: 88rem;
  --gutter: 1.25rem;
  --radius: 6px;
}

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

/* The `hidden` attribute must actually hide. The UA rule that implements it is
   `display: none` at UA origin, so ANY author rule that sets `display` on a
   matching element silently outranks it -- add `display: flow-root` to
   `.section` one day and the inline contact form site.js hides comes back.
   This is the one place `!important` is right: it does not compete with a
   layout rule, it defends an HTML semantic. Nothing in the built markup ships
   `hidden`; it is set at runtime, by script, and only ever to hide the DUPLICATE
   of something that is already reachable elsewhere. */
[hidden] {
  display: none !important;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.6;
  color: var(--ink);
  background: var(--white);
  /* Room for the fixed mobile call bar so it never covers the last line. */
  padding-bottom: 4.5rem;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-head);
  line-height: 1.15;
  margin: 0 0 0.6em;
}

p {
  margin: 0 0 1em;
}

img {
  max-width: 100%;
  height: auto;
}

a {
  color: var(--navy);
}

.wrap {
  width: 100%;
  max-width: var(--wrap);
  margin: 0 auto;
  padding: 0 var(--gutter);
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--navy);
  color: var(--white);
  padding: 0.6rem 1rem;
  z-index: 100;
}

.skip-link:focus {
  left: 0;
}

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

/* ------------------------------------------------------------- buttons -- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  font-family: var(--font-head);
  font-weight: 700;
  font-size: 1rem;
  line-height: 1.2;
  text-decoration: none;
  text-align: center;
  padding: 0.7rem 1.25rem;
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: 4px 4px 0 var(--shadow);
  cursor: pointer;
  transition: transform 0.06s linear, box-shadow 0.06s linear;
}

.btn:active {
  transform: translate(3px, 3px);
  box-shadow: 1px 1px 0 var(--shadow);
}

.btn--primary {
  background: var(--orange-cta);
  color: var(--white);
  box-shadow: 0 0 0 3px var(--sky), 4px 4px 0 var(--shadow);
}

.btn--primary:hover {
  background: var(--orange-cta-hover);
}

.btn--ghost {
  background: var(--pale);
  color: var(--navy);
  box-shadow: 0 0 0 3px var(--sky), 4px 4px 0 var(--shadow);
}

.btn--ghost:hover {
  background: var(--white);
}

.btn--phone {
  background: var(--white);
  color: var(--navy);
}

.btn--lg {
  font-size: 1.125rem;
  padding: 0.9rem 1.6rem;
}

.btn--submit {
  width: 100%;
}

.btn__icon {
  width: 1.5rem;
  height: 1.5rem;
  flex: none;
  image-rendering: pixelated;
}

.cta-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
  margin: 1.25rem 0;
}

.cta-row__or {
  font-family: var(--font-head);
  font-weight: 600;
  color: var(--navy); /* out-of-hero default; every current instance is inside .hero, where .hero .cta-row__or paints --pale */
  margin: 0;
}

/* -------------------------------------------------------------- header -- */

.site-header {
  background: var(--navy);
  border-bottom: 4px solid var(--ink);
  z-index: 40;
}

/* Sticky is conditional at mobile for one reason: with JS off the nav is
   expanded, and an expanded nav in a sticky header eats roughly half a 360px
   viewport on every scroll, permanently occluding the page. With JS the nav is
   collapsed and the header is one short bar, so sticky is free. Desktop is
   sticky either way -- see the min-width: 48rem block. */
:where(.js) .site-header {
  position: sticky;
  top: 0;
}

.site-header__bar {
  display: flex;
  /* Base = the no-JS state; see the per-state note below. */
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding-top: 0.6rem;
  padding-bottom: 0.6rem;
}

/* WRAPPING IS PER-STATE, NOT GLOBAL. .site-nav is `width: 100%; order: 3` at
   mobile, which only lands on its own row in a wrapping container -- in the
   default `nowrap` it is squeezed onto the logo's line instead. But wrap is
   only WANTED in the two states where something is actually in flow below the
   logo row. The other two states have nothing there, and wrap costs them a
   whole extra row: under `nowrap` the flex line shrinks the logo to fit
   (251px -> 197px at 1280); allowing wrap drops the logo or the toggle to a
   new line instead. Measured on 2026-08-26, this was worth 41% of the desktop
   header height (109px -> 154px at 1280) and took the sticky mobile header
   from 63px to 124px with the nav closed. So:

     no JS               -> wrap    (nav is expanded and in flow)
     JS, nav closed      -> nowrap  (nav is display:none; logo + toggle only)
     JS, nav open        -> wrap    (nav is back in flow)
     >= 48rem, any state -> nowrap  (nav is `width: auto`; one row always)

   The gates match `.site-nav`'s own gates so the two cannot drift apart. */
:where(.js) .site-header__bar {
  flex-wrap: nowrap;
}

:where(.js) .site-header.is-nav-open .site-header__bar {
  flex-wrap: wrap;
}

.site-header__logo img {
  display: block;
  width: auto;
  max-height: 3rem;
}

.nav-toggle {
  /* A button that does nothing must never be shown. */
  display: none;
  align-items: center;
  gap: 0.5rem;
  background: transparent;
  color: var(--white);
  border: 3px solid var(--sky);
  border-radius: var(--radius);
  font-family: var(--font-head);
  font-weight: 700;
  padding: 0.45rem 0.7rem;
  cursor: pointer;
}

:where(.js) .nav-toggle {
  display: inline-flex;
}

.nav-toggle__bars,
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  display: block;
  width: 18px;
  height: 3px;
  background: var(--white);
  content: "";
}

.nav-toggle__bars {
  position: relative;
}

.nav-toggle__bars::before {
  position: absolute;
  top: -6px;
}

.nav-toggle__bars::after {
  position: absolute;
  top: 6px;
}

.site-nav {
  display: block;
  width: 100%;
  order: 3;
  padding-bottom: 0.75rem;
}

:where(.js) .site-nav {
  display: none;
}

:where(.js) .site-header.is-nav-open .site-nav {
  display: block;
}

.site-nav__list {
  list-style: none;
  margin: 0 0 0.75rem;
  padding: 0;
}

.site-nav__link {
  display: block;
  padding: 0.55rem 0;
  color: var(--white);
  font-family: var(--font-head);
  font-weight: 600;
  text-decoration: none;
  border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}

.site-nav__link:hover,
.site-nav__link.is-current {
  color: var(--orange-bright);
}

.site-nav__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
}

/* Match the mobile call bar's button-sizing treatment (.call-bar__btn below):
   each button grows to share the row equally instead of shrinking to its own
   text width, which is what made the CTA look conspicuously narrower than the
   phone button. `flex: 1 1 0%` only has room to act while `.site-nav__actions`
   is itself a full-width block (mobile, and the no-JS/nav-open flow states);
   at >= 48rem `.site-nav__actions` is a shrink-to-fit flex item beside the nav
   links, so this is a no-op there and the desktop six-link-fit override two
   screens down (`.site-nav__actions .btn` under `@media (min-width: 48rem)`)
   still owns padding/font-size at that width. */
.site-nav__actions .btn {
  flex: 1 1 0%;
  padding: 0.6rem 0.5rem;
  font-size: 0.95rem;
}

/* ---------------------------------------------------------------- hero -- */

/* ONE HERO TREATMENT FOR ALL SIX PAGES (User, 2026-09-06: "The hero banners
 * should all have the same size and opacity, but each page might have a
 * different hero banner"). Size and opacity belong to the BANNER BOX, which is
 * shared; the image inside it is the per-page part. templates/_hero.html.j2 is
 * the only markup that may produce a hero.
 *
 * THIS BLOCK IS EDITED IN PLACE, which rounds 1-3 below deliberately were not.
 * Their append-only rule exists so that adding a component for a NEW page
 * leaves every already-built page byte-identical. This change is the opposite
 * kind of change: it is a cross-page unification whose whole purpose is to
 * alter all six heroes, and three superseded variants had to be deleted rather
 * than shadowed -- a dead `.hero__art { height: auto }` left in place would
 * silently fight the shared box.
 *
 * WHAT "80% OF CURRENT" WAS MEASURED AGAINST. There was no single "current":
 * the three old variants disagreed, and two of them had no CSS height at all.
 * Banner heights as they actually rendered (Chromium, built tree, 2026-09-06):
 *
 *   page                                variant        360px     1280px
 *   index, residential-arvada-hvac      .hero__art     162.0     576.0
 *   commercial-hvac-arvada              .hero__art      89.0     316.5
 *   about-us-hvac, indoor-air-...       .photo--hero   202.5     416.0
 *   duct-cleaning-arvada                (none)            --        --
 *
 * The two `.hero__art` numbers are not a design decision at all -- they are
 * the art's aspect ratio times the viewport width, so they keep growing with
 * the screen (864px at 1920) and cannot be 80%-ed into a shared value.
 * `.photo--hero` was the only variant with a declared box, so it is the
 * baseline: it rendered 202.5px at 360 (bound by its 16/9 ratio, not by its
 * 20rem cap, which never bound at mobile) and 416px from 48rem up (bound by
 * its 26rem cap). 80% of those is 162px and 332.8px, which is what the two
 * values below are -- 10rem is 160px, 2px under the mobile figure, and the
 * rem is worth more than the 2px.
 *
 * That lands as a REDUCTION on every page that had a banner except
 * commercial-hvac-arvada, whose art is a 4:1 letterbox and was anomalously
 * short. That one page GROWS, and the growth is very uneven -- both halves
 * are recorded below, because the desktop half quoted on its own badly
 * understates it.
 *
 * THE COMMERCIAL EXCEPTION, BOTH BREAKPOINTS. Same run as the table above
 * (tenths differ only in the scrollbar allowance):
 *
 *   commercial-hvac-arvada        before      after     change
 *   360px                           88.9      160.0       +80%
 *   1280px                         316.2      332.8        +5%
 *
 * +80% at 360px is a near-doubling, and 360 is the mobile-first breakpoint,
 * so it is the half that matters most -- quoting only "~5% at desktop" would
 * let a reader sign off believing the exception is negligible. It is not.
 *
 * The cause is the art, not the value chosen here. commercial-hero.png is
 * 3150x779 -- 4.04:1 -- where the other `.hero__art` pages carry
 * hero-banner.png at 10000x4500, 2.22:1. With no CSS height each rendered at
 * viewport width / ratio, so the 4:1 page was always the shortest banner on
 * the site at every width. Any single shared height low enough to reduce the
 * 2.2:1 pages is necessarily an increase for the 4:1 one; that is arithmetic,
 * not a mis-tuned number, and no value of `--hero-banner-h` escapes it.
 *
 * The User was shown BOTH figures -- +5% at 1280 and +80% at 360 -- and
 * accepted the trade on 2026-09-06: uniformity wins. So this is a recorded
 * decision, not an oversight. Do not "fix" the mobile growth by special-casing
 * commercial-hvac-arvada; that reintroduces exactly the per-page divergence
 * this block exists to remove.
 */
:root {
  /* 10rem = 160px. The desktop half of this pair is at the end of the file. */
  --hero-banner-h: 10rem;
}

.hero {
  position: relative;
  /* The banner is out of flow, so the hero has to be told not to be shorter
     than it -- otherwise a short hero would let the band bleed into the
     section below. */
  min-height: var(--hero-banner-h);
  background: var(--navy);
  color: var(--white);
  border-bottom: 4px solid var(--ink);
}

/* The banner band. Absolute so the text layer starts at the TOP of the hero
   and overlaps it, which is the "move the hero text up under the header"
   half of this change. No offset from the header is computed anywhere,
   because none could be: the header is 63px at 360 with the nav collapsed,
   118px at 768, 82px at 1280 and 486px at 360 with scripting off. The hero
   follows the header in document flow, so its top edge IS the header's bottom
   edge in every one of those states.

   VERTICALLY CENTRED IN THE HERO, NOT PINNED TO ITS TOP (User, 2026-09-06:
   "the hero banner ... should be center-justified instead of top"). The band
   is a fixed `--hero-banner-h` tall but `.hero` is as tall as its copy, which
   varies per page -- 581px at 1280 on the homepage against a 333px band, so
   the top-pinned band left a 249px gap of bare `--navy` below it. Centring is
   only visible in that difference, which is why it is a band-position change
   and NOT an `object-position` one: the artwork inside was already centred on
   both axes and is deliberately left alone.

   `top: 0; bottom: 0` + a definite `height` + auto block margins is the
   centring mechanism (CSS 2.1 sec. 10.6.4: an over-constrained absolutely
   positioned box with auto margins splits the leftover space equally). A
   `transform: translateY(-50%)` would also centre it, but it would make this
   element a containing block for any descendant that ever goes `fixed`, so
   the margin form is preferred. The band stays out of flow either way, so the
   hero copy still starts at the header -- `HERO_TITLE_GAP_MAX` is untouched
   by this and still measures 16px. */
.hero__media {
  position: absolute;
  inset: 0 0 0 0;
  height: var(--hero-banner-h);
  margin-block: auto;
  overflow: hidden;
  z-index: 0;
  /* Decoration behind live text: it must never eat a click meant for the h1
     or a CTA that overlaps it. */
  pointer-events: none;
}

.hero__media picture {
  display: block;
  height: 100%;
}

/* 20% (User, 2026-09-06) AND THAT NUMBER IS LOAD-BEARING FOR CONTRAST, not
   just for taste. The hero's text sits on top of this, and the backing is
   `--navy`. The lightest thing any banner can composite to is
   0.2*white + 0.8*navy = rgb(75, 97, 127), which is 6.3:1 against the white
   hero text and 5.2:1 against `--pale` (the lead and the "or"). Both clear
   WCAG AA's 4.5:1 with margin, and they do so for ANY image, present or
   future, because white is the ceiling. Raise this opacity or lighten
   `.hero`'s background and that guarantee is gone --
   `test_hero_text_contrast_survives_any_banner_image` recomputes it from the
   browser's own numbers and fails, which is the mechanism that stops a later
   image swap from quietly making the hero unreadable. */
.hero__media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  opacity: 0.2;
}

/* Pixel art, and only pixel art, is scaled with hard edges. The photographs
   in the same box must NOT be -- that is why `.photo` has never set this. */
.hero__media .hero__art {
  image-rendering: pixelated;
}

.hero__inner {
  position: relative;
  z-index: 1;
  /* Was 1.75rem, under a banner that was in flow above it. The banner is now
     behind the text, so this padding is the entire gap between the header's
     bottom border and the first line of the hero. */
  padding-top: 1rem;
  padding-bottom: 2rem;
}

.hero__title {
  font-size: clamp(1.9rem, 7vw, 3.25rem);
  text-transform: uppercase;
  letter-spacing: 0.01em;
  text-shadow: 3px 3px 0 var(--ink);
}

.hero__title span {
  display: block;
}

.hero__lead {
  font-size: 1.15rem;
  font-weight: 600;
  color: var(--pale);
}

.hero .cta-row__or {
  color: var(--pale);
}

.hero__offer {
  display: inline-block;
  /* Explicit here because this is an <a> on the homepage and residential
     page since 2026-09-08; the global `p { margin: 0 0 1em; }` rule no
     longer reaches it, and without this the gap to `.cta-row` collapses
     by 1em (16px). */
  margin: 0 0 1em;
  font-family: var(--font-head);
  font-weight: 700;
  background: var(--orange-cta);
  color: var(--white);
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  padding: 0.5rem 0.9rem;
  box-shadow: 4px 4px 0 var(--shadow);
  /* The homepage and residential page turn this into a real <a> (items
     1.3/2.3); the interactive affordances below mirror .btn's idiom so it
     behaves consistently as a link. Harmless as a no-op if the element is
     still a <p>. */
  text-decoration: none;
  cursor: pointer;
  transition: transform 0.06s linear, box-shadow 0.06s linear;
}

.hero__offer:hover,
.hero__offer:focus-visible {
  transform: translate(-1px, -1px);
  box-shadow: 5px 5px 0 var(--shadow);
}

.hero__offer:focus-visible {
  outline: 3px solid var(--orange);
  outline-offset: 2px;
}

.hero__offer:active {
  transform: translate(3px, 3px);
  box-shadow: 1px 1px 0 var(--shadow);
}

/* ------------------------------------------------------------ sections -- */

.section {
  padding: 2.5rem 0;
  border-bottom: 2px solid var(--pale);
}

.section--bundle,
.section--promise {
  background: var(--paper);
}

.section--prevent,
.section--closing-cta {
  background: var(--navy);
  color: var(--white);
}

.section--prevent .section-title,
.section--closing-cta .section-title,
.section--prevent .lead,
.section--closing-cta .cta-row__or {
  color: var(--white);
}

.section--closing-cta .section-title a {
  color: var(--white);
  text-decoration: none;
}

.section--closing-cta .section-title a:hover {
  color: var(--orange-bright);
}

.section-title {
  font-size: clamp(1.5rem, 4.5vw, 2.25rem);
  color: var(--navy);
}

.section-title__kicker {
  display: block;
  /* Enlarged 0.55em -> 0.75em per User request 2026-09-08 (item 4.3, "'Now
     offering' needs to be bigger"). It is an `em` of `.section-title`, whose
     clamp floors at 1.5rem on a phone, so 0.55em rendered 13.2px at 360 --
     smaller than body copy and easy to miss above the heading it labels.
     0.75em is 18px there, i.e. the size of `.subhead` (1.15rem = 18.4px),
     which is the same typographic device: uppercase, letter-spaced, and in
     the same token. Anchoring to that rather than inventing a number keeps the
     two kicker/subhead treatments reading as one system. Shared by all four
     kickers on the site -- three are the byte-identical "Now Offering:"
     bundle heading and must not diverge; the fourth is residential's "Level
     Up" under its bar art. All four measured at 360 and 1280: still one
     line, still clear of the art. */
  font-size: 0.75em;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--navy);
}

.section-title__main {
  display: block;
}

.subhead {
  font-size: 1.15rem;
  color: var(--navy);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.lead {
  font-size: 1.1rem;
}

.lead--with-icon {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.lead__icon,
.panel__heart {
  /* Enlarged per User mobile review 2026-09-07 (items 1.10/3.3) -- covers the
     commercial "We Protect Your Peak Hours." shield among others. */
  width: 2.75rem;
  height: 2.75rem;
  flex: none;
  image-rendering: pixelated;
}

.lead--peak {
  /* Commercial "We Protect Your Peak Hours." line -- bold, per User mobile
     review 2026-09-07 (item 3.3), which asked for light blue. --sky measures
     ~3.3:1 against the white .section background here, failing WCAG AA, so
     this was set in --blue-mid (5.99:1) instead.

     NOW --navy (11.50:1). The sec.7 palette pass (2026-09-11) retired
     --blue-mid, and the User was shown that this specific paragraph would
     stop being light blue and chose to apply sec.7 literally anyway. Not a
     regression -- a deliberate decision. Same move as
     .icon-list--accent-sky below. */
  font-weight: 700;
  color: var(--navy);
}

.note {
  font-size: 0.9rem;
  color: var(--navy);
}

/* ----------------------------------------------------------- icon list -- */

.icon-list,
.price-list {
  list-style: none;
  margin: 0 0 1.25rem;
  padding: 0;
}

.icon-list li,
.price-list li {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.35rem 0;
  font-weight: 500;
}

.icon-list__icon {
  /* Enlarged per User mobile review 2026-09-07 (items 1.4/2.5/2.8) -- serves
     the homepage bundle shields, the "Our Promise" checkmarks, the
     residential pain-point icons/second-opinion shields, and the commercial
     pain-point icons. */
  width: 2.25rem;
  height: 2.25rem;
  flex: none;
  image-rendering: pixelated;
}

.icon-list--accent-sky li {
  /* Residential pain-point list -- lighter blue per User mobile review
     2026-09-07 (item 2.4). --sky measures ~3.3:1 against the white .section
     background this list sits on (residential-arvada-hvac.html.j2), which
     fails WCAG AA (4.5:1) for body text, so this took --blue-mid (5.99:1).

     NOW --navy (11.50:1): the sec.7 palette pass (2026-09-11) retired
     --blue-mid. THE `--accent-sky` CLASS NAME IS NOW A MISNOMER and is kept
     only because renaming it means template edits, which that pass put out of
     scope. Rename it with the markup, not here. */
  color: var(--navy);
}

.icon-list--accent-orange li {
  /* Commercial pain-point list -- orange per User mobile review 2026-09-07
     (item 3.2). --orange-cta (4.85:1 on white) since the sec.7 pass
     2026-09-11; --orange-dark was 4.26:1 and failed AA for this body copy. */
  color: var(--orange-cta);
}

.price-list li {
  flex-wrap: wrap;
}

.price--was {
  color: var(--navy);
  opacity: 0.75;
}

.price--now {
  color: var(--orange-cta);
  font-family: var(--font-head);
  font-size: 1.2rem;
}

.price-list li.price-list__combo,
.lead--combo {
  /* Homepage "10% off IAQ test + duct cleaning service combo" line -- orange
     per User mobile review 2026-09-07 (item 1.5). Same token as .price--now
     above so it reads as deliberate.

     `.lead--combo` is the duct page's copy of that same sentence, which the
     User asked to be bold and orange in its own right (request 2026-09-08,
     item 4.4). It shares this declaration block rather than repeating the two
     properties, so the two instances of one sentence cannot drift apart and
     no second orange gets typed. It needs its own class name because
     `.price-list__combo` is an element of `.price-list` and the duct
     occurrence is a `.lead`, not a list item. */
  color: var(--orange-cta);
  font-weight: 700;
}

/* -------------------------------------------------------------- panels -- */

.panel {
  background: var(--sky);
  color: var(--white);
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: 0 0 0 3px var(--orange), 6px 6px 0 var(--shadow);
  padding: 1.25rem;
  margin: 1.5rem 0;
}

.panel p {
  margin: 0 0 0.5rem;
}

.panel p:last-child {
  margin-bottom: 0;
}

.panel__punch {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--font-head);
  font-weight: 700;
  font-size: 1.15rem;
}

.panel--second-opinion {
  text-align: center;
  background: var(--pale);
  color: var(--navy);
}

.panel__line {
  font-family: var(--font-head);
  font-weight: 700;
  font-size: 1.4rem;
  text-transform: uppercase;
}

.panel__line--free {
  /* 2.2rem (35.2px) inside `h3.panel__line`'s weight 700, on --pale. That is
     large text by WCAG's 18.66px-bold bound, so --orange-cta's 4.00:1 on
     --pale clears the 3:1 threshold. It would NOT clear 4.5:1 -- do not
     shrink this below 18.66px without changing the colour. */
  color: var(--orange-cta);
  font-size: 2.2rem;
}

.offer-badge {
  /* --navy, NOT the accent orange, and that is the sec.7 pass's one
     exception (2026-09-11). This `color` is inherited by
     `.offer-badge__terms`, 1.05rem/600 = 16.8px -- normal-size text needing
     4.5:1. On this --pale background --orange-cta gives only 4.00:1; --navy
     gives 9.48:1. Do not "restore consistency" by orange-ing this back. */
  display: block;
  text-align: center;
  background: var(--pale);
  color: var(--navy);
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: 0 0 0 3px var(--sky), 6px 6px 0 var(--shadow);
  padding: 1.25rem;
  margin: 0;
  font-family: var(--font-head);
}

.offer-badge__lead {
  display: block;
  font-size: 2.4rem;
  line-height: 1;
}

.offer-badge__body {
  display: block;
  font-size: 1.4rem;
  font-weight: 700;
}

.offer-badge__terms {
  display: block;
  font-size: 1.05rem;
  font-weight: 600;
}

/* --------------------------------------------------------------- cards -- */

.card-row {
  display: grid;
  gap: 1.25rem;
}

.card-row + .lead {
  /* Breathing room between the commercial "Who we serve" card grid and its
     closing line per User mobile review 2026-09-07 (item 3.11). `.card-row`
     declares no margin and `p` only has a bottom margin, so without this the
     closing <p class="lead"> sat flush against the grid above it. */
  margin-top: 2rem;
}

.card {
  background: var(--white);
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: 6px 6px 0 var(--shadow);
  padding: 1.5rem;
}

.card__title {
  font-size: 1.25rem;
  color: var(--navy);
}

.care-team__icons {
  display: flex;
  gap: 1rem;
  align-items: center;
}

.care-team__icons img {
  image-rendering: pixelated;
}

/* ---------------------------------------------------------------- form -- */

.contact-inline {
  scroll-margin-top: 6rem;
}

.contact-form {
  max-width: 34rem;
}

.field {
  margin: 0 0 1rem;
  border: 0;
  padding: 0;
}

.field label,
.field legend {
  display: block;
  font-family: var(--font-head);
  font-weight: 600;
  margin-bottom: 0.3rem;
}

/* `select` is IN THIS SELECTOR LIST, not in a rule of its own, because the
   requirement is that it look like the text inputs -- and one shared
   declaration block is the only way to say that without two copies of the
   border, padding and colour that would then drift apart. */
.field input[type="text"],
.field input[type="tel"],
.field input[type="email"],
.field select,
.field textarea {
  width: 100%;
  font: inherit;
  padding: 0.6rem 0.7rem;
  border: 3px solid var(--navy);
  border-radius: var(--radius);
  background: var(--white);
  color: var(--ink);
}

.field--group {
  border: 3px solid var(--pale);
  border-radius: var(--radius);
  padding: 0.75rem 1rem;
}

.choice {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  padding: 0.15rem 0;
}

.choice label {
  font-family: var(--font-body);
  font-weight: 400;
  margin: 0;
}

/* Honeypot. Off-screen rather than display:none so a naive bot still finds
   and fills it; never focusable, never announced. */
.contact-form__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* Was `.contact-form__alt`, a sentence ending in a bare tel: link. It is now a
   real call button below the submit button (User, 2026-09-06). It stays an
   <a href="tel:"> and not a <button>: that is what dials with JavaScript off,
   and it is also what site.js's DELEGATED `a[href^="tel:"]` listener matches,
   so the call_click beacon tracks it with no new JavaScript at all. */
.contact-form__call {
  margin-top: 0.75rem;
}

/* -------------------------------------------------------------- dialog -- */

.contact-dialog {
  width: min(38rem, calc(100vw - 2rem));
  max-height: calc(100vh - 2rem);
  padding: 0;
  border: 4px solid var(--ink);
  border-radius: var(--radius);
  background: var(--white);
  color: var(--ink);
}

.contact-dialog::backdrop {
  background: rgba(30, 58, 95, 0.7);
}

.contact-dialog__inner {
  position: relative;
  padding: 1.5rem;
}

.contact-dialog__close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  width: 2.25rem;
  height: 2.25rem;
  font-size: 1.4rem;
  line-height: 1;
  background: var(--navy);
  color: var(--white);
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  cursor: pointer;
}

/* ------------------------------------------------------------ call bar -- */

.call-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 50;
  display: flex;
  gap: 0.5rem;
  padding: 0.5rem;
  background: var(--navy);
  border-top: 4px solid var(--ink);
}

.call-bar__btn {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  font-family: var(--font-head);
  font-weight: 700;
  font-size: 0.95rem;
  text-decoration: none;
  padding: 0.6rem 0.5rem;
  border: 3px solid var(--ink);
  border-radius: var(--radius);
}

.call-bar__btn--call {
  background: var(--orange-cta);
  color: var(--white);
}

.call-bar__btn--quote {
  background: var(--pale);
  color: var(--navy);
}

/* -------------------------------------------------------------- footer -- */

.site-footer {
  background: var(--navy);
  color: var(--pale);
  padding: 2.5rem 0 1.5rem;
}

.site-footer a {
  color: var(--white);
}

.site-footer__grid {
  display: grid;
  gap: 1.75rem;
}

.site-footer__heading {
  font-family: var(--font-head);
  font-size: 1.1rem;
  color: var(--white);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.site-footer__phone {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-weight: 700;
  text-decoration: none;
}

.site-footer__badges {
  display: flex;
  /* SUPERSEDED (2026-09-10): badges are a 2x2 grid now (override near EOF).
     `nowrap` below only fixed 2026-09-07 item 1.11 (3 badges wrapping 2+1)
     and is dead code -- do not "fix" the grid back to a wrapping row. */
  flex-wrap: nowrap;
  gap: 1rem;
  align-items: center;
}

.site-footer__badges img {
  min-width: 0;
  width: auto;
  max-width: 100%;
  height: auto;
}

.site-footer__legal {
  margin-top: 2rem;
  padding-top: 1.25rem;
  border-top: 1px solid rgba(255, 255, 255, 0.2);
  font-size: 0.8rem;
  opacity: 0.85;
}

.site-footer__legal img {
  image-rendering: pixelated;
}

.bbb-badge {
  /* White card behind the BBB logo so it stops blending into the footer's
     dark background per User mobile review 2026-09-07 (item 1.12). Now used
     only by the footer badges grid -- the mid-body trust-strip copies that
     once also carried it were deleted 2026-09-11; it stays unscoped. */
  background: var(--white);
  border: 3px solid var(--white);
  border-radius: var(--radius);
  padding: 0.35rem;
  image-rendering: pixelated;
}

/* --------------------------------------------------------- breakpoints -- */

@media (min-width: 48rem) {
  body {
    /* The call bar is mobile-only, so the reserved space goes away with it. */
    padding-bottom: 0;
  }

  .site-header {
    /* Unconditional here: the desktop nav is one horizontal row, so it costs
       no meaningful vertical space whether or not JavaScript ran. */
    position: sticky;
    top: 0;
  }

  /* Pin the bar to one row at every desktop state. BOTH selectors are
     required: `:where(.js) .site-header.is-nav-open .site-header__bar` above is
     (0,2,0), so a bare `.site-header__bar` (0,1,0) here would lose to it and a
     nav left open across a resize would keep the wrapped mobile layout. The
     second selector below is also (0,2,0) and later in source order, so it
     wins. Deliberate: the desktop nav is `width: auto` and always fits beside
     the logo, so there is no state where wrapping is correct here. */
  .site-header__bar,
  .site-header.is-nav-open .site-header__bar {
    flex-wrap: nowrap;
  }

  .nav-toggle {
    display: none;
  }

  .site-nav {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 1.5rem;
    width: auto;
    order: 0;
    padding-bottom: 0;
  }

  .site-nav__list {
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
    margin: 0;
  }

  .site-nav__link {
    border-bottom: 0;
    padding: 0;
    font-size: 0.95rem;
  }

  .site-nav__actions {
    flex-wrap: nowrap;
  }

  .call-bar {
    display: none;
  }

  .card-row {
    grid-template-columns: 1fr 1fr;
  }

  .site-footer__grid {
    grid-template-columns: 2fr 1fr 1fr;
  }

  .site-footer__badges {
    /* Room to wrap again above the phone breakpoint (item 1.11). */
    flex-wrap: wrap;
  }
}

@media (prefers-reduced-motion: reduce) {
  .btn {
    transition: none;
  }
}

/* ================================================================
 * Phase 1b components.
 *
 * APPENDED, NEVER EDITED IN PLACE. Everything above this banner is Phase 1a
 * and is left byte-identical: the header/nav rules and their `:where(.js)`
 * gates cost three review rounds to settle, and the `min-width: 48rem` block
 * above owns the header pin. The desktop overrides for the components below
 * therefore live in their OWN media query at the end of this file rather than
 * being spliced into that one.
 *
 * These classes serve /residential-arvada-hvac and /commercial-hvac-arvada.
 * The remaining Phase 1b pages reuse them; add to this section, do not fork.
 * ================================================================ */

/* --------------------------------------------------- service catalogue -- */

/* A named group of services: icon + title, an optional intro, then the items.
   `break-inside` is deliberately NOT set anywhere here -- a group grows with
   the data and must be free to fragment. */
.svc-group {
  margin: 0 0 2.25rem;
}

.svc-group:last-child {
  margin-bottom: 0;
}

.svc-group__title {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-size: 1.35rem;
  color: var(--navy);
}

.svc-group__icon {
  width: 2.5rem;
  height: 2.5rem;
  flex: none;
  image-rendering: pixelated;
}

.svc-group__intro {
  margin: 0 0 1.1rem;
}

/* A <dl> of term/description pairs, one card each. The live pages number the
   terms in their own copy ("1. ...", "2. ..."), so no counter is generated
   here -- generated numbers would fight the transcribed ones. */
.svc-list {
  display: grid;
  gap: 1rem;
  margin: 0;
  padding: 0;
}

.svc-list + .subhead,
.svc-list + .note {
  /* Breathing room between a card grid and the block of text that follows it,
     per User request 2026-09-08 (items 2.6, 3.4 and 3.5 -- one rule, because
     they are one defect). The gap was not merely tight, it was measured at
     exactly 0px in Chromium: `.svc-list` declares `margin: 0` above, and
     `h1..h4` and `p` carry bottom margins only, so nothing separated the last
     card's shadow from the following heading or note.

     KEYED ON THE FOLLOWING ELEMENT, NOT ON `.svc-list`, on the same reasoning
     as `.card-row + .lead`: a bare `margin-bottom` on `.svc-list` would hit
     all 15 instances sitewide, including the six that are followed by a
     mid-page `h3.svc-group__title` repeat that nobody asked to space out, and
     the duct page's `.svc-list` + `.cta-row` pair, which already has 20px.

     2rem matches `.card-row + .lead`, and the cards need at least that: they
     carry a 6px offset shadow below their border box, so 32px of box-to-box
     space reads as ~26px of white. Measured at 360 and 1280 on all three
     pairs. It also lands on the IAQ page's `.svc-list` + `.note` and
     `.svc-list` + `.subhead` pairs (items 3.4 and 3.5 above) and on About
     Us's "the people" card grid before "What It's Like to Work With Us." --
     both the same card-then-text shape. It no longer lands on a step-7
     `.subhead`: per 2026-09-08 User request (spec §2.4) step 7 moved inside
     the `<dl>` as a seventh `.svc-item`, so that adjacency is gone. */
  margin-top: 2rem;
}

.svc-item {
  background: var(--white);
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: 6px 6px 0 var(--shadow);
  padding: 1rem 1.1rem;
}

.svc-item__term {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--font-head);
  font-weight: 700;
  color: var(--navy);
}

.svc-item__icon {
  /* Enlarged 1.75rem -> 2rem per User request 2026-09-08 (item 2.5, "make
     the checkbox next to Anthony's name bigger" -- that icon is `shield.png`
     on the About Us bio card, which is a `.svc-item__icon`). The markup still
     carries width="28" height="28" on all 21 usages; the CSS overrides the
     attributes, same as `.icon-list__icon`, whose markup still says 32.

     BLAST RADIUS, INTENDED: 21 icons across About Us, IAQ, residential and
     commercial -- the three bio icons are a set and Anthony's cannot grow
     alone, and the numbered process/service cards use the same treatment.

     MEASURED COLLATERAL, at 360px: none. 2rem was measured as the largest
     wrap-free step. At 2.25rem exactly two of the 21 terms gained a second
     line -- commercial's "1. Initial System Assessment" and residential's
     "1. A/C Repair & Maintenance" -- so the User chose 2rem on 2026-09-08 to
     keep the collateral at zero. Note this is deliberately SMALLER than
     `.icon-list__icon`'s 2.25rem; that family sits beside shorter labels and
     can afford the extra 4px, this one cannot. Nothing wraps at 1280 and no
     icon overlaps its text at either width. */
  width: 2rem;
  height: 2rem;
  flex: none;
  image-rendering: pixelated;
}

/* A <dd> carries a browser default margin-inline-start of 40px. Reset it, or
   every description hangs off the right of its own card. */
.svc-item__desc {
  margin: 0.45rem 0 0;
}

/* Sections with a paper background need the cards to stay distinguishable. */
.section--promise .svc-item,
.section--bundle .svc-item {
  background: var(--white);
}

/* ---------------------------------------------------------- pull quote -- */

.pull-quote {
  margin: 1.5rem 0;
  padding: 0.25rem 0 0.25rem 1rem;
  border-left: 6px solid var(--orange);
  font-family: var(--font-head);
  font-size: 1.1rem;
  color: var(--navy);
}

.pull-quote p {
  margin: 0;
}

/* ------------------------------------------------------------ tag list -- */

/* The commercial page's industry lists: plain, dense, no bullet art. */
.tag-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.tag-list li {
  padding: 0.3rem 0;
  border-bottom: 1px solid var(--pale);
}

.tag-list li:last-child {
  border-bottom: 0;
}

/* OPT-IN BULLETS, per User request 2026-09-08 (items 3.3, 3.8, 4.2, 4.7 and
   4.9 -- five lists on the IAQ and duct pages that the User asked to be "in a
   bullet list format"). Those lists render with no markers at all because the
   base class above is `list-style: none`.

   A MODIFIER, NOT AN EDIT TO `.tag-list`. The base class has five more users
   the request never mentions -- the commercial page's five industry lists
   inside `.card` boxes -- and the comment above records that those are
   deliberately bullet-free. Changing the base would silently reverse that.

   `outside` (the initial value, restated because it is a decision, not a
   default we inherited): every one of these items wraps to two or three lines
   at 360px, and `inside` would run the wrapped lines back under the marker
   instead of aligning them with the first word.

   The indent is `padding-left` ON THE UL, which is what restores the `padding:
   0` the base class sets. Chromium draws an `outside` marker to the left of
   the li's BORDER box, not inside the li's padding, so putting the indent on
   the li instead was measured to hang the bullets at x=8 on a 360px screen --
   12px to the LEFT of the 20px `.wrap` gutter every other element on the page
   starts at, and 8px from the viewport edge. On the UL the markers land at
   x=28: inside the gutter, and in the same place `.icon-list`'s inline icons
   sit, which is the site's existing pattern for a marker-then-text row. The
   li's `border-bottom` separators indent with the list rather than staying
   flush at 20px, which is the deliberate trade -- each rule then spans
   exactly the text column it separates. */
.tag-list--bulleted {
  list-style-position: outside;
  padding-left: 1.25rem;
}

.tag-list--bulleted li {
  list-style-type: disc;
}

/* Orange for the IAQ page's "Does this sound familiar?" list (item 3.3, "the
   list following it should be orange"). Same --orange-cta token as
   `.icon-list--accent-orange li` and `.price-list__combo`, deliberately: this
   site has exactly one accent orange. The ::marker inherits `color`, so the
   bullets go orange with the text. Only this one list is accented; the other
   four bulleted lists get markers and nothing else.

   RESOLVED BY THE sec.7 PASS (2026-09-11). This used to be --orange-dark
   (#D1520E), 4.26:1 against the white .section background -- BELOW WCAG AA's
   4.5:1 for normal-size text, recorded here as a known defect rather than
   silently re-coloured. sec.7 retired that token; --orange-cta is
   4.85:1 and passes, without forking the one accent orange this site has. */
.tag-list--accent-orange li {
  color: var(--orange-cta);
}

/* Orange for the duct page's "Interested in a full system refresh?" sentence
   (spec §4.8, 2026-09-08 User request -- "a like break" is the User's typo
   for "a line break"; the split into its own `<p>` provides that break).
   Colour only: the User asked for orange, not bold, so this does not reuse
   `.lead--combo`'s `font-weight: 700`. Same --orange-cta token as the rules
   above, deliberately -- one accent orange sitewide. The contrast shortfall
   noted above was fixed by the sec.7 pass (2026-09-11). MEASURED HERE, NOT
   ASSUMED: this sentence renders 16px/400 on --paper, NOT on white -- it sits
   in a --paper section band -- where --orange-dark was 4.00:1 and --orange-cta
   is 4.55:1. It passes AA for normal-size text by 0.05, which is precisely why
   --orange-cta is set for --paper rather than for white. */
.text--accent-orange {
  color: var(--orange-cta);
}

.tag-list--bulleted + p {
  /* Same 0px-gap defect as `.svc-list + .subhead` above, on the User's item
     4.10 ("more space between that list and the text under it"). Keyed on the
     BULLETED modifier rather than on `.tag-list`, which both keeps it off the
     commercial industry lists by construction and confines it to the three
     places it can currently land -- item 4.10's pair plus the duct
     Test/Clean/Compare and IAQ "who is this for" lists, which are the same
     list-then-paragraph shape and are lists THIS change puts bullets on.
     Spacing only 4.10 would leave the other two flush against a list that
     just grew markers, i.e. worse than before this commit. 1.5rem, not the
     2rem used for `.svc-list` above: a `.tag-list` has no card shadow under
     it, and at 2rem the paragraph measured as drifting away from the list it
     belongs to. */
  margin-top: 1.5rem;
}

/* --------------------------------------------- Phase 1b breakpoints -- */

/* Its own block, deliberately. See the banner at the top of this section. */
@media (min-width: 48rem) {
  .svc-list {
    grid-template-columns: 1fr 1fr;
  }

  /* About Us step 7 (spec §2.4, 2026-09-08 User request) -- a seventh
     `.svc-item` that must not land alone in the left column once the grid
     above goes two-up. Modifier on the one item rather than a change to the
     base `.svc-list` grid, which serves 15 instances sitewide. */
  .svc-item--full {
    grid-column: 1 / -1;
  }
}

/* ================================================================
 * Phase 1b round 2 components.
 *
 * APPENDED, NEVER EDITED IN PLACE, exactly as round 1 was: nothing above this
 * banner changed, so the Phase 1a header/nav rules and round 1's components
 * stay byte-identical. Desktop overrides go in the media block at the very
 * end of this file, not spliced into an earlier one.
 *
 * These classes serve /duct-cleaning-arvada and
 * /indoor-air-quality-testing-arvada. Round 2 landed the duct page only and
 * noted here that `.photo--hero` and `.tag-list--sub` were not yet used by
 * any built page; round 2b built the IAQ page, so every rule in this section
 * went live and that caveat stopped applying. `.photo--hero` has since been
 * deleted by the 2026-09-06 hero unification -- see the note where it stood.
 * Round 3 reuses this section; add here, do not fork.
 * ================================================================ */

/* ---------------------------------------------------------- photographs -- */

/* The first real photographs on the site. Deliberately NOT `.hero__art`:
   that class sets `image-rendering: pixelated`, which is right for the
   pixel-art banners and wrong for a camera photo.

   `aspect-ratio` + `object-fit: cover` rather than an intrinsic width/height
   pair, because the sources are a mix of landscape (3630x2160) and portrait
   (1216x2160) and a portrait photo at 100% width would otherwise run nearly
   two screens tall on a phone. The box is fixed, the photo is cropped to it,
   and there is no layout shift while it loads. */
.photo {
  margin: 0 0 1.25rem;
}

.photo img {
  display: block;
  width: 100%;
  height: auto;
  object-fit: cover;
  object-position: center;
  background: var(--pale);
}

/* `.photo--hero` WAS HERE and is deleted, not deprecated. It was the second of
   three hero variants; the hero is now one shared box (see `--hero-banner-h`
   in the hero section above) and the two photo pages reach it through
   templates/_hero.html.j2's `photo_name=` argument like everyone else. Left in
   place it would have kept fighting the shared box: its `aspect-ratio` and
   `max-height` sit at the same specificity as `.hero__media img`. Its desktop
   `max-height` override in the round 2 breakpoint block below is gone for the
   same reason. */

/* In-body photographs get the same frame as a card so they read as part of
   the same kit. The 6px shadow sits inside `.wrap`'s gutter, so it never
   causes horizontal overflow at 360px. */
.photo--framed img {
  aspect-ratio: 3 / 2;
  max-height: 22rem;
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: 6px 6px 0 var(--shadow);
}

/* Worn WITH `.photo--framed`, for a source that is taller than it is wide
   (currently one photo: the IAQ page's technician kneeling at a return vent
   with a meter, 1216x2160). In the shared 3/2 box `object-fit: cover` keeps
   only the middle ~37% of a portrait frame's height, which on that photo is a
   band of blank wall -- it throws away the one image on the site that shows
   the work actually being done. A 3/4 box keeps 75% of the height, trimming
   the window above and the carpet below while the technician, the vent and
   the meter all stay in frame.

   The cap is on WIDTH, not height. `.photo--framed`'s `max-height` exists to
   stop a tall image running two screens down a phone, but applied to a
   portrait box it does the cropping all over again: the height clamp
   silently widens the effective ratio back into a letterbox on a wide
   screen. Capping the width bounds the height by the aspect ratio instead,
   so the crop is the same at every viewport. */
.photo--portrait img {
  aspect-ratio: 3 / 4;
  max-width: 22rem;
  max-height: none;
  margin-inline: auto;
}

/* --------------------------------------------------- headings with art -- */

/* A pixel icon set beside a section title, the way `.svc-group__icon` sits
   beside a service-group title. Inline rather than flex so the title still
   wraps as one paragraph of text when the heading is long. */
.section-title__icon {
  width: 2rem;
  height: 2rem;
  margin-right: 0.5rem;
  vertical-align: -0.3em;
  image-rendering: pixelated;
}

/* The residential "Level Up" section title (residential-arvada-hvac.html.j2,
   #second-opinion-heading) treats `text-bar-level-up.png` as a decorative
   banner sitting on its own line above the heading text, per 2026-09-08
   User decision -- the `.section-title__kicker` "Level Up" stays as real,
   selectable text; the art is not a replacement for it.
   The PNG's canvas is 1600x1050 (1.5238:1) but the opaque "LEVEL UP" bar
   inside it is only 1408x407 (~3.46:1), centred with large transparent
   bands above and below -- only ~39% of the canvas height is visible
   artwork. Sizing this like the square `.section-title__icon` (height-only)
   would leave the visible bar tiny, so it is sized by width instead, with
   height:auto to preserve the true aspect ratio. At width 11rem: canvas
   height = 11rem * (1050/1600) = 7.22rem, and the visible bar within that
   canvas = 7.22rem * (407/1050) = ~2.8rem tall. */
.section-title__icon--bar {
  display: block;
  width: 11rem;
  max-width: 100%;
  height: auto;
  margin-right: 0;
  margin-bottom: 0.5rem;
  vertical-align: baseline;
}

/* The live IAQ and duct heroes both close with a line of copy that is a
   heading on the live page ("Schedule an Air Quality Assessment:", "Ready to
   See What's Hiding in Your Ducts? Call Today!"). It stays a heading here,
   one level below the hero's h1, and is sized to sit under it rather than
   compete with it. */
.hero__cta-title {
  margin: 1.5rem 0 0.5rem;
  font-size: 1.2rem;
  color: var(--pale);
}

/* ------------------------------------------------------ nested tag list -- */

/* The IAQ page's pollen-season bullet carries three sub-bullets. They are
   indented and unruled so the parent list's rules still read as the list. */
.tag-list--sub {
  margin: 0.35rem 0 0.15rem 1.1rem;
}

.tag-list--sub li {
  padding: 0.15rem 0;
  border-bottom: 0;
}

/* The sub-list's markers, per item 3.8. `.tag-list--bulleted li` is a
   DESCENDANT selector, so the three pollen seasons already take the parent's
   bullets through it -- the sub-list needs no extra class in the markup,
   which is why none was added. Two things still have to be said here:

   1. `disc` inside `disc` is not legible as nesting. `circle` (hollow) is the
      conventional second level and is the visual distinction the item asks
      for. Verified in Chromium at both widths: filled outer, hollow inner.
   2. The sub-list carries neither `--bulleted` nor `.tag-list`'s indent, so
      it needs its own `padding-left` to give its markers room. 1rem, slightly
      tighter than the parent's 1.25rem, because `.tag-list--sub` above
      already offsets the whole list by a 1.1rem left margin.

   Both selectors are `.tag-list--bulleted .tag-list--sub ...` rather than
   bare `.tag-list--sub ...` so that a future un-bulleted nested list keeps
   the existing marker-free treatment. */
.tag-list--bulleted .tag-list--sub {
  padding-left: 1rem;
}

.tag-list--bulleted .tag-list--sub li {
  list-style-type: circle;
}

/* ----------------------------------------------------------- flow line -- */

/* The duct page's "Testing -> Inspection -> Cleaning -> ..." summary, with the
   live layout's pixel health-bar art either side of it. Wraps to as many rows
   as it needs on a phone instead of overflowing. */
.flow-line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  margin: 0 0 1.5rem;
  font-family: var(--font-head);
  font-weight: 700;
  color: var(--navy);
  text-align: center;
}

.flow-line__rule {
  width: 100%;
  max-width: 12rem;
  height: auto;
  image-rendering: pixelated;
}

/* ---------------------------------------- Phase 1b round 2 breakpoints -- */

/* Its own block, deliberately. See the banner at the top of this section. */
@media (min-width: 48rem) {
  .photo--framed img {
    max-height: 26rem;
  }

  /* Re-stated because the `.photo--framed` rule above has the same
     specificity and would otherwise re-impose the height clamp here. */
  .photo--portrait img {
    max-height: none;
  }

  .hero__cta-title {
    font-size: 1.35rem;
  }
}

/* ================================================================
 * Phase 1b round 3.
 *
 * APPENDED, NEVER EDITED IN PLACE, exactly as rounds 1 and 2 were: not one
 * line above this banner is changed. Desktop overrides go in a media block
 * down here, not spliced into an earlier one.
 *
 * TWO PARTS, unlike rounds 1 and 2. First the two components that serve
 * /about-us-hvac, the sixth and last Phase 1 page. Then -- and this is the
 * departure from "components only" -- extra DECLARATIONS on the Phase 1a
 * header selectors, fixing a wrapping defect that has shipped on every page
 * since Phase 1a. They are declarations, not edits: the rules they land on
 * are untouched where they stand, and no gate, no `.is-nav-open` rule and no
 * `flex-wrap` is among them. See the note above that block for why the
 * existing tests could not catch it.
 * ================================================================ */

/* Worn WITH `.photo--framed`, for a source that is as tall as it is wide.
   Both About-page photographs are 2160x2160. `.photo--framed`'s 3/2 box would
   keep only the middle two thirds of a square frame, and its `max-height`
   makes that worse rather than better: with `width: 100%` the height clamp
   widens the EFFECTIVE ratio on a big screen (a 1088px-wide box clamped to
   26rem tall is 2.6:1), so a wide viewport crops harder than a narrow one.
   Same reasoning, and the same fix, as `.photo--portrait` above: cap the
   WIDTH, let the aspect ratio bound the height, and the crop is then identical
   at every viewport. */
.photo--square img {
  aspect-ratio: 1 / 1;
  max-width: 24rem;
  max-height: none;
  margin-inline: auto;
}

/* A piece of illustrative pixel art set inside a section's flow, the way
   `.hero__art` sits at the top of a hero. Centred and width-capped so it
   reads as an illustration beside the copy rather than a full-bleed band, and
   `image-rendering: pixelated` because it IS pixel art -- the same reason
   `.photo` deliberately does not set it. */
.section-art {
  margin: 0 0 1.25rem;
  text-align: center;
}

.section-art img {
  display: inline-block;
  width: 100%;
  max-width: 22rem;
  height: auto;
  image-rendering: pixelated;
}

/* ------------------------------------------------- desktop header fit -- */

/* A PHASE 1A DEFECT, fixed here because the header is shared and it therefore
   shipped on every page. At 1280 the header phone broke across THREE lines
   ("(720)" / "445-" / "5532") and the "Contact Us" label across two, which
   pushed the bar to 109px tall. The layout tests could not see it: they assert
   RELATIONSHIPS ("the nav is on the logo's row") on purpose, so they survive
   font-metric drift -- and a relationship assertion cannot tell a phone number
   on one line from the same number on three.

   NOTHING ABOUT THE WRAP ARCHITECTURE CHANGES HERE. No `:where(.js)` gate, no
   `.is-nav-open` rule and no `flex-wrap` declaration is touched; the
   `min-width: 48rem` block above still owns the one-row pin. These are extra
   declarations on the same selectors, in a later block, so source order
   settles them at equal specificity -- except `.site-nav__actions .btn`, which
   is (0,2,0) deliberately so it outranks the bare `.btn` padding it replaces.

   The break was mid-number and mid-word, so `nowrap` is the fix, and it is
   NOT gated to desktop: at 360 with JavaScript off the same two buttons are in
   flow, `.site-nav__actions` wraps, and they stack instead of overflowing. */
.site-nav__actions .btn--phone span,
.site-nav__actions .btn--primary {
  white-space: nowrap;
}

/* THE NAV FITS ONE ROW ONCE THE HEADER IS WIDE ENOUGH. The 2026-08-27 note
   that used to stand here concluded the opposite -- "six nav links do not fit
   one row at 1280, and cannot be made to without shrinking the text past
   legibility" -- and its arithmetic was right for the box it measured: the
   header bar was a plain `.wrap`, so its content box was 68rem - 2 gutters =
   1048px at EVERY viewport from 1280 to 1920, and one row needed more than
   that. What that note got wrong was the conclusion it drew, that the deficit
   was unfixable. The deficit was the 68rem cap itself, and the User settled
   the design question it was deferring on 2026-09-06: widen the header, keep
   body prose at 68rem. `--header-wrap` does that; the block at the end of this
   file has the re-measured budget. DO NOT restore the old numbers -- 1048px
   is not this header's content box any more.

   What the tightening below is still worth, and why it stays: it is what got
   one row within reach at all. It drops the 768 band from four nav rows /
   168px (where `nowrap` alone lands it, worse than the 128px it started at)
   to three rows / 118px, and it is what makes the six links measure 1019.8px
   on one row instead of 1201px. 0.875rem link text is a 1.2px reduction from
   0.95rem, not a legibility change. */
@media (min-width: 48rem) {
  .site-header__bar {
    gap: 0.75rem;
  }

  .site-header__logo img {
    /* 2.5rem, not 3rem: under `flex-wrap: nowrap` the flex line was already
       squeezing the lockup to 38px tall to make room, so this is a hair
       LARGER than what shipped -- it just stops being an accident.

       THIS IS A CAP, NOT THE RENDERED SIZE. The logo is a shrinkable flex
       item on an over-full line, so between 768 and 1023 it renders well
       BELOW this cap -- 26.8px at 768, 31.6px at 1023 -- and never touches
       40px. Raising this number alone therefore does not resize anything by
       the amount it appears to; what sets the size in this band is the
       `min-width` FLOOR in the header-width block at the end of the file.
       Governs 48rem-64rem only; 64rem up raises the cap to 3.65rem. */
    max-height: 2.5rem;
  }

  .site-nav {
    gap: 1rem;
  }

  .site-nav__list {
    gap: 0.85rem;
    /* The list still wraps -- `flex-wrap` is untouched -- but its lines now
       pack toward the buttons instead of toward the logo, so the sixth link
       lands directly under the fifth and the nav reads as one right-aligned
       block rather than as a full row plus an orphan on the far left. */
    justify-content: flex-end;
  }

  .site-nav__link {
    font-size: 0.875rem;
  }

  .site-nav__actions .btn {
    padding: 0.55rem 0.8rem;
    font-size: 0.9rem;
  }

  .site-nav__actions .btn__icon {
    width: 1.25rem;
    height: 1.25rem;
  }
}

/* A pixel icon trailing a run of body-copy text (16px, not a flex parent),
   per 2026-09-08 User request (spec §2.2) moving the About Us clock out of
   the section title and into the story paragraph, right after "Urgent." None
   of the other three inline-icon rules fit: `.section-title__icon` (2rem) is
   sized against a heading and would read as twice the line's cap height next
   to 16px text; `.lead__icon` and `.icon-list__icon` both assume a flex
   parent, which a plain trailing `<p>` icon does not have. Sized down from
   `.section-title__icon`'s 2rem to 1.5rem so it reads as part of the
   sentence, with margin on the left (not right) since it trails the text
   instead of leading a heading. Lives here, in Phase 1b round 3, rather than
   up in Phase 1a where it first shipped: `.body-icon` serves /about-us-hvac,
   the page round 3 is the designated home for, and Phase 1a above this file's
   first banner is APPENDED, NEVER EDITED IN PLACE. */
.body-icon {
  width: 1.5rem;
  height: 1.5rem;
  margin-left: 0.35rem;
  vertical-align: -0.3em;
  image-rendering: pixelated;
}

/* ================================================================
 * Hero unification breakpoint (2026-09-06).
 *
 * The desktop half of `--hero-banner-h`, in its own block at the end of the
 * file for the same reason rounds 1-3 put theirs here: the breakpoint lives
 * in exactly one place per round and is never spliced into an earlier query.
 * ================================================================ */

@media (min-width: 48rem) {
  /* 20.8rem = 332.8px = 80% of the 26rem box `.photo--hero` declared from
     this breakpoint up. Written as the arithmetic, not as 332.8px, so the
     baseline and the User's 80% are both readable in the value itself and
     neither can be silently re-derived. */
  :root {
    --hero-banner-h: calc(26rem * 0.8);
  }
}

/* ================================================================
 * Header width, and the logo that lives in it (User, 2026-09-06).
 *
 * WHAT WAS ACTUALLY WRONG: the logo was never too small -- THE HEADER WAS TOO
 * NARROW. `.site-header__bar` is a `.wrap`, so it was capped at `--wrap`
 * (68rem = 1088px, a 1048px content box). On a 1990px window that is ~470px
 * of unused space down each side, and inside that squeeze the nav had already
 * wrapped to two rows while the logo -- a shrinkable flex item on an
 * over-full line -- was crushed to 33.7px against a declared 2.5rem cap it
 * never reached. Scaling the logo inside that box could not work: the space
 * it needed was sitting beside the header, not inside it. So the fix is the
 * container, and the logo is then sized against the room that frees up.
 *
 * The three declarations below are one mechanism, in the order the browser
 * applies them: give the bar a wider ceiling, floor the logo so it can never
 * be crushed again, raise its cap so it can use the new room, then stop the
 * nav shrinking once there is provably space for it on one row.
 *
 * THE BUDGET, MEASURED IN CHROMIUM ON THE BUILT TREE, 2026-09-06. Every
 * number here is a `getBoundingClientRect` reading, not a stylesheet value:
 *
 *   Six links on one row .................... 633.8px  (+ 16px nav gap)
 *   Phone button + CTA, `nowrap` ............ 370.0px
 *   => `.site-nav` at max-content .......... 1019.8px
 *   Logo at its 3.65rem cap ................. 304.7px  (+ 12px bar gap)
 *   => one-row content box needed .......... 1336.7px
 *   + 2 x 20px `.wrap` gutters ............. 1376.7px of BORDER box
 *
 * 88rem = 1408px clears that by 31.3px, which is the safety margin: this site
 * ships NO webfont (see the top of this file), so a visitor with Montserrat
 * or Poppins actually installed measures different link widths than the
 * fallback stack does here. 87rem would leave only 15.3px of that margin and
 * 86rem = 1376px is 0.7px short outright.
 *
 * WHY THE ONE-ROW GATE IS 88rem AND NOT 80rem/1280. It is the same number on
 * purpose: THE NAV GOES TO ONE ROW EXACTLY WHEN THE BAR REACHES ITS FULL
 * WIDTH, and below that it is not a free choice but a trade. At 1280 the bar
 * is viewport-bound to a 1240px content box, 96.7px short of the 1336.7px
 * above, so a one-row nav there forces the logo down to 39.9px -- 1.18x the
 * 33.7px baseline, i.e. SMALLER than the 54.1px it gets by leaving the nav on
 * the two rows it already had. Both were measured. Widening gutters, gaps and
 * button padding recovers at most ~48px of the 96.7px, and the rest would
 * have to come out of the link text the 48rem block above already tightened
 * to 14px. So 1280-1407 spends the space on the logo, which is what the User
 * asked to grow, and nothing there is worse than what shipped.
 *
 * A media query cannot read a custom property, so the `88rem` in the gate
 * below and `--header-wrap` at the top of this file are ONE NUMBER written
 * twice. Change them together.
 *
 * RENDERED LOGO HEIGHTS, before -> after (Chromium, both JS states):
 *   768 .... 23.3 -> 26.8px   four nav rows, header 153.5px: both unchanged
 *   1024 ... 31.6 -> 42.8px   two nav rows, header 81.6px: both unchanged
 *   1280 ... 33.7 -> 54.1px   two nav rows, header 81.6px: both unchanged
 *   1920 ... 33.7 -> 58.4px   nav 2 rows -> ONE, header 81.6px unchanged
 * The header is never taller than it was at any width, and no width overflows
 * the viewport. 58.4px is the cap for exactly that reason: it is the height
 * the bar already had (the old two-row nav's 58.4px), so the logo grows into
 * space the header was already paying for and the bar stays 77.6px + 4px
 * border. Anything above 3.65rem makes the header taller, which the User
 * ruled out.
 * ================================================================ */

.site-header__bar {
  /* Later in source order than `.wrap` at equal (0,1,0) specificity, so this
     wins without an `!important` and without touching `.wrap`, which still
     governs every other block on the page. `width: 100%` is unchanged, so
     below 1408px the bar is viewport-bound and this is inert -- the widening
     only ever happens where there was empty space to reclaim. */
  max-width: var(--header-wrap);
}

@media (min-width: 48rem) {
  .site-header__logo {
    /* THE FLOOR, AND THE ONLY THING STOPPING THE CRUSH. `max-height` on the
       img is advisory here: the flex line is over-full, the logo is
       shrinkable, and it lost -- 23.3px rendered against a 40px cap. A
       `min-width` on the flex ITEM is what flex-shrink is not allowed to go
       past, and the img's `max-width: 100%` then carries the height with it.

       8.75rem = 140px wide = 26.8px tall at 768, up from 23.3px. It is a
       floor and not a pin (`flex-shrink: 0`) on purpose, so that 1024 and up
       still grow past it instead of being frozen at the tablet size.

       WHY IT IS NOT LARGER: 768 has a 728px content box, and after the bar
       gap, the nav gap and the 370px phone+CTA pair the link list is left
       with 330px - the logo's width. The list must stay at least 184px wide
       or "Indoor Air Quality Testing", the longest link, breaks across two
       lines and the header grows a row. Measured: 9rem still renders four
       rows / 149.5px, 9.25rem tips it to 171.9px. 8.75rem sits 8px inside
       that cliff -- deliberate headroom, for the same no-webfont reason as
       the 88rem above. */
    min-width: 8.75rem;
  }
}

@media (min-width: 64rem) {
  .site-header__logo img {
    /* 3.65rem = 58.4px, the enlarged cap. Reached at 1408 and above; between
       1024 and 1407 the line is still over-full so the logo renders below it
       (42.8px at 1024, 54.1px at 1280) and grows smoothly with the viewport.

       GATED AT 64rem, NOT 48rem, AND THIS IS NOT COSMETIC: the cap sets the
       flex BASE size, so raising it at 768 makes the logo shrink to a larger
       final width, which steals from the link list and takes the header to
       five nav rows / 211.9px (measured). 768-1023 keeps the 2.5rem cap and
       gets its enlargement from the floor above instead. */
    max-height: 3.65rem;
  }
}

@media (min-width: 88rem) {
  .site-nav {
    /* SIX LINKS, ONE ROW -- the User-visible point of the widening. The nav
       is a flex item whose base size is its max-content width (1019.8px, the
       list unwrapped); refusing to shrink it therefore hands the list exactly
       the width it needs and the wrap never happens. The logo stays
       shrinkable and absorbs whatever is left, which is why it and not the
       nav is what gives way if this ever gets tight again.

       Pinned by test_nav_links_are_one_row_once_the_header_is_full_width,
       together with an overflow assertion: the failure mode of
       `flex-shrink: 0` is a nav that pushes past the viewport instead of
       re-wrapping, and there is 196px of slack before that can happen
       (1368px content box, 140px logo floor, 12px gap). */
    flex-shrink: 0;
  }
}

/* ================================================================
 * Phase 1b round 4 -- PER-PAGE HERO ART FRAMING (User, 2026-09-09).
 * Spec: docs/spec/website-content-updates.md sec. 1.1, extended by sec. 0.2
 * item 1 (which folds sec. 2.1 and sec. 3.1 in: "fix the hero image" on
 * /about-us-hvac and /indoor-air-quality-testing-arvada means the CROP, not a
 * new photograph).
 *
 * APPENDED, NEVER EDITED IN PLACE, exactly as rounds 1-3 were. That matters
 * more than usual here, because the rule this round reframes -- `.hero__media
 * img` at the top of this file -- sits in the Phase 1a region. It is NOT
 * edited. Nor is it shadowed by a same-specificity copy down here that would
 * depend on source order to win: every rule below is written in the COMPOUND
 * form `.hero__media.hero__media--<page> img` -- two class selectors plus a
 * type selector, (0,2,1), against the base rule's (0,1,1) -- so it wins on
 * SPECIFICITY. Move this block anywhere in the file and the rendering is
 * identical. That is deliberate: a cascade-order dependency is invisible in a
 * diff and this is the file where that has bitten before.
 *
 * WRITE THE MODIFIER TWICE, AND HERE IS WHY. `.hero__media--about img` on its
 * own is (0,1,1), not (0,2,1): a BEM `--modifier` is still exactly ONE class.
 * That TIES the base rule `.hero__media img` and wins on source order alone.
 * The tie is not academic for the two `object-position` rules below, which
 * override a property the base rule actually sets (`object-position: center`,
 * with it at the top of this file). Move a single-class version of this block
 * above that rule and the About/IAQ framing silently reverts to centred --
 * nothing in the diff, nothing in the test run, just a wrong-looking page.
 * (The home/residential rules set `transform`, which nothing else sets, so
 * they would survive either form; the claim above has to hold for all four.)
 * The compound form is what makes the paragraph above TRUE rather than merely
 * intended. It shipped single-class once, in this block, and was corrected
 * the same day -- verified by moving the whole block above the base rule,
 * rebuilding, and confirming the computed `object-position` did not move.
 *
 * WHAT IS AND IS NOT FORKED. The shared box is untouched: `--hero-banner-h`
 * (10rem / calc(26rem * 0.8)) and the 0.2 opacity are exactly the values the
 * User fixed on 2026-09-06, and NOTHING here selects `.hero__media` itself --
 * only the img inside it. The 2026-09-06 rationale block above warns "Do not
 * 'fix' the mobile growth by special-casing commercial-hvac-arvada"; that
 * warning is about the box's HEIGHT, and no height is forked here. What is
 * forked is how the art sits inside a box whose size never changes, which is
 * the part `templates/_hero.html.j2` has always called the per-page part.
 *
 * ------------------------------------------------------------------
 * WHY object-position ALONE CANNOT DO THE HOME AND RESIDENTIAL PAGES.
 *
 * Measured in Chromium against the built tree, 2026-09-09, `object-fit:
 * cover` as shipped:
 *
 *   page          360px band          1280px band
 *   home          100%x / 98.8%y      100%x / 57.8%y   of the source visible
 *   residential   100%x / 98.8%y      100%x / 57.8%y
 *
 * 100% of the source WIDTH is visible at both widths on both pages, because
 * the band is wider-ratio (2.25:1 at 360, 3.85:1 at 1280) than the art
 * (2.222:1), so `cover` is width-bound. With no horizontal slack there is
 * nothing for `object-position`'s x to pan across: on these two pages it is
 * INERT at every width, and the y slack only moves an already-centred
 * subject. Neither `contain` nor `fill` helps -- `contain` scales the art
 * DOWN (the subject would shrink from 77.6% of the band to 44.8% at 1280,
 * the opposite of "closer in"), and `fill` stretches pixel art 1.7:1.
 *
 * So the request is not reachable on the `object-fit` axis, and the reason is
 * the art, not the rule. Measured with PIL against the committed sources:
 *
 *   art (10000x4500)                        ink bbox        of the frame
 *   hero-banner.png (home)                  4977 x 2016     49.8%w  44.8%h
 *   hero-banner-vector-20260412...(resi)    3114 x 2016     31.1%w  44.8%h
 *
 * Half to two thirds of each file is EMPTY MARGIN, and that margin is
 * rgb(30, 58, 95) -- byte-identical to `--navy: #1E3A5F`, the colour `.hero`
 * is already painted. So the band was spending most of its area drawing navy
 * onto navy, and the drawn subject sat at x=74.2% / 72.6% of the frame:
 * jammed against the right edge with the left half empty. That is what
 * "double cropped ... should be closer in" and "needs to be filled and closer
 * in on the house icon" describe.
 *
 * THE FIX IS TO FRAME THE BAND ON THE INK, and because the discarded margin
 * is the hero's own navy, clipping it costs nothing visible.
 *
 *   transform: scale(S) translateX(T)      -- translate applies FIRST
 *
 * T moves the ink's centre to the band's centre: T = 50% - <ink centre x>.
 * The whole source width is on screen (above), so source-x% and band-x% are
 * the same axis and one T holds at every width. S then zooms about the band
 * centre, which the translate has just put the ink on, so S and T are
 * independent -- change one without recomputing the other.
 *
 * S IS 90% OF THE LARGEST ZOOM THAT STILL CLIPS NOTHING, per breakpoint:
 *
 *     S = 0.9 * min(100 / ink-width-as-%-of-band, 100 / ink-height-as-%)
 *
 * The 10% is headroom, and it is why the User's "not cropped" survives being
 * zoomed: at every width below the subject ends up at exactly 90% of
 * whichever band axis binds first, so no part of the artwork can reach an
 * edge. The two breakpoints need different S because the band's aspect ratio
 * changes (2.25:1 vs 3.85:1) while the art's does not -- the same reason
 * `--hero-banner-h` itself is forked at 48rem.
 *
 *   page         width   ink in band (before)   S      ink in band (after)
 *   home          360    49.8%w  45.4%h        1.81    90.0%w  82.1%h
 *   home         1280    49.8%w  77.5%h        1.16    57.7%w  89.9%h
 *   residential   360    31.1%w  45.4%h        1.98    61.7%w  89.8%h
 *   residential  1280    31.1%w  77.5%h        1.16    36.1%w  89.9%h
 *
 * Both pages share S at 1280 because both inks are 2016px tall and the
 * height binds there; they differ at 360 because the width binds for home
 * (its ink is 4977px wide) and the height still binds for residential.
 *
 * NO VERTICAL TRANSLATE. Both ink boxes are already centred vertically in
 * their source (y centre 49.7% and 49.5%), which is under 3px of band at
 * 1280. Correcting that would be noise dressed up as precision.
 * ------------------------------------------------------------------ */

.hero__media.hero__media--home img {
  /* ink centre x = (4928 + 9904) / 2 / 10000 = 74.2%, so T = 50 - 74.2. */
  transform: scale(1.81) translateX(-24.2%);
}

.hero__media.hero__media--residential img {
  /* ink centre x = (5702 + 8815) / 2 / 10000 = 72.6%, so T = 50 - 72.6. */
  transform: scale(1.98) translateX(-22.6%);
}

@media (min-width: 48rem) {
  /* The 48rem half, gated on the same breakpoint as `--hero-banner-h` itself
     and for the same reason: the band goes from 2.25:1 to 3.85:1 there while
     the art's ratio does not move, so the axis that binds the zoom changes
     from width to height. Both pages land on the same S because the height
     binds for both and both inks are exactly 2016px tall. T is unchanged --
     the whole source width is on screen at every width, so the translate that
     centres the ink is breakpoint-independent. */
  .hero__media.hero__media--home img {
    transform: scale(1.16) translateX(-24.2%);
  }

  .hero__media.hero__media--residential img {
    transform: scale(1.16) translateX(-22.6%);
  }
}

/* ------------------------------------------------------------------
 * THE PHOTOGRAPH PAGES ARE THE OPPOSITE CASE and are fixed on the
 * `object-position` axis alone, with no transform: their sources are much
 * TALLER-ratio than the band, so `cover` is height-bound, there is a great
 * deal of vertical slack, and the only question is which horizontal slice of
 * the picture the band shows. Measured before this round:
 *
 *   page   source        of the source visible at 360   at 1280
 *   about  2160 x 2160   44.4% of the height            26.0%
 *   iaq    3630 x 2160   74.6%                          43.7%
 *
 * For `object-position: center Y`, the visible source band is
 * [Y*(1-v), Y*(1-v)+v] where v is that visible fraction -- which is how the
 * windows below were chosen rather than nudged.
 * ------------------------------------------------------------------ */

.hero__media.hero__media--about img {
  /* THE STANDING TECHNICIAN WAS DECAPITATED AT DESKTOP. His head occupies
     source y 29-41%; the centred 26% window at 1280 was y[37.0, 63.0], so
     only his chin and below survived. 38% moves the window to y[28.1, 54.1]
     -- his head is whole, and the kneeling technician's head (y 49-62%) keeps
     its top half. Both heads together span 33% of the frame and the desktop
     window is 26% tall, so they CANNOT both fit and this is a choice, not a
     tuning: the standing technician has his hands on the gas-valve wiring and
     is the subject of the alt text. At 360 the window is 44.4% tall and lands
     at y[21.1, 65.5], which holds both heads in full. */
  object-position: center 38%;
}

.hero__media.hero__media--iaq img {
  /* THE SUNSET WAS THE POINT AND IT WAS SITTING ON THE BOTTOM EDGE. The
     mountain ridge runs across source y 66-71% and the glow above it y
     58-69%; the centred 43.7% window at 1280 was y[28.2, 71.9], so the ridge
     landed hard against the bottom of the band and the whole foreground was
     gone. 60% moves the window to y[33.8, 77.5]: the glow and the ridge sit
     inside it with a strip of grassland under them. At 360 the window is
     74.6% tall and barely moves (y[12.7, 87.3] to y[15.2, 89.8]), which is
     correct -- there was nothing wrong with the mobile crop. */
  object-position: center 60%;
}

/* /commercial-hvac-arvada AND /duct-cleaning-arvada ARE DELIBERATELY ABSENT,
 * and their absence is a measured decision rather than an omission.
 *
 * COMMERCIAL asked to "fill the space" (spec sec. 1.1) and already does:
 * commercial-hero.png is 3150x779, a 4.04:1 scene with ink edge to edge and
 * no empty margin at all, and under the shared box `cover` shows 55.6%w/100%h
 * at 360 and 95.1%w/100%h at 1280 -- the band is full on both axes at both
 * widths. The request was raised against the pre-2026-09-06 layout, where
 * that page's band rendered 88.9px at 360; the shared height already answered
 * it (+80%, recorded in the rationale block above). Every fit available here
 * would make it worse: sec. 1.1 works out that `contain` renders this art
 * ~89px tall inside the 160px mobile band, "the exact opposite of fill the
 * space". So the correct change is none.
 *
 * DUCT-CLEANING has no hero image at all. It is BLOCKED on a photograph the
 * User is supplying (spec sec. 0.2 item 3, sec. 4.1) and must not be given a
 * placeholder or another page's art. Its bare `call hero()` passes no
 * `framing`, so nothing here selects it and its empty `.hero__media` renders
 * exactly as before -- which is the point: the band is emitted on that page
 * too, and it is what proves "the same size everywhere".
 * ================================================================ */

/* ==================================================================
 * 2026-09-10 -- THE HOMEPAGE HERO ART, PUT WHERE THE USER ASKED FOR IT
 *
 * This supersedes the 2026-09-09 `--home` framing above. That pass moved the
 * art but did not land it: the User reported three defects against the live
 * render, and they are one defect wearing three hats.
 *
 *   1. On a phone the art sat behind the PARAGRAPHS. Measured at 360: the
 *      band ran 354.1-514.1 while the headline ran 79.4-184.3 and the lead
 *      started at 202.5, so the artwork was 170px BELOW the last line it was
 *      supposed to be behind. Cause: `.hero__media` is `inset: 0 0 0 0` with
 *      a definite height and `margin-block: auto`, which CENTRES the band in
 *      a `.hero` whose height comes from the copy. The homepage's mobile copy
 *      is 745px tall, so a 160px band centres at mid-paragraph.
 *   2. The ink was horizontally CENTRED -- measured centre x = 49.9% at all
 *      four widths -- because `translateX(-24.2%)` above exists precisely to
 *      drag it there. The User wants the source's own composition back: empty
 *      space on the LEFT, figures on the RIGHT of the headline.
 *   3. On a real desktop window the hats and feet were cut off. Measured at
 *      1920: ink 449.0px tall in a 332.8px band, clipped 60.9px at the top
 *      and 55.3px at the bottom. NOT visible at 1280 (ink 299.3px, fits),
 *      which is why the 2026-09-09 pass missed it -- 1280 was the widest
 *      viewport anyone had measured. Cause: the band is wider-ratio than the
 *      art at every desktop width, so `object-fit: cover` is WIDTH-bound and
 *      the rendered ink height is 0.2016 x viewport width, while the band's
 *      height is fixed in rem. The two cross at ~1651px and everything above
 *      that clips. A scale tuned at one width cannot fix a defect whose size
 *      is a function of width.
 *
 * THE MECHANISM: SIZE THE FRAME, DON'T ZOOM IT. `object-fit: cover` draws the
 * whole source and crops NOTHING when the box it fills has the source's own
 * aspect ratio. hero-banner.png is 10000x4500 = exactly 20:9, so an <img> box
 * of `h` by `h * 20 / 9` renders the complete frame at a size we chose. `h` is
 * in rem, so the rendered ink is the same number of pixels at 360 and at 4000
 * and symptom 3 cannot come back at ANY width -- it is not tuned out, it is
 * designed out. `object-fit` stays `cover` and `object-position` stays
 * `50% 50%` (both inert at this ratio: zero slack on either axis), so the
 * shared rules and their guard test are untouched, and spec sec. 0.8's
 * finding that an `object-fit` FORK is inert still holds -- this is not one.
 *
 * INK GEOMETRY, measured with PIL against the committed source (bbox of every
 * pixel that differs from the rgb(30,58,95) margin, which is byte-identical to
 * `--navy`):
 *
 *   bbox (4928, 1229) - (9905, 3245) of 10000 x 4500 = 4977 x 2016
 *   x 49.28% .. 99.05%     y 27.3111% .. 72.1111%
 *
 * so for a frame of height A and width W = A * 20/9:
 *
 *   ink height = 0.448 * A          ink width  = 0.4977 * W = 1.106 * A
 *   ink top    = 0.273111 * A below the frame's top
 *   ink right  = 0.0095 * W = 0.0211111 * A left of the frame's right edge
 *
 * The 0.0095 term is why `right` below subtracts it: we are positioning the
 * INK's right edge, not the file's, and the file carries a right margin.
 *
 * A IS CHOSEN SO THE INK IS AS TALL AS THE HEADLINE, which is what "aligned
 * with the headline" means in pixels. Measured heights of the h1's own text
 * rects: 106.9px at 360, 182.6px at 768 and above (the `clamp()` caps at
 * 3.25rem, so 768, 1280 and 1920 all render the same type).
 *
 *   A = 15rem   -> ink 265.4 x 107.5   (mobile;  headline 106.9)
 *   A = 25.5rem -> ink 451.3 x 182.8   (>=48rem; headline 182.6)
 *
 * THE BAND IS TOP-PINNED ON THIS PAGE, AND ONLY ON THIS PAGE. The art cannot
 * reach the headline from a centred band -- at 360 the band's top is 290px
 * below the headline's, and `overflow: hidden` stops the <img> escaping it.
 * So `.hero__media--home` re-pins the band to the hero's top, which puts the
 * headline inside it at every width, and the <img> is then placed within the
 * band. The 2026-09-06 "center-justified instead of top" decision still
 * governs the other five pages;
 * `test_hero_banner_band_is_vertically_centred_in_the_hero` now asserts
 * centring on those five and top-pinning here, so neither can drift.
 * The band's HEIGHT and the art's 0.2 OPACITY are untouched -- the shared box
 * is not forked, only the band's offset inside its own hero.
 *
 * 0.875rem = 14px is measured, not assumed: `.hero__inner` has 1rem of top
 * padding and the h1's first line rect starts 2px above its border box (half
 * leading), so the headline's first glyph row is 14px below the hero's top on
 * every width. Putting the ink's top there aligns art and type by their tops.
 *
 * HORIZONTALLY THE INK'S RIGHT EDGE MEETS THE CONTENT COLUMN'S RIGHT EDGE,
 * derived from `--wrap` and `--gutter` rather than re-typed, so it tracks the
 * headline if either token moves. Everything to the ink's left is empty --
 * 74.6px at 360, 712.8px at 1280, 1032.8px at 1920 -- which is the voidspace
 * the User asked for, and the figures read as standing to the right of the
 * headline. There is no `translateX` any more; the source's own composition
 * is the point, and dragging it to centre was the bug.
 * ================================================================== */

.hero__media.hero__media--home {
  /* `bottom: auto` is the whole change: it un-over-constrains the box, so the
     auto block margins that centred it resolve to zero and `top: 0` wins.
     Left, right and height are exactly as the shared rule set them. */
  inset: 0 0 auto 0;
  margin-block: 0;
}

.hero__media.hero__media--home img {
  --home-art-h: 15rem;
  --home-art-w: calc(var(--home-art-h) * 20 / 9);
  position: absolute;
  width: var(--home-art-w);
  height: var(--home-art-h);
  /* MEASURED, NOT DEFENSIVE. The base `img { max-width: 100% }` at :115 is
     narrower than the frame below 48rem (533.3px of frame in a 360px band)
     and silently clamped it to the band's width, which un-does the 20:9 ratio
     the whole approach rests on -- `cover` then crops after all. It does not
     bind at 1280, so this reproduced ONLY at mobile widths. */
  max-width: none;
  /* ink top -> 14px below the hero's top, i.e. the headline's first line. */
  top: calc(0.875rem - var(--home-art-h) * 0.273111);
  /* ink right -> the content column's right edge. `100%` is the band, which
     is the viewport; the max() is `.wrap`'s auto margin, and it collapses to
     0 below --wrap exactly as `margin: 0 auto` does. */
  right: calc(
    max(0px, (100% - var(--wrap)) / 2) + var(--gutter)
    - var(--home-art-w) * 0.0095
  );
  /* Replaces `scale(1.81) translateX(-24.2%)` and its 48rem sibling above.
     The frame is sized and placed outright now, so there is nothing to zoom
     and nothing to pan. */
  transform: none;
}

@media (min-width: 48rem) {
  /* Same breakpoint as `--hero-banner-h`, for the same reason it was forked
     there: the headline's type stops growing at 3.25rem here, so the ink
     height that matches it changes exactly once. Only the one variable moves;
     every offset above is written in terms of it. */
  .hero__media.hero__media--home img {
    --home-art-h: 25.5rem;
  }
}

/* ==================================================================
 * 2026-09-10 (SECOND PASS) -- THE DESKTOP ART, GROWN TO ITS CEILING
 *
 * A USER DECISION ON A TRADEOFF THE FIRST PASS DOCUMENTED. That pass tied
 * `--home-art-h` to the headline's measured type height, so the ink was
 * exactly as tall as the words (182.6px of type, 182.8px of ink at >=48rem).
 * Review observed that this left the navy band two thirds empty -- 182.8px of
 * ink in a 332.8px band -- and that because the no-clip mechanism is the
 * source's own 20:9 ratio in rem, it is clip-proof at ANY size, so a bigger
 * desktop figure was available for the asking. The User asked. This is that.
 *
 * WHAT ACTUALLY LIMITS THE SIZE, MEASURED IN CHROMIUM RATHER THAN REASONED:
 * it is NOT the band. The ink's top is pinned to the headline's first line of
 * type (that is symptom 1 and it does not move), so every rem of extra height
 * is spent downwards, and the first thing below the headline is the lead
 * paragraph. The band has 136px of slack under the ink; the lead has 29.8px.
 *
 *   at >=48rem, page pixels, homepage:
 *     ink top        95.6   (= hero top + 14px, fixed, independent of A)
 *     headline type  95.6 - 278.2
 *     lead top      308.1
 *     band          81.6 - 414.4
 *
 *   ink height = 0.448 * A, so clearance(A) = 308.1 - 95.6 - 0.448 * A
 *
 *     A = 25.5rem (408px)   ink 182.8   clearance 29.8   <- first pass
 *     A = 28rem   (448px)   ink 200.7   clearance 11.9   <- HERE
 *     A = 28.54rem(456.6px) ink 204.5   clearance  8.0   <- the test's floor
 *     A = 29.66rem(474.5px) ink 212.5   clearance  0.0   <- ink TOUCHES the lead
 *     A = 44.5rem (712px)   ink 318.8   clearance -106   <- where the BAND
 *                                                          would have bound
 *
 * So the honest ceiling is 29.66rem, not "the band is 332.8 tall so make the
 * art 332.8". Between 29.66rem and 44.5rem the art is inside the band and on
 * top of the body copy, which is the defect the User reported first
 * ("it should overlap the HEADLINE only"). Measured at 32rem: the ink ends
 * 16.8px BELOW the lead's first line.
 *
 * 28rem IS CHOSEN WITH THAT MARGIN STATED: 1.66rem (26.5px of frame, 11.9px
 * of ink) below the touch point, and 0.54rem below the 8px floor
 * `HOME_INK_LEAD_CLEARANCE_PX` asserts. 28.5rem measures 8.3px of clearance --
 * inside the floor by 0.3px -- which is a hair-trigger, not a margin, so the
 * last half-rem is deliberately left on the table.
 *
 * WHAT IT BUYS: ink 451.2 x 182.8 -> 495.5 x 200.7, +9.8% on each axis and
 * +20.6% of area. Modest, because the vertical budget was only ever the
 * headline-to-lead gutter. Reported as such rather than dressed up.
 *
 * THE OTHER FIVE CONSTRAINTS ARE MEASURED, NOT ASSUMED, at 360/768/1280/1920/
 * 2560 (`test_home_hero_art_is_whole_and_sits_right_of_the_headline`):
 *
 *   - CLIPPING stays zero at every width. It is structural, not tuned: only
 *     `--home-art-h` moves and it stays in rem, so the ink is the same 200.7px
 *     at 1280, 1920 and 2560 and the test's spread assertion still states it.
 *     Nothing viewport-relative enters the chain -- that would kill
 *     width-independence, which is the whole fix.
 *   - THE BAND still contains it with 118.1px to spare at >=48rem.
 *   - VOIDSPACE ON THE LEFT survives: a taller frame is a wider frame and it
 *     grows leftwards from a pinned right edge, so this had to be checked.
 *     Ink centre 65.1% / 71.6% / 64.4% / 60.8% at 768 / 1280 / 1920 / 2560
 *     (was 68.0 / 73.3 / 65.5 / 61.7; floor 55%), void to its left 32.9% /
 *     52.2% / 51.5% / 51.1% (was 38.6 / 55.7 / 53.8 / 52.8; floor 15%). The
 *     narrowest desktop width, 768, is the worst case and clears by 17.9pts.
 *   - THE INK'S TOP is untouched: `top` is written as
 *     `0.875rem - A * 0.273111`, which cancels A out of the ink's top edge
 *     exactly, so the alignment holds at any A. Measured gap to the
 *     headline's first line: under 0.01px at every width.
 *   - MOBILE DOES NOT MOVE. This override is inside the same 48rem query as
 *     the value it replaces; the 15rem below it is untouched, and the shrink
 *     there is forced (at 360 the headline ends 184.3px in and the lead starts
 *     202.5px in -- 18.2px of slack, and the ink already uses all but 17.6 of
 *     it). Measured identical to 4 decimal places at 360 across every
 *     candidate from 25.5rem to 32rem.
 *
 * APPEND-ONLY, so the 25.5rem figures in the block above are left standing as
 * the record of the first pass; this block supersedes them. `--hero-banner-h`,
 * `HERO_BAND_H`, the 0.2 opacity, the 20:9 mechanism, every offset `calc()`
 * and all five other pages are untouched -- one variable moves.
 * ================================================================== */

@media (min-width: 48rem) {
  .hero__media.hero__media--home img {
    --home-art-h: 28rem;
  }
}

/* ================================================================
 * HOMEPAGE SECTIONS PASS -- User mobile review 2026-09-10.
 * Two rules, both new, both added for markup that moved on index.html.j2.
 *
 * COMPOUND / DESCENDANT SELECTORS ON PURPOSE. A BEM `--modifier` is ONE class
 * and ties with its base rule, winning only on source order (spec sec. 0.4),
 * and this stylesheet is append-only with other branches landing at its end.
 * Neither rule below may depend on where it sits in the file:
 *   `.card .offer-badge` is (0,2,0) against `.offer-badge`'s (0,1,0), so it
 *   wins on specificity, not order.
 *   `.card__offer-line` is a brand-new class with no competing rule at all.
 * ================================================================ */

.card .offer-badge {
  /* The badge moved INSIDE the commercial card (item 3). `.offer-badge` sets
     `margin: 0`, which was right when it was the only child of its own
     `section--offer`; in a card it now has a Learn More button under it and
     needs flow spacing. Scoped to `.card` so commercial-hvac-arvada.html.j2's
     copy of the badge -- which is NOT in a card and shares every
     `.offer-badge*` rule with this one -- is untouched.

     Contrast is unchanged by the move: the badge paints its own `--pale`
     background, so its text measures the same here as it does anywhere,
     whatever sits behind it. Since the sec.7 pass (2026-09-11) that text is
     `--navy` on `--pale` = 9.48:1; it was `--orange-dark` at 3.51:1. */
  margin: 0 0 1.25rem;
}

.card__offer-line {
  /* The residential card's dated furnace-replacement line (item 4).

     SIZE WAS A CONTRAST DECISION, NOT A TASTE ONE. `--orange-dark` measured
     4.26:1 on the card's white background -- under WCAG AA's 4.5:1 for body
     text, over the 3:1 that applies to large text -- so 1.2rem (19.2px) at
     weight 700 was required to clear WCAG's 18.66px bold bound, which 1.15rem
     (18.4px) would NOT have.

     The sec.7 palette pass (2026-09-11) moved this to `--orange-cta`
     (4.85:1 on this card's white background), so the colour now passes AA
     at ANY size and
     the size is no longer load-bearing for contrast. It is kept at 1.2rem/700
     as a typographic choice, and a test asserts both. */
  color: var(--orange-cta);
  font-family: var(--font-head);
  font-weight: 700;
  font-size: 1.2rem;
}
/* ================================================================
 * Footer badges 2x2 + Contact Us removal (User mobile review 2026-09-10).
 *
 * Appended overrides rather than in-place edits, per this file's
 * append-only convention. Uses the `.site-footer__badges--grid` modifier
 * CLASS added in `_footer.html.j2`, compounded with the base class, so the
 * override wins on genuine specificity -- NOT on source order. (An earlier
 * commit on this branch mis-justified a bare-selector repeat as following
 * "the same pattern as the Phase 1b banner above" -- that citation was
 * wrong: the Phase 1b block is a comment divider, not a rule, and no such
 * precedent exists in this file. Retracted; see spec §0.4, which documents
 * this exact same-specificity/source-order trap for BEM modifiers and is
 * the actual authority here.)
 *
 * WHY: the User asked for all four credential badges (three existing +
 * the BBB mark, moved up out of the legal strip) in one 2x2 block, and
 * for the footer's "Contact Us" heading/phone/email to go, since they
 * duplicate the pinned mobile call bar and the desktop header phone CTA.
 *
 * SUPERSESSION: `.site-footer__badges`'s base-rule comment above
 * ("Kept to one row... item 1.11") describes a flex `nowrap` fix for
 * THREE badges wrapping 2+1 at 360px. That concern cannot recur here --
 * a CSS grid with a fixed 2-column track lays out 2x2 unconditionally,
 * it does not wrap at all. This is a deliberate new instruction from the
 * same User, not a regression of the 2026-09-07 fix.
 *
 * COHERENT SET, BBB EXCEPTED (User decision 2026-09-10, review round 2):
 * the three credential badges are 120x120 and keep the square
 * `aspect-ratio: 1/1` + `object-fit: contain` treatment below. The BBB
 * mark's source raster is measured at 3840x2160 (16:9) -- the markup's
 * OLD `140x60` attributes (neither the real ratio nor the real pixel
 * count) were only ever a pre-load size hint; `_footer.html.j2` now
 * carries `width="160" height="90"`, a 16:9 pair matching the measured
 * raster, so the pre-CSS reserved box already has the right shape.
 * Forcing 16:9 art into a 1:1 box left it letterboxed to the vertical
 * middle third of its white card, reading as a smaller, lighter
 * afterthought next to the three edge-to-edge circular badges. The BBB
 * cell now gets its own `aspect-ratio: 16/9` (see the `img.bbb-badge`
 * rule below) so the mark fills its card the way the other three fill
 * theirs, while `.bbb-badge`'s white-card background/border/padding
 * (site.css:1128-1138) is untouched -- the mark must stay legible on navy
 * regardless of shape.
 *
 * DESKTOP: measured at 1280px, the footer `.wrap` caps at 68rem (1088px)
 * minus gutters, so a `2fr 1fr` grid gives the badges column roughly
 * 320-340px -- enough for a comfortable 2x2 block (2 x 120px badges +
 * gap), but a single row of 4 badges (~530px incl. gaps) would not fit
 * without either shrinking the badges below their mobile size or taking
 * width from the service-area column. 2x2 at every width was chosen so
 * the badge footprint never changes, and to avoid recreating the exact
 * class of uneven-wrap bug item 1.11 was fixed for, now with a fourth
 * badge in the mix. The grid's 2 equal-`fr` columns are computed from the
 * container, not from item content, so letting the BBB cell's own image
 * grow wider than 120px (below) cannot itself widen its track or knock
 * the 2x2 crooked -- it just fills more of the track it already has.
 * ================================================================ */

.site-footer__badges.site-footer__badges--grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 1rem;
  align-items: center;
}

.site-footer__badges.site-footer__badges--grid img {
  /* `width: 100%; max-width: 120px` -- NOT `width: auto`. Measured: with
     `auto`, Chromium collapses the box to 0x0 at 360px before the image
     finishes loading, because there is no other definite size for
     `aspect-ratio` to resolve against pre-load. `100%` gives every badge a
     definite size from its grid track immediately, then `max-width` clamps
     it. Also measured: `justify-items: center` has no visible effect once
     width is a percentage (Chromium places the clamped box at the track's
     start edge, not centered) -- removed rather than left in as a comment
     that doesn't match what renders. The result is still a clean, evenly
     offset 2x2 (both columns' badges share one left edge; verified at
     360px and 1280px), just not centered within each cell. */
  width: 100%;
  max-width: 120px;
  aspect-ratio: 1 / 1;
  object-fit: contain;
}

.site-footer__badges.site-footer__badges--grid img.bbb-badge {
  /* Higher specificity (0,3,1) than the generic img rule above (0,2,1),
     so this wins unconditionally, not on source order. No `max-width`: the
     grid track (fixed by the 2-column layout, independent of content) is
     already the card's outer bound, so the image fills exactly to the
     track's edge at every width instead of stopping at an arbitrary
     square-badge number that has nothing to do with its own ratio. The
     16/9 figure is the raster's measured intrinsic ratio (3840x2160), not
     the markup's now-corrected `160x90` size hint -- see the block
     comment above. */
  aspect-ratio: 16 / 9;
  max-width: none;
}

@media (min-width: 48rem) {
  /* Contact Us column removed from the footer grid (User mobile review
     2026-09-10) -- was `2fr 1fr 1fr` (area / contact / badges). Two
     columns remain; badges keep roughly their prior share of the row.

     Review round 3, Fix A: `.site-footer__grid` alone is (0,1,0), the exact
     same specificity as the base override at line ~1206 (also (0,1,0), also
     inside this same media query) -- they tie and this one only won because
     it sits later in the file, which is the trap this file's own review
     process exists to catch. `.site-footer__grid` only ever appears as the
     `<div class="wrap site-footer__grid">` child of `<footer
     class="site-footer">` (verified: every occurrence across
     website/templates and the built website/dist pages), so qualifying with
     the ancestor is safe and gives this rule genuine (0,2,0) specificity,
     independent of source order. */
  .site-footer .site-footer__grid {
    grid-template-columns: 2fr 1fr;
  }

  /* No `.site-footer__badges--grid { grid-template-columns: ... }` rule
     here on purpose -- deleted a no-op that briefly lived in this spot.
     The unconditional rule above already sets `repeat(2, 1fr)` and this
     media query has never overridden it to anything else; restating the
     same value here would be dead weight a future editor could "fix" by
     changing its value and see nothing happen. Badges stay 2x2 at desktop
     too -- see the DESKTOP note in the block comment above for the
     measured widths behind that choice. */
}

/* ================================================================
   CONTACT FORM: the client-side validation messages (User, 2026-09-11)

   APPENDED AT THE END OF THE FILE, LIKE EVERY BLOCK BEFORE IT, AND FOR A
   REASON THAT IS NOT STYLE. `docs/spec/website-content-updates.md` navigates
   this stylesheet by `site.css:<line>` citations reaching past :1900, and
   `test_spec_file_line_citations_point_at_what_they_claim` fails the moment an
   insertion higher up shifts them. Appending is the only way to add a rule
   here without invalidating citations that have nothing to do with it.

   --orange-cta IS THE ONLY ORANGE THAT MAY CARRY THIS TEXT. Both copies of the
   form are painted on white (`.contact-dialog` sets `--white`; the inline copy
   falls through to `body`), where this token measures 4.85:1 -- clear of AA's
   4.5:1 for normal-size text, which is what 0.9rem/600 is (WCAG's large-text
   bound is 18.66px bold, and 14.4px is nowhere near it). The retired
   --orange-dark managed only 4.26:1 on white; see the token's own comment. The
   test measures this live off the painted background rather than trusting the
   sentence above.
   ================================================================ */

/* The either/or rule, told BEFORE it is broken. Plain inherited ink, not
   orange: it is an instruction, not an error, and colouring it like one would
   make the real errors below mean less. */
.field__hint {
  margin: 0 0 1rem;
  font-size: 0.9rem;
}

.field__error {
  margin: 0.35rem 0 0;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--orange-cta);
}

/* Belt and braces over the UA's own `[hidden] { display: none }`. The error
   boxes ship in the DOM `hidden` and site.js unhides them; if any rule above
   ever gives `.field__error` a display value, `hidden` would stop hiding and
   every visitor -- including one with JavaScript off -- would see four empty
   orange gaps under the form. Stating it here makes that impossible. */
.field__error[hidden] {
  display: none;
}

/* ================================================================
   FOOTER BADGES: full width, beneath the service-area text (User request
   2026-09-11).

   APPENDED, NEVER EDITED IN PLACE, like every block above. The 2026-09-10
   block near :2607 stays exactly as written -- it is still the phone
   layout, unchanged by construction: everything below either applies at
   every width (the `grid-column` span, which does not touch the inner
   track count) or is inside `min-width: 48rem`.

   SELECTOR SHAPE IS PINNED BY A TEST, deliberately. Both container rules
   below repeat the compound `.site-footer__badges.site-footer__badges--grid`
   verbatim because `test_footer_badges_grid_modifier_is_a_compound_class_selector`
   (tests/website/test_website_build.py) asserts EVERY badges-grid container
   selector in the built CSS is exactly that string. An ancestor-qualified
   variant (`.site-footer .site-footer__badges...`), which would otherwise be
   the house fix for a source-order tie, fails that test -- so the desktop
   `repeat(4, 1fr)` below does win on source order over the equal-specificity
   `repeat(2, 1fr)` at :2607. That is safe here in a way the BEM trap of spec
   sec. 0.4 is not: the two rules are not competing definitions of one layout,
   they are a base and a media-query refinement of it, and this file is
   append-only, so nothing may be inserted between them that reorders them.

   WHY FULL WIDTH: previously the badges were the narrow `1fr` column of the
   footer's `2fr 1fr` desktop grid, sitting BESIDE the service-area copy.
   `grid-column: 1 / -1` drops them onto their own row spanning both columns,
   under the "...the entire metro area!" line. Below 48rem the footer grid
   has a single implicit column, so `1 / -1` is that one column and nothing
   moves.

   WHY THE BBB CLAMP: the three credential badges carry `max-width: 120px`,
   but the BBB image deliberately has `max-width: none` (see the 2026-09-10
   block) so it can fill its track. In the old narrow column that track was
   ~170px and the mark measured 162x91 at 1280 -- near its own 160x90 size
   hint, so it read as one of four evenly-weighted badges. Across the full
   width the four tracks are 250px each, and the same `max-width: none`
   measures 250x141 (Chromium, 1280, measured both ways on the built site),
   twice the footprint of the 120x120 badges beside it and tall enough to
   push the whole row's height up with it. That is not "the badges keep
   their size", so the desktop clamp below holds the mark at its own
   declared 160x90: measured 162x91 before this change, 160x90 after.
   `aspect-ratio` is restated because
   `test_footer_bbb_badge_gets_its_own_native_ratio` requires every
   badges-grid BBB rule to declare the measured 16:9 ratio.
   ================================================================ */

.site-footer__badges.site-footer__badges--grid {
  grid-column: 1 / -1;
}

@media (min-width: 48rem) {
  .site-footer__badges.site-footer__badges--grid {
    grid-template-columns: repeat(4, 1fr);
  }

  .site-footer__badges.site-footer__badges--grid img.bbb-badge {
    aspect-ratio: 16 / 9;
    max-width: 160px;
  }
}
