/* Self-hosted web fonts — Zilla Slab + Libre Franklin, from the @fontsource packages 2026-10-14.
 * Nothing is fetched from fonts.googleapis.com or fonts.gstatic.com: that would put two
 * third-party hosts on the critical render path and need two CSP exceptions back.
 * csp-allows-external-resources.test.js fails the build if a page ever links one.
 * Latin subsets only, with the unicode-range Google serves for them.
 *
 * 2026-10-09 swap: Barlow Condensed (display) and Source Sans 3 (body) were replaced by
 * Archivo and IBM Plex Sans. Barlow Condensed reads as a sports/equipment-rental flyer at
 * display size and Source Sans 3 has no voice; together they gave the page a generated
 * look the owner called out. Archivo is an engineered grotesque with presence; IBM Plex
 * Sans pairs natively with the Plex Mono already here, so the three faces are one system
 * rather than three unrelated picks. Same total weight: ~80 KB across 4 files, was ~79 KB
 * across 5. Plex Mono 600 went with them — mono is now licence and spec data only, never
 * an eyebrow, so one weight covers it.
 *
 * 2026-10-14 swap: Archivo and IBM Plex (Sans + Mono) were replaced by Zilla Slab (display) and
 * Libre Franklin (body). The owner's read of the 10-09 set was still "looks generated", and the
 * tells were the treatment as much as the faces: mono uppercase letterspaced labels, tight negative
 * tracking on headings, an arrow on every link, rounded dark panels floated inside the page. Zilla
 * Slab has real weight at display size and Franklin Gothic is the American trade/newspaper face;
 * neither is a default a generator reaches for. Mono is gone entirely: labels are the body face in
 * sentence case. Three files, ~82 KB, against four and ~82 KB before. Picked from two rendered
 * directions 2026-10-14.
 *
 * Licenses and provenance: /assets/fonts/LICENSES-2026-10-14.txt.
 *
 * Slab-face digits are lining figures. Zilla Slab's default figures are old-style: 3, 4, 5, 7 and 9
 * drop about 0.13-0.14em below the baseline (3.1-3.4px at 24px, measured 2026-10-15), so the phone
 * number in the slab face read as uneven on a phone and its underline broke around the dropping
 * digits (skip-ink). `body` sets lining-nums for elements to inherit. The `font:` shorthand resets
 * font-variant-numeric to normal on the element it is written on, so a rule that sets the slab
 * face with the shorthand (`.footer-phone`) repeats it. The many Libre Franklin rules that use the
 * shorthand need nothing: Franklin's default digits are already lining. tests/e2e/lining-figures
 * .test.js fails on any slab-face text with digits that is not lining.
 */
@font-face {
  font-family: "Zilla Slab";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/assets/fonts/zilla-slab-600-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Zilla Slab";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/assets/fonts/zilla-slab-700-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Libre Franklin";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/assets/fonts/libre-franklin-variable-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* SPCom Solutions — site stylesheet
 *
 * Plain CSS, no framework, no build step beyond the ?v= content-hash stamp.
 * Breakpoints: 960 (grids), 1000 (header collapses), 888 (split wraps), 680, 480 and 360 (phones).
 *
 * Palette: "Slate & Signal", chosen by the owner 2026-09-24 from five mockups.
 * Cool gray-blue surfaces carry the trust signal a security/IT contractor wants;
 * the logo's red is used as a signal color — CTAs, links, eyebrows — not a flood.
 * The four vendor logos are white-on-transparent and only read on a dark surface,
 * so the header, vendor strip and footer are slate; everything else is light.
 *
 * The audit's "first screen sells nothing" finding drives the hero: the desktop
 * above-the-fold carries a value proposition, a visible phone number and a CTA.
 */

:root {
  --bg:        #F3F5F7;
  --panel:     #FFFFFF;
  --ink:       #1E2731;
  --heading:   #2E3A47;
  --muted:     #5D6B7A;
  --line:      #D5DCE3;
  --line-soft: #C7D0DA;
  --field-line:#758394;   /* form field borders: 3.5:1 on --bg, 3.9:1 on white (WCAG 1.4.11 needs 3:1) */

  --accent:    #D7263D;   /* signal red — buttons, eyebrows */
  --accent-dk: #B81828;   /* hover, and links on white (6.5:1) */
  --accent-tx: #8E0A12;   /* text on the soft tint */
  --accent-sf: #FBE3E6;   /* soft tint — city chips */

  --slate:     #2E3A47;   /* header, vendor strip */
  --slate-dk:  #1E2731;   /* footer */
  --slate-ln:  #3D4B5A;   /* rules on slate */
  --on-slate:  #C7D0DA;   /* body text on slate */
  --on-slate-m:#8896A5;   /* muted text on slate */
  --brand-hi:  #F28B96;   /* "Specialized" in the header wordmark */

  --band:      #0B0B0B;   /* the red-streak CTA band */

  --focus:     #D7263D;

  --maxw: 1120px;
  --gutter: 24px;
  --radius: 3px;
  --radius-lg: 3px;
  --header-h: 76px;     /* the sticky header's row height; 60px at 680 and below */

  --font-head: "Zilla Slab", Rockwell, Georgia, serif;
  --font-body: "Libre Franklin", "Franklin Gothic Medium", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

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

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 16.5px/1.6 var(--font-body);
  font-variant-numeric: lining-nums; /* after the shorthand, which resets it; see the font notes */
  overflow-x: hidden; /* guard: e2e asserts no horizontal scroll at 375px */
}

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

/* The header is sticky, so the skip link's jump to #main would put main's first lines under it.
   scroll-margin on main reserves the header's row height plus its 1px border, read from the same
   token as the row's min-height so the minimum and the reserve move together.
   Not `html { scroll-padding-top }`: in Chromium that makes focusing any control inside the
   sticky header (Tab, or the menu's Escape handler) scroll the page up by hundreds of pixels,
   because the header sits inside the padded band. tests/e2e/skip-link.test.js guards both. */
main { scroll-margin-top: calc(var(--header-h) + 1px); }

a { color: var(--accent-dk); }
a[href^="tel:"] { white-space: nowrap; } /* never break a number across lines */
a:focus-visible,
button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }

/* No negative tracking: tight-tracked grotesque headings are the SaaS-template look. The slab
   serifs also need a line-height that lets them clear each other. */
h1, h2, h3 { font-family: var(--font-head); line-height: 1.12; text-wrap: balance; margin: 0 0 14px; color: var(--heading); letter-spacing: 0; }
h1 { font-size: 54px; font-weight: 700; line-height: 1.08; }
h2 { font-size: 38px; font-weight: 700; margin-top: 52px; }
h3 { font-size: 22px; font-weight: 600; margin-top: 28px; }
/* 70ch at 17px runs past 600px — long enough that the eye loses the line. */
p  { margin: 0 0 16px; max-width: 64ch; }
ul { max-width: 64ch; }
li { margin: 6px 0; }

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

.skip-link {
  position: absolute; left: -9999px;
  background: var(--accent); color: #fff; padding: 10px 16px; z-index: 100;
}
.skip-link:focus { left: 12px; top: 12px; }

/* ---------- header (slate) ----------
 *
 * The brand is the cloud mark on its own plus the company name as live text.
 *
 * 2026-10-14: until now the header carried the whole 470px logo (cloud, "SPECIALIZED" lettering,
 * five service icons) in a 44px white chip, where the lettering was a smear, and the owner reads
 * "SPCom Solutions" as an abbreviation. The lockup now follows the logo's own typography: bold
 * caps for the name with the first word in the highlight red, and "SOLUTIONS" spaced out beneath,
 * which is how the logo file itself sets that word (white, spaced, under the icon row). That is
 * the one place letterspaced caps are allowed on this site: it is the owner's lettering, not a
 * UI label. The mark is cut from the 1180px logo he supplied, with the white matte stripped from
 * its edges so it sits clean on slate. The chosen direction was B of three rendered mockups.
 */

.site-header {
  background: var(--slate);
  border-bottom: 1px solid var(--slate-ln);
  position: sticky; top: 0; z-index: 40;
}
.site-header .wrap {
  display: flex; align-items: center; justify-content: space-between;
  gap: 20px; min-height: var(--header-h);
}
.brand {
  display: inline-flex; align-items: center; gap: 12px;
  color: #fff; text-decoration: none; white-space: nowrap; line-height: 1;
}
.brand img { width: auto; height: 48px; flex-shrink: 0; }
.brand-name { display: block; font-family: var(--font-body); text-transform: uppercase; }
.brand-line { display: block; font-size: 15px; font-weight: 800; letter-spacing: 0.02em; }
.brand-first { color: var(--brand-hi); }
.brand-sub {
  display: block; margin-top: 4px;
  font-size: 11px; font-weight: 600; letter-spacing: 0.22em; color: var(--on-slate);
}

.nav-toggle {
  display: none; align-items: center; justify-content: center; position: relative;
  background: none; border: 1px solid var(--slate-ln); border-radius: var(--radius);
  padding: 9px 12px; font: 600 14px var(--font-body); color: #fff; cursor: pointer;
}
/* The icon only shows on phones (680 and down), where it stands in for the label. */
.nav-toggle-icon { display: none; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }
.nav-toggle[aria-expanded="true"] .nav-toggle-bars { display: none; }
.nav-toggle[aria-expanded="false"] .nav-toggle-x { display: none; }

.site-nav ul { display: flex; gap: 20px; list-style: none; margin: 0; padding: 0; }
.site-nav a {
  text-decoration: none; color: var(--on-slate); font-weight: 600; font-size: 15px;
  padding: 6px 0; border-bottom: 2px solid transparent;
}
.site-nav a:hover { color: #fff; border-bottom-color: var(--accent); }
.site-nav a[aria-current="page"] { color: #fff; border-bottom-color: var(--accent); }

.header-cta {
  display: inline-flex; align-items: center; gap: 8px;
  background: var(--accent); color: #fff; text-decoration: none;
  font: 700 15px var(--font-body);
  padding: 11px 18px; border-radius: var(--radius); white-space: nowrap;
}
.header-cta:hover { background: var(--accent-dk); }

/* ---------- 1000: the header stops fitting ----------
 *
 * Added 2026-10-09 with the Archivo + IBM Plex Sans swap. Barlow Condensed was a CONDENSED
 * face; Archivo is not, so the header's intrinsic minimum width went 767px -> 865px and the
 * three nowrap items (wordmark, nav, phone) no longer shared a row below ~865px. Because
 * body{overflow-x:hidden} suppresses the scrollbar, the overflow did not scroll — the red
 * phone CTA was silently CLIPPED, reading "321" at iPad portrait width on all 19 pages.
 * The nav therefore collapsed to the hamburger at 880 rather than 680.
 *
 * 2026-10-14: Zilla Slab at 28px and Libre Franklin nav links were wider again (925px
 * intrinsic), and the breakpoint moved to 940. Later the same day the full company name
 * replaced "SPCom Solutions". Measured on the homepage with fonts loaded at 1280: mark 78 +
 * 12 gap + name 263 = brand 353, nav 396 (link gap trimmed 26 -> 20), phone 141, two 20px gaps
 * and two 24px gutters = 978px. Breakpoint 1000 for a 22px margin, which keeps the full nav on
 * an iPad in landscape (1024). tests/e2e/header-fits.test.js measures both sides of it.
 */
@media (max-width: 1000px) {
  .nav-toggle { display: inline-flex; }
  .site-nav {
    display: none;
    position: absolute; left: 0; right: 0; top: 100%;
    background: var(--slate); border-bottom: 1px solid var(--slate-ln);
    padding: 8px var(--gutter) 18px;
  }
  .site-nav.is-open { display: block; }
  .site-nav ul { flex-direction: column; gap: 0; }
  .site-nav li { border-bottom: 1px solid var(--slate-ln); }
  .site-nav a { display: block; padding: 13px 0; border-bottom: none; }
  .header-cta { padding: 10px 14px; font-size: 14px; }
}

/* ---------- 680: the phone header ----------
 *
 * Three items in 375px: the mark with the name on three lines, an icon-only menu button, and
 * the "Email us" button. (Until 2026-10-14 the third item was the phone number, which the 2026-09
 * audit found reachable only inside the hamburger on the live site; on phones the number now has
 * its own call bar pinned to the bottom of the screen, see "call bar" below.) .brand-first goes block so the lockup reads SPECIALIZED / COMMUNICATIONS /
 * SOLUTIONS, the way the logo stacks it. The button's "Menu"/"Close" label stays in the DOM,
 * visually hidden, so the button keeps its accessible name. The brand takes margin-right:auto
 * so the menu button sits beside "Email us" instead of floating mid-row.
 */
@media (max-width: 680px) {
  :root { --header-h: 60px; }
  .site-header .wrap { gap: 10px; }
  .brand { margin-right: auto; gap: 7px; }
  .brand img { height: 34px; }
  .brand-first { display: block; }
  .brand-line { font-size: 11px; line-height: 1.2; }
  .brand-sub { font-size: 8.5px; margin-top: 1px; letter-spacing: 0.18em; }
  .nav-toggle { width: 36px; height: 36px; padding: 0; }
  .nav-toggle-icon { display: block; }
  .nav-toggle-label {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
    overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
  }
  .header-cta { padding: 9px 10px; font-size: 13px; }
}

/* ---------- 360: smallest phones ----------
 * 320px is the floor tests/e2e/header-fits.test.js measures. Measured, not estimated.
 */
@media (max-width: 360px) {
  .site-header .wrap { gap: 6px; }
  .brand { gap: 6px; }
  .brand img { height: 24px; }
  .brand-line { font-size: 10px; }
  .brand-sub { font-size: 8px; }
  .header-cta { font-size: 12px; padding: 8px 5px; }
}

/* ---------- hero (white) ---------- */

.hero {
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  padding: 64px 0 56px;
}
.hero .eyebrow {
  font: 600 15px/1.3 var(--font-body); letter-spacing: 0;
  color: var(--muted); margin-bottom: 14px;
}
.hero h1 { margin-bottom: 14px; }
.hero .lede { font-size: 19px; color: var(--muted); max-width: 62ch; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 26px; }

/* Homepage variant: copy on the left, a real photo on the right. */
.hero--photo .wrap { display: flex; align-items: center; gap: 48px; }
.hero--photo .hero-copy { flex: 1 1 0; min-width: 0; }
/* Most jobsite photos are portrait phone shots of vertical things -- panels, racks,
   antenna masts. Cropping them to a landscape hero slot would cut the subject in half,
   so they get their own taller, narrower slot instead. */
/* A photo inside the page body, with its caption. */
.figure { margin: 26px 0; }
.figure img { width: 100%; border-radius: var(--radius-lg); }
.figure figcaption { font-size: 14px; color: var(--muted); margin-top: 8px; }
.figure--half { max-width: 420px; }
/* Project gallery: square tiles so portrait and landscape jobsite photos sit in one
   even grid without cropping the subject out of either. */
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin-top: 20px; }
.tile { margin: 0; }
.tile img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; border-radius: var(--radius); background: var(--bg-alt); }
.tile figcaption { font-size: 14px; line-height: 1.45; color: var(--muted); margin-top: 8px; }
.hero-photo {
  flex: 0 0 380px; width: 380px;
  border-radius: var(--radius-lg);
  box-shadow: 0 2px 6px rgba(30, 39, 49, .18);
}
/* Must come AFTER .hero-photo: same specificity, so source order decides. The portrait
   jobsite shots get a narrower, taller slot than the landscape ones. */
.hero-photo--tall { flex: 0 0 300px; width: 300px; height: 400px; object-fit: cover; }

.btn {
  display: inline-flex; align-items: center; gap: 8px;
  font: 700 17px var(--font-body); text-decoration: none;
  padding: 14px 24px; border-radius: var(--radius); border: 1px solid transparent;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-dk); }
.btn-secondary { background: var(--panel); color: var(--heading); border-color: var(--line-soft); }
.btn-secondary:hover { border-color: var(--heading); }

/* ---------- content ---------- */

main { padding-bottom: 72px; }
.section { padding-top: 8px; }
.geo-line {
  font: 600 15px var(--font-body); color: var(--muted);
  margin: -4px 0 22px;
}

.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 26px 0 8px; }
.card {
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 22px 22px 18px;
}
.card h3 { margin-top: 0; }
.card p { font-size: 16px; color: var(--muted); margin-bottom: 10px; }
.card a { font-weight: 600; text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
.card a:hover { color: var(--accent); }

.panel {
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--radius-lg); padding: 24px 26px; margin: 28px 0;
}

/* Comparison tables (Cat6/Cat6a/fiber, hosted vs on-premises PBX). A real table on wide screens;
   on phones each row stacks into a card and every cell is prefixed by its column header from
   data-label, so nothing is wider than the viewport (site-wide-rendering.test.js fails any
   element past the right edge, scrollable or not). */
.compare { width: 100%; border-collapse: collapse; margin: 24px 0 0; font-size: 15px; }
.compare caption { text-align: left; font: 600 15px var(--font-body); color: var(--ink); padding: 0 0 8px; }
.compare th, .compare td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
.compare thead th { background: var(--panel); }
.compare tbody th { font-weight: 600; }
@media (max-width: 680px) {
  .compare thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
  .compare tr { display: block; border: 1px solid var(--line); border-radius: 6px; margin: 0 0 10px; }
  .compare th, .compare td { display: block; border: 0; border-top: 1px solid var(--line); }
  .compare tbody th { background: var(--panel); border-top: 0; }
  .compare td::before { content: attr(data-label) ": "; font-weight: 600; }
}

.city-list { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 16px 0 0; }
.city-list li {
  background: var(--accent-sf); color: var(--accent-tx);
  border-radius: 4px; padding: 6px 12px; font: 600 14px var(--font-body); margin: 0;
}

.credential {
  display: inline-flex; align-items: center; gap: 8px;
  font: 600 14px var(--font-body); color: var(--ink);
  background: var(--panel); border: 1px solid var(--line);
  border-radius: 4px; padding: 8px 14px;
}

/* License chip + Control4 badge + one line of proof, sitting under the hero. */
.credential-row {
  display: flex; align-items: center; flex-wrap: wrap; gap: 14px 22px;
  padding: 18px 0;
}
.credential-row .credential { margin: 0; }
.badge-control4 {
  display: inline-flex; align-items: center;
  background: var(--panel); border: 1px solid var(--line); border-radius: 4px;
  padding: 6px 14px;
}
.badge-control4 img { height: 34px; width: auto; }

/* Founder panel on /about/: portrait beside the bio. */
.about-portrait { display: flex; align-items: flex-start; gap: 26px; }
.about-portrait .portrait {
  flex: 0 0 160px; width: 160px; height: 160px; object-fit: cover;
  border-radius: var(--radius-lg);
}
.about-portrait > div { flex: 1 1 0; min-width: 0; }

.breadcrumb { font-size: 14px; color: var(--muted); padding: 18px 0 0; }
.breadcrumb ol { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; }
.breadcrumb li + li::before { content: "/"; margin-right: 8px; color: var(--line-soft); }
.breadcrumb a { color: var(--muted); }

/* ---------- vendor strip (slate) ---------- */

.vendor-strip {
  background: var(--slate); color: var(--on-slate);
  padding-top: 30px; padding-bottom: 32px; margin-top: 52px; margin-bottom: 8px;
}
.vendor-label {
  font: 600 14px/1 var(--font-body);
  color: #B8C2CB; margin: 0 0 18px;
}
.vendor-logos {
  display: flex; align-items: center; justify-content: space-between; gap: 40px;
  padding: 0;
}
.vendor-logos img { width: auto; }
.vendor-logos img:nth-child(1) { height: 40px; }
.vendor-logos img:nth-child(2) { height: 34px; }
.vendor-logos img:nth-child(3) { height: 26px; }
.vendor-logos img:nth-child(4) { height: 28px; }

/* ---------- CTA band: the owner's red-streak artwork ---------- */

/* Last thing in <main> on every page that has it, so the negative bottom margin cancels
   main{padding-bottom:72px} and the band meets the footer edge to edge. */
.cta-band {
  background: var(--band) url("/assets/band-red-streaks-1600w.webp") right bottom / cover no-repeat;
  color: #fff;
  border: 0; border-radius: 0;
  padding-top: 56px; padding-bottom: 52px; margin-top: 60px; margin-bottom: -72px;
}
/* Full-bleed dark bands. .wrap is centred, so 50% of the containing block minus 50vw is exactly
   the distance to the viewport edge, and the padding puts the content back on the wrap's grid.
   Rounded dark panels floated inside the page were the most template-looking thing left. */
.vendor-strip, .cta-band {
  margin-left: calc(50% - 50vw); margin-right: calc(50% - 50vw);
  padding-left: max(var(--gutter), calc(50vw - var(--maxw) / 2 + var(--gutter)));
  padding-right: max(var(--gutter), calc(50vw - var(--maxw) / 2 + var(--gutter)));
}
.cta-band h2 { color: #fff; margin-top: 0; }
.cta-band p { color: #D6CFCF; max-width: 58ch; }
.cta-band .hero-actions { margin-top: 20px; }
.cta-band .btn-secondary { background: transparent; color: #fff; border-color: rgba(255, 255, 255, .45); }
.cta-band .btn-secondary:hover { border-color: #fff; }


/* ---------- direction A "Field Record" (2026-10-09) ----------
 *
 * The homepage used to be sixteen identical bordered cards stacked down the page — three
 * virtue cards, eight service cards, four testimonials, one area panel — all the same
 * border, radius, padding and rhythm. That uniformity is what read as machine-made. These
 * components replace cards with photographs and hairline rules. They are homepage-only for
 * now; the other eighteen pages keep .cards and just inherit the new type.
 */

/* Full-bleed photo hero. The scrim is doing real work: the van shot is bright sky across
 * the top third, and white text over it failed contrast in the mockup. Two layers — a flat
 * wash for the whole frame, a left-weighted gradient under the column that holds the text. */
.hero.hero--cover {
  position: relative; isolation: isolate;
  min-height: 540px; display: flex; align-items: flex-end;
  background: #0E1216; border-bottom: 0; padding: 0; overflow: hidden;
}
.hero--cover > .hero-bg {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: -2;
}
/* 2026-10-14: the right half was so dark the vans — the one thing in the frame that proves the
 * business is real — read as shadow. The wash now holds under the text column (the lede ends near
 * 47% at 1440 now that it is capped at 44ch, the headline near 62%) and is all but gone by the
 * right edge: ~54% darkening there before, ~2% now, and the flat wash over the whole frame is
 * dropped. Where the headline's tail crosses the white van, the text-shadow below does the
 * contrast work the wash no longer does. Second pass after the owner's "still too dark". */
.hero--cover::after {
  content: ""; position: absolute; inset: 0; z-index: -1;
  background:
    linear-gradient(90deg, rgba(10, 14, 18, .86) 0%, rgba(10, 14, 18, .70) 44%, rgba(10, 14, 18, .14) 70%, rgba(10, 14, 18, .02) 100%);
}
.hero--cover .eyebrow, .hero--cover h1, .hero--cover .lede {
  text-shadow: 0 1px 3px rgba(0, 0, 0, .6), 0 2px 24px rgba(0, 0, 0, .45);
}
.hero--cover .wrap { padding-top: 72px; padding-bottom: 64px; }
.hero--cover .hero-copy { max-width: 46rem; }
.hero--cover h1 { color: #fff; }
.hero--cover .eyebrow { color: #D5DCE3; }
.hero--cover .lede { color: #E6EAED; max-width: 44ch; } /* ends inside the dark half of the wash */

/* The credential chips became a single hairline strip. Same three facts, one line, no boxes. */
.proof-rule {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px 28px;
  padding: 18px 0; border-bottom: 1px solid var(--line);
  font: 600 15px var(--font-body);
  color: var(--muted); margin: 0;
  /* Spacing separates the facts. A "/" glyph between them orphaned at the end of a
     wrapped row, and at --line-soft it was 1.43:1 against the strip anyway. */
  /* It is a <p>, so the global p{max-width:64ch} clamped it to ~500px and broke it onto
     three lines even at full width. The strip is chrome, not prose: it opts out. It still
     wraps on narrow viewports by design — one line above ~1000px, two to four below. */
  max-width: none;
}
.proof-rule img { height: 26px; width: auto; }

/* Services as photographs. Five carry a real jobsite shot; the three with no photo yet get a
 * slate tile instead of a placeholder, which looks deliberate and doubles as the shot list. */
.svc-tiles {
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 30px 28px; margin: 36px 0 8px;
}
.svc-tile { margin: 0; }
.svc-tile img {
  width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block;
  background: #EEF1F3;
}
.svc-tile h3 { margin: 15px 0 6px; }
.svc-tile p { font-size: 15px; line-height: 1.5; color: var(--muted); margin: 0; max-width: none; }
.svc-tile a { font-weight: 600; text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
.svc-tile a:hover { color: var(--accent); }
.svc-tile--nophoto {
  background: var(--slate); padding: 20px;
  display: flex; flex-direction: column; justify-content: flex-end; min-height: 232px;
}
.svc-tile--nophoto h3 { color: #fff; margin-top: 0; }
.svc-tile--nophoto p { color: #B8C2CB; }
.svc-tile--nophoto a { color: #fff; }

/* Text against a photograph, full width, no card around either half. */
.split {
  display: flex; flex-wrap: wrap; align-items: stretch;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  margin: 56px 0 0;
}
/* The proof strip already closes with a hairline, so a split directly under it would draw a
   second rule 56px below the first. The strip's border is the band's top edge instead. */
main .wrap > .split:first-child { margin-top: 0; border-top: 0; }
.split-copy { flex: 999 1 460px; min-width: 0; padding: 56px 48px 56px 0; }
.split-copy h2 { margin-top: 0; }
.split-media { flex: 1 1 380px; min-width: 0; min-height: 380px; position: relative; }
.split-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

/* Quotes on rules instead of in boxes. Three, not four — the fourth was orphaned on its own
 * row in a three-column grid, which is the open cosmetic item in tasks/todo.md. */
.quotes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 38px; margin: 34px 0 8px; }
.quote { margin: 0; border-top: 2px solid var(--ink); padding-top: 20px; }
.quote blockquote { margin: 0 0 16px; font-size: 18px; line-height: 1.55; color: #222A31; }
.quote figcaption {
  font: 600 15px var(--font-body);
  color: var(--heading);
}

/* Plain two-column city list — the pink chips read as tags for something clickable. */
.area-cols {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 8px 24px; margin: 18px 0 0; list-style: none; padding: 0; max-width: none;
}
.area-cols li { margin: 0; font-size: 16px; }

/* ---------- footer (dark slate) ---------- */

.site-footer {
  background: var(--slate-dk); color: var(--on-slate);
  padding: 44px 0 30px; font-size: 15px;
}
.site-footer a { color: #fff; }
.footer-grid { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 32px; }
.site-footer h4 {
  font: 700 15px/1 var(--font-body);
  color: #fff; margin: 0 0 12px;
}
.site-footer ul { list-style: none; margin: 0; padding: 0; }
.site-footer li { margin: 6px 0; }
.site-footer li a { text-decoration: none; }
.site-footer li a:hover { text-decoration: underline; }
.footer-nap p { margin: 0 0 8px; max-width: none; }
.footer-phone { font: 700 24px var(--font-head); font-variant-numeric: lining-nums; color: #fff; text-decoration: none; }
.footer-license { font: 500 14px var(--font-body); color: var(--on-slate-m); margin-top: 12px; }
.footer-google { font-size: 14px; margin-top: 10px; }
.footer-bottom {
  border-top: 1px solid var(--slate); margin-top: 30px; padding-top: 18px;
  font-size: 13px; color: var(--on-slate-m);
}

/* ---------- contact form (/contact/#contact-form) ----------
 * Netlify Forms: a plain same-origin POST, so it works without JS and the CSP's form-action 'self'
 * covers it. The header's "Email us" button lands on #contact-form, under the sticky header, so the
 * section reserves the header's height the same way main does. Inputs inherit the body size, which
 * is 16px on phones: anything smaller makes iOS Safari zoom in on focus. */
#contact-form { scroll-margin-top: calc(var(--header-h) + 1px); }
.contact-form {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px;
  max-width: 760px; margin-top: 22px;
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field--full { grid-column: 1 / -1; }
.field label { font: 600 15px var(--font-body); color: var(--heading); }
.field .optional { font-weight: 400; color: var(--muted); }
.field input,
.field select,
.field textarea {
  width: 100%; font: inherit; line-height: 1.4; color: var(--ink); background: var(--panel);
  border: 1px solid var(--field-line); border-radius: var(--radius); padding: 12px 14px;
}
/* A select ignores line-height, so both get the same floor or the select sits 4px short. */
.field input,
.field select { min-height: 50px; }
.field textarea { min-height: 150px; resize: vertical; }
.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible { outline: 3px solid var(--focus); outline-offset: 1px; }
.contact-form .btn { justify-self: start; cursor: pointer; }

/* ---------- call bar (phones) ----------
 * Every page carries one tel: link pinned to the bottom of the screen at 680 and below, so the
 * number is always one tap away (owner's request, 2026-10-14; the header's top-right button became
 * "Email us" the same day). The hero's own Call button hides at that width rather than showing the
 * same red button twice on the first screen. The footer gets the bar's height as extra bottom
 * padding so its last line can scroll clear of the bar, and scroll-padding-bottom keeps a field or
 * link reached by Tab from being scrolled to just under it.
 * The height floor keeps the bar off short screens: a desktop window zoomed to 200-400% is under
 * 680 CSS px wide too, and there the sticky header plus the bar would cover half the screen.
 * tests/e2e/call-bar.test.js guards all of it. */
.call-bar { display: none; }
@media screen and (max-width: 680px) and (min-height: 420px) {
  html { scroll-padding-bottom: 78px; }
  .call-bar {
    display: flex; align-items: center; justify-content: center;
    position: fixed; z-index: 30;
    left: var(--gutter); right: var(--gutter); bottom: max(12px, env(safe-area-inset-bottom));
    min-height: 54px; padding: 0 20px;
    background: var(--accent); color: #fff; text-decoration: none;
    font: 700 17px var(--font-body); border-radius: var(--radius);
    box-shadow: 0 6px 18px rgba(10, 14, 18, .35);
  }
  .call-bar:hover { background: var(--accent-dk); }
  .hero .hero-actions a[href^="tel:"] { display: none; }
  .site-footer { padding-bottom: calc(30px + 78px + env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 680px) {
  .contact-form { grid-template-columns: minmax(0, 1fr); }
  .contact-form .btn { justify-self: stretch; justify-content: center; }
}

/* ---------- 960 ---------- */
@media (max-width: 960px) {
  h1 { font-size: 46px; }
  h2 { font-size: 31px; }
  .cards { grid-template-columns: repeat(2, 1fr); }
  .tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .footer-grid { grid-template-columns: 1fr 1fr; }
  .hero--photo .wrap { flex-direction: column; align-items: stretch; gap: 28px; }
  .hero-photo { flex-basis: auto; width: 100%; max-width: 560px; }
  .hero-photo--tall { flex-basis: auto; width: 100%; max-width: 360px; height: 420px; margin: 0 auto; }
  .vendor-logos { flex-wrap: wrap; justify-content: center; gap: 28px 40px; }
  .svc-tiles { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .quotes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* .split wraps when its content box drops under 460+380=840px, i.e. around 888px of viewport —
   some 200px before the 680 breakpoint. Zero the inner gutter there or the copy sits 60px from
   the edge while the photo and the section rule stop at 24px. */
@media (max-width: 888px) {
  .split-copy { padding: 44px 0; }
}

/* ---------- 680 ---------- */
@media (max-width: 680px) {
  body { font-size: 16px; }
  .hero { padding: 44px 0 40px; }
  h1 { font-size: 38px; }
  h2 { font-size: 27px; margin-top: 38px; }
  .hero .lede { font-size: 17px; }
  .hero--cover { min-height: 460px; }
  /* One column of text over the photo: the gradient has to cover the full width now. */
  .hero--cover::after { background: linear-gradient(180deg, rgba(10,14,18,.62) 0%, rgba(10,14,18,.86) 100%), rgba(10,14,18,.32); }
  .hero--cover .wrap { padding-top: 48px; padding-bottom: 44px; }
  .svc-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 26px 20px; }
  .quotes { grid-template-columns: 1fr; gap: 30px; }
  .split { margin-top: 40px; }
  .split-copy { flex-basis: 100%; padding: 36px 0; }
  .split-media { flex-basis: 100%; min-height: 280px; }
  .area-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .proof-rule { font-size: 13px; gap: 8px 14px; }


  .cards { grid-template-columns: 1fr; }
  .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
  .footer-grid { grid-template-columns: 1fr; gap: 26px; }
  .about-portrait { flex-direction: column; }
  /* Longhands only: a padding shorthand here would override the full-bleed left/right padding. */
  .cta-band { padding-top: 30px; padding-bottom: 28px; }
  .vendor-strip { padding-top: 22px; padding-bottom: 24px; }
}

/* ---------- 480 ---------- */
@media (max-width: 480px) {
  :root { --gutter: 16px; }
  h1 { font-size: 32px; }
  .hero-actions .btn { width: 100%; justify-content: center; }
  .vendor-logos img:nth-child(1) { height: 32px; }
  .vendor-logos img:nth-child(2) { height: 28px; }
  .vendor-logos img:nth-child(3) { height: 22px; }
  .vendor-logos img:nth-child(4) { height: 24px; }
}

/* ---------- motion ----------
 * One load reveal on the hero copy, and one entry reveal per section further down, added by
 * script.js as .reveal and switched on as .is-in. Nothing staggers, nothing moves sideways, and a
 * section already on screen when script.js runs never gets .reveal, so the first screen does not
 * fade in on load; one that crosses the fold when the fonts swap in simply fades once. Without JS
 * no element carries .reveal, so nothing is ever hidden; print shows everything regardless. */
@media (prefers-reduced-motion: no-preference) {
  .hero-copy { animation: hero-in .55s ease-out both; }
  @keyframes hero-in {
    from { opacity: 0; transform: translateY(14px); }
    to   { opacity: 1; transform: none; }
  }
  .reveal { opacity: 0; transform: translateY(14px); transition: opacity .45s ease-out, transform .45s ease-out; }
  .reveal.is-in { opacity: 1; transform: none; }
}
/* transition: none too, or a parked section fades in over 450ms when the media switches, and a
 * print started inside that window captures it half-faded. 2026-10-16: seen once script.js was
 * deferred and so ran earlier relative to the load event than the parser-blocking tag had. */
@media print {
  .reveal { opacity: 1; transform: none; transition: none; }
}

/* The .js class is added by the inline head snippet whose sha256 is pinned in
   netlify.toml's CSP. Without JS the nav is always visible rather than hidden
   behind a toggle that cannot work. */
html:not(.js) .site-nav { display: block; }
html:not(.js) .nav-toggle { display: none; }
/* Below the collapse the nav is an absolute panel under a sticky header. Without JS nothing can
   close it, so it covered the hero and, being sticky, ~340px of every screen after. Put it in flow
   on its own row inside a header that scrolls away. tests/e2e/header-no-js.test.js guards it. */
@media (max-width: 1000px) {
  html:not(.js) .site-header { position: static; }
  html:not(.js) .site-header .wrap { flex-wrap: wrap; align-content: flex-start; }
  /* Wrapping splits the row's min-height across both lines; keep the logo row at full height. */
  html:not(.js) .brand { min-height: var(--header-h); }
  html:not(.js) .site-nav { position: static; order: 3; flex: 0 0 100%; padding: 0 0 12px; border-bottom: 0; }
}
