/* =============================================================================
   AMERICAN CONSTRUCTION. ONE STYLESHEET, ONE SOURCE OF TRUTH.
   Every page links this file (home as css/site.css, subpages as /css/site.css).
   There is no second stylesheet and no <style> block on any page. A rule that
   only one page needs still lives here, scoped to that page's own class prefix.

   ORDER
     1  Fonts (self hosted)
     2  Tokens
     3  Reset and base
     4  Layout primitives
     5  Buttons
     6  Header, nav, mobile menu
     7  Mobile action bar
     8  Home page sections
     9  Trade page rules (.svc-)
    10  License page rules (.cred-)
    11  Gallery modal
    12  Forms
    13  Footer
    14  Responsive
    15  Reduced motion
    16  Nav dropdowns
    17  Content depth sections (.acp-depth, .acp-prose, .acp-table)

   Palette is unchanged from the 2026-08-10 build: navy ground, brand red,
   silver. Contrast ratios in the comments were computed, not guessed.
   ============================================================================= */

/* =============================================================================
   1. FONTS. Self hosted latin subsets, downloaded from Google Fonts on
   2026-08-11 and served from assets/fonts/. Both files are variable fonts, so
   one file per family covers every weight we use. No Google Fonts link tag, no
   CSS import, no third party request at render time.
   ============================================================================= */
@font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 400 600;
    font-display: swap;
    src: url('/assets/fonts/inter-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: 'Inter Tight';
    font-style: normal;
    font-weight: 600 700;
    font-display: swap;
    src: url('/assets/fonts/inter-tight-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;
}

/* =============================================================================
   2. TOKENS
   ============================================================================= */
:root {
    /* Radius. 16px and 24px are gone: they read soft against construction work.
       Nothing may exceed 14px. --radius-lg (14px) was deleted on 2026-08-11
       with zero uses; the 50% on the review avatars stays a literal, because a
       circle is not a step on this scale.
       Shadows: --shadow-panel is the only one on the site. It was a raw
       rgba(0,0,0,.5), which is pure black over a navy surface and reads as a
       smudge rather than a shadow. Built from --color-primary instead, the
       darkest ink already in the palette. */
    --radius-sm: 6px;
    --radius-md: 10px;
    --shadow-panel: 0 18px 44px rgba(10, 21, 36, 0.62);

    /* Colours, sampled off the TEMP (unapproved) badge. Logo navy median
       #021E42, red #D00B18, silver #F9F9F8. Values unchanged. */
    --color-primary: #0A1524;
    /* Deep navy surface */
    --color-primary-text: #F2F4F7;
    /* Near white headings, 16.6:1 on --color-primary */
    --color-accent: #E5202F;
    /* Brand red. #FFF on it hits 4.59:1 (AA) */
    --color-accent-hover: #F05563;
    --color-accent-soft: #F05563;
    /* Red for TEXT on dark: 5.66:1 on bg, 5.09:1 on surface. The base
       --color-accent is only 4.19:1 on bg, so any red TEXT uses this one.
       Red as a FILL or a rule (borders, icon strokes, button backgrounds) may
       use --color-accent. */
    --color-accent-hero: #FF6E64;
    /* PUSHED REDDER 2026-08-14 on Blake's direction, from #FF8A93. It measures
       3.32:1 against the lightest pixel behind the headline, which is the
       66,73,82 sunlit van flank in the table below, so it still clears the 3:1
       fence with room.
       PUSHED AGAIN the same day, #FF6E79 to #FF6E64. Note WHICH channel moved:
       blue, from 121 to 100, with red and green held. Blue is the pink in this
       colour and it carries almost no luminance weight (0.0722), so taking it
       out reads as a real shift from pink to red and costs only 0.04 of
       contrast. Dropping GREEN is what darkens it: #FF6069 lands at 3.09, too
       close to the 3:1 fence to survive any future lightening of the scrim. If
       more red is asked for again, the honest answer is that the photo has to
       get darker first.
       HERO HEADLINE ONLY. Lighter than --color-accent-soft, and it
       exists for exactly one reason: the hero red sits over a sunlit white van,
       not over --color-bg, so it is the only red on the site whose contrast is
       set by a photograph. #F05563 clears 3:1 there only at the old heavy
       scrim; this tint clears it at the lighter scrim Blake asked for on
       2026-08-13. See the measurement table above .hero-photo .hero-overlay.
       Do NOT use this token anywhere else, and do not merge it back into
       --color-accent-soft: on flat dark backgrounds the soft red is correct
       and this one is needlessly washed out. */
    --color-bg: #060F1C;
    --color-surface: #0E1B2E;
    --color-text: #E9EDF3;
    --color-text-muted: #A9B4C4;
    /* 9.16:1 on --color-bg, AA */
    --color-border: #1E2E45;
    --color-border-hover: #4C6A94;
    /* Card hover border. 3.12:1 on --color-surface, 3.47:1 on --color-bg, so it
       clears the 3:1 non-text contrast floor. The old literal #2C4260 was
       1.69:1 on surface. */
    --color-surface-hover: #14243B;
    /* One notch up from --color-surface, so a card hover is not carried by the
       1px border alone. This is the value the area-link hover was already using
       as a raw hex; a separate #142338 was briefly introduced for the card
       hover and the two measured 1.01:1 apart, i.e. the same colour, so they
       are one token. */
    --color-white: #FFFFFF;
    --color-steel: #C6D0DC;
    /* 12.3:1 on bg */
    --color-error: #F0A0A8;

    /* Promoted from raw hexes 2026-08-11. All five were already in the sheet as
       literals; none of them changed value, they only got names. */
    --color-accent-press: #C21020;
    /* Filled-button hover. A darker red than --color-accent, not the lighter
       --color-accent-soft, which is the TEXT red. White on it is 6.20:1. */
    --color-accent-press-active: #A50D1A;
    /* Filled-button active. White on it is 7.86:1. */
    --color-ink: #050D18;
    /* Deepest surface on the site: the footer and the dark button's hover.
       Below --color-bg, which is why it is not one of the surface steps. */
    --color-placeholder: #8595A9;
    /* Form placeholder text. 5.65:1 on the input's own --color-surface. */

    --color-success: #6FCF97;
    /* Success used to alias --color-accent-soft, i.e. the brand RED, so a
       submitted form and a failed one were the same hue. This is a desaturated
       green that sits with the navy: 10.11:1 on --color-bg and 9.09:1 on
       --color-surface, both well past AA. Colour is never the only signal, the
       success message is prefixed with the word "Sent". */

    /* Aliases so a token name reads as a role, not a colour. */
    --color-background: var(--color-bg);
    --color-brand-primary: var(--color-accent);
    --color-brand-hover: var(--color-accent-hover);

    /* Type */
    --font-display: 'Inter Tight', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
    --font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
    --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;

    --text-hero: clamp(46px, 5.2vw, 72px);
    --text-h2: clamp(36px, 3.6vw, 52px);
    --text-h3: 24px;
    --text-lead: 18px;
    --text-body: 16px;
    --text-small: 14px;
    --text-label: 13px;

    /* Tracking. The 12px uppercase tier had drifted to four values (0.10, 0.12,
       0.16 and 0.18em) with no rule about which meant what, so labels that sit
       side by side were spaced differently for no reason. Two roles now:
       an eyebrow above a heading, and every other small uppercase label. The
       11px brand-sub (0.24em) and the 14px link tier (0.10em) are their own
       sizes and are deliberately not folded in here. */
    --tracking-eyebrow: 0.18em;
    --tracking-label: 0.12em;

    /* Legacy aliases kept so inherited rules and subpages do not break. */
    --text-h1: var(--text-hero);
    --text-body-lg: var(--text-lead);

    /* Spacing.
       The t-shirt scale is inherited. --space-xs (8px), --space-xl (96px) and
       --space-2xl (128px) were deleted on 2026-08-11: zero uses each. What was
       actually repeated in the stylesheet were four values the scale had no
       name for, so they are named below by ROLE rather than by size. */
    --space-sm: 16px;
    --space-md: 32px;
    --space-lg: 64px;

    --space-section-head: 44px;
    /* The gap under a section's eyebrow+heading block, and the footer grid's
       row gap. Nine uses. */
    --space-field: 18px;
    /* Form field rhythm: the gap between rows, inside the split row, and around
       the consent block. */
    --space-inline: 14px;
    /* The gap between two buttons or two inline items on one line. */

    --tap-min: 44px;
    /* Minimum pointer target. Deliberately its OWN token even though it is also
       44px: it is the WCAG target floor, not a spacing step, and if the rhythm
       above is ever retuned this must not move with it. */

    /* Rhythm */
    --section-pad: 104px;
    --hero-pad: 60px;
    --gutter: 64px;
    --grid-gap: 24px;
    --card-pad: 28px;

    /* Constraints.
       MEASURES. The inner max-widths were seven ad hoc numbers: 760, 780, 820,
       860, 1000, 1080 and 1200. Two pairs were near-identical and doing the
       same job, so 760 collapsed into 780 and 820 into 860, leaving five, each
       named for what it constrains. A new inner width uses one of these or it
       does not get one. */
    --measure-prose: 780px;
    /* Section heads and lead paragraphs. A comfortable line length. */
    --measure-list: 860px;
    /* Stacked lists that read as a column: the FAQ, the single review rail. */
    --measure-pair: 1000px;
    /* A two-up grid that should not run the full page width. */
    --measure-grid: 1080px;
    /* Multi-column grids inside a section. */
    --max-width: 1200px;
    /* The page container itself. */
    --nav-height: 78px;
    --action-bar-height: 66px;
}

/* =============================================================================
   3. RESET AND BASE
   ============================================================================= */
*,
*::before,
*::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

html {
    scroll-behavior: smooth;
    scroll-padding-top: calc(var(--nav-height) + 24px);
    -webkit-text-size-adjust: 100%;
}

body {
    font-family: var(--font-body);
    font-size: var(--text-body);
    line-height: 1.65;
    background-color: var(--color-bg);
    color: var(--color-text);
    /* clip, not hidden. overflow-x:hidden makes the element a scroll container,
       which silently kills position:sticky inside it and gives the page a
       programmatically scrollable x axis it never uses. clip does the same
       visual job with neither side effect. */
    overflow-x: clip;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

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

ul {
    list-style: none;
}

a {
    color: inherit;
    text-decoration: none;
}

h1,
h2,
h3,
h4,
h5,
h6 {
    font-family: var(--font-display);
    color: var(--color-primary-text);
}

h1 {
    font-size: var(--text-hero);
    font-weight: 700;
    line-height: 0.98;
    letter-spacing: -0.045em;
    /* Kills one-word orphans in the long service and area page headings
       ("...San Diego / County", "...in Chula / Vista"). Progressive
       enhancement: browsers without it fall back to normal wrapping. */
    text-wrap: balance;
}

h2 {
    font-size: var(--text-h2);
    font-weight: 700;
    line-height: 1.04;
    letter-spacing: -0.035em;
    /* Same reason as h1 above: these headings are short and set large, so the
       browser's greedy line breaker leaves one-word last lines. Progressive
       enhancement, browsers without it wrap normally. */
    text-wrap: balance;
}

/* Running copy gets `pretty` rather than `balance`: it only fixes the last
   line, so a paragraph never ends on a single orphaned word, and unlike
   balance it does not re-measure every line of a long block. */
p {
    text-wrap: pretty;
}

h3 {
    font-size: var(--text-h3);
    font-weight: 650;
    line-height: 1.15;
    letter-spacing: -0.02em;
}

h4,
h5,
h6 {
    font-size: var(--text-lead);
    font-weight: 600;
    line-height: 1.25;
    letter-spacing: -0.01em;
}

*:focus-visible,
:where(a, button, summary, [tabindex]):focus-visible {
    outline: 2px solid var(--color-accent-soft);
    outline-offset: 3px;
}

*:focus:not(:focus-visible) {
    outline: none;
}

.skip-link {
    position: absolute;
    left: -9999px;
    top: 0;
    z-index: 10002;
    background: var(--color-accent);
    color: var(--color-white);
    padding: 12px 20px;
    font-family: var(--font-body);
    font-weight: 600;
    border-radius: 0 0 var(--radius-sm) 0;
}

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

/* =============================================================================
   4. LAYOUT PRIMITIVES
   Every section on the site sits on this one alignment system. A section that
   wants a different measure caps its own inner block, never the container.
   ============================================================================= */
.container {
    width: calc(100% - var(--gutter));
    max-width: var(--max-width);
    margin-inline: auto;
}

.section-pad {
    padding-block: var(--section-pad);
}

/* SECTION RHYTHM. Every band ran at exactly --section-pad top and bottom, so
   the whole page had one interval and no section was grouped with, or set
   apart from, any other. Two deliberate exceptions, both expressed as a
   multiple of the token so they still collapse with it on mobile:

   1. The differentiator band is the payoff of the services grid directly above
      it, so the pair closes up. Two full pads either side of the colour change
      put 208px between the grid and its own conclusion.
   2. The reviews band is the one section carrying other people's words, and it
      earns the pause on both sides.

   Nothing else moves. If a third exception ever seems necessary, the honest
   fix is a rhythm scale, not a third one-off. */
.services {
    padding-bottom: calc(var(--section-pad) * 0.78);
}

.diff {
    padding-top: calc(var(--section-pad) * 0.78);
}

.acp-reviews.section-pad {
    padding-block: calc(var(--section-pad) * 1.22);
}

.section-head {
    max-width: var(--measure-prose);
    margin-bottom: var(--space-section-head);
}

.section-head.is-centered {
    margin-inline: auto;
    text-align: center;
}

/* A section head's bottom margin is the gap to the content it introduces. When
   it is the last thing in its container it introduces nothing, so the margin is
   dead space stacked on top of the section's own padding. /about/ opens with
   exactly that: heading, lead, then 44px of nothing before the band ends. */
.section-head:last-child {
    margin-bottom: 0;
}

.eyebrow {
    display: block;
    font-family: var(--font-body);
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: var(--tracking-eyebrow);
    color: var(--color-accent-soft);
    margin-bottom: 14px;
}

.section-xl-eyebrow {
    font-size: 12px;
    letter-spacing: var(--tracking-eyebrow);
}

/* Scoped to h1 on purpose. This class carried font-size, line-height and
   letter-spacing identical to the base h2 rule, so on the 65 h2 elements that
   wear it every declaration was a no-op. It does real work only on the 15 h1
   elements (the area and trade page titles), where it steps an h1 DOWN to h2
   size. The class stays in the markup as a hook on both. */
h1.section-xl-title {
    font-size: var(--text-h2);
    line-height: 1.04;
    letter-spacing: -0.035em;
}

.section-lead,
.areas-lede,
.svc-lead {
    margin-top: 20px;
    font-size: var(--text-lead);
    line-height: 1.6;
    color: var(--color-text-muted);
    max-width: var(--measure-prose);
}

.section-head.is-centered .section-lead,
.section-head.is-centered .areas-lede,
.svc-lead {
    margin-inline: auto;
}

/* Scroll reveal. The hidden start state is scoped to .js-ready, which the
   chrome script puts on <html>. With JS off nothing is ever hidden. */
.js-ready .js-stagger>* {
    opacity: 0;
    transform: translateY(8px);
    transition: opacity 380ms ease, transform 380ms ease;
}

.js-ready .js-stagger>.is-visible {
    opacity: 1;
    transform: none;
}

/* =============================================================================
   5. BUTTONS
   Two, sitewide: primary "Request an Estimate", secondary "Call (619) 815-5546".
   No other CTA wording exists on this site.
   ============================================================================= */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    min-height: 52px;
    padding: 0 26px;
    font-family: var(--font-body);
    font-size: 15px;
    font-weight: 600;
    letter-spacing: -0.005em;
    line-height: 1.2;
    text-align: center;
    cursor: pointer;
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    transition: background-color 200ms ease, border-color 200ms ease, color 200ms ease;
}

.btn-filled-light {
    background-color: var(--color-accent);
    border-color: var(--color-accent);
    color: var(--color-white);
}

.btn-filled-light:hover {
    background-color: var(--color-accent-press);
    border-color: var(--color-accent-press);
}

.btn-filled-light:active {
    background-color: var(--color-accent-press-active);
    border-color: var(--color-accent-press-active);
}

.btn-outline-light {
    background: transparent;
    border-color: rgba(255, 255, 255, 0.38);
    color: var(--color-white);
}

.btn-outline-light:hover {
    border-color: var(--color-white);
    background: rgba(255, 255, 255, 0.08);
}

.btn-outline-dark {
    background: transparent;
    border-color: var(--color-border);
    color: var(--color-primary-text);
}

.btn-outline-dark:hover {
    border-color: var(--color-steel);
}

.btn-filled-dark {
    background-color: var(--color-primary);
    border-color: var(--color-primary);
    color: var(--color-white);
}

.btn-filled-dark:hover {
    background-color: var(--color-ink);
}

.btn:focus-visible {
    outline: 2px solid var(--color-accent-soft);
    outline-offset: 3px;
}

.btn-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-inline);
}

/* Text link with a rule under it. Used for every "View X" link on the site. */
.text-link,
.service-link,
.portfolio-more a,
.svc-link {
    display: inline-block;
    font-family: var(--font-body);
    /* 14px is the design system's small-text floor. These are card CTAs, not
       decorative labels, so they sit on the floor rather than under it. */
    font-size: 14px;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--color-accent-soft);
    border-bottom: 1px solid currentColor;
    padding-bottom: 3px;
    transition: color 200ms ease;
}

/* AA FIX. These used --color-accent (#E5202F), which is 4.19:1 on --color-bg
   and 3.8:1 on --color-surface: a fail for text at this size. --color-accent-soft
   is 5.66:1 / 5.09:1. Hover AND :focus-visible both had to change, or the
   keyboard state stayed the failing colour. */
.text-link:hover,
.text-link:focus-visible,
.service-link:hover,
.service-link:focus-visible,
.portfolio-more a:hover,
.portfolio-more a:focus-visible {
    color: var(--color-white);
}

/* =============================================================================
   6. HEADER, NAV, MOBILE MENU
   ============================================================================= */
.nav-wrapper {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: var(--nav-height);
    /* 1001, one above .mobile-menu. position:fixed plus z-index makes this a
       stacking context, so at 1000 the open overlay covered the toggle button
       and the menu could only be closed by following a link. */
    z-index: 1001;
    border-bottom: 1px solid transparent;
    transition: background-color 250ms ease, border-color 250ms ease;
}

.nav-wrapper.scrolled {
    background-color: rgba(6, 15, 28, 0.94);
    backdrop-filter: blur(10px);
    border-bottom-color: var(--color-border);
}

.nav-container {
    display: flex;
    justify-content: space-between;
    align-items: center;
    height: 100%;
    gap: var(--space-md);
}

.logo {
    display: flex;
    align-items: center;
    position: relative;
    z-index: 1001;
}

.brand-lockup {
    display: flex;
    align-items: center;
    gap: 12px;
}

.nav-logo-img {
    /* 58px in the 78px header: as large as the bar allows with 10px breathing
       room. Client asked for a bigger menu logo (2026-08-11). */
    height: 58px;
    width: auto;
    display: block;
}

.brand-text {
    display: flex;
    flex-direction: column;
    line-height: 1;
}

.brand-mark {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 18px;
    letter-spacing: -0.01em;
    color: var(--color-white);
    white-space: nowrap;
}

.brand-sub {
    font-family: var(--font-body);
    font-weight: 500;
    /* Was 10px desktop / 9px mobile. Heavily letterspaced caps at 9px are
       under the legibility floor for the only text in the logo lockup. */
    font-size: 11px;
    letter-spacing: 0.24em;
    text-transform: uppercase;
    color: var(--color-steel);
    margin-top: 5px;
    white-space: nowrap;
}

.nav-links {
    display: flex;
    align-items: center;
    gap: 26px;
    /* Full header height rather than the natural height of its tallest item.
       align-items:center still centres every link, and the extra height is what
       lets .nav-item stretch so a dropdown's top:100% lands on the header's
       bottom edge instead of 17px above it. See section 16. */
    align-self: stretch;
}

.nav-link {
    font-size: 15px;
    font-weight: 500;
    color: var(--color-white);
    padding: 6px 0;
    position: relative;
    white-space: nowrap;
}

.nav-link::after {
    content: '';
    position: absolute;
    left: 0;
    bottom: 0;
    /* Full width from the start, scaled to nothing. Animating width forces a
       layout pass on every frame of the hover; scaleX is a compositor-only
       transform. Same 200ms, same left-to-right wipe, from transform-origin. */
    width: 100%;
    height: 1px;
    background-color: currentColor;
    transform: scaleX(0);
    transform-origin: left center;
    transition: transform 200ms ease;
}

.nav-link:hover::after {
    transform: scaleX(1);
}

.nav-phone {
    font-size: 15px;
    font-weight: 600;
    color: var(--color-white);
    white-space: nowrap;
    display: inline-flex;
    align-items: center;
    min-height: var(--tap-min);
}

.nav-phone:hover {
    color: var(--color-steel);
}

.nav-cta {
    min-height: var(--tap-min);
    padding: 0 20px;
    font-size: 14px;
    white-space: nowrap;
}

/* Mobile menu toggle. A real <button>, so it is keyboard operable without a
   custom keydown handler. 46x46 clears the 44px touch target floor; the
   inherited 50x40 div did not. */
.nav-toggle {
    display: none;
    align-items: center;
    justify-content: center;
    width: 46px;
    height: 46px;
    background: transparent;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    color: var(--color-white);
    cursor: pointer;
    position: relative;
    z-index: 1001;
}

.nav-toggle-bars {
    display: block;
    position: relative;
    width: 20px;
    height: 2px;
    background: currentColor;
    transition: background-color 150ms ease;
}

.nav-toggle-bars::before,
.nav-toggle-bars::after {
    content: '';
    position: absolute;
    left: 0;
    width: 20px;
    height: 2px;
    background: currentColor;
    transition: transform 250ms ease;
}

.nav-toggle-bars::before {
    top: -6px;
}

.nav-toggle-bars::after {
    top: 6px;
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bars {
    background-color: transparent;
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bars::before {
    transform: translateY(6px) rotate(45deg);
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bars::after {
    transform: translateY(-6px) rotate(-45deg);
}

/* Drawer. visibility:hidden in the closed state is what removes the phantom
   tab stops: opacity:0 alone leaves every link focusable behind the page.
   100dvh instead of 100vh keeps the panel usable in landscape and under a
   mobile browser's collapsing toolbar, and overflow-y:auto lets a long list
   scroll rather than clip. */
.mobile-menu {
    position: fixed;
    inset: 0;
    height: 100vh;
    height: 100dvh;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    background-color: var(--color-bg);
    padding: calc(var(--nav-height) + 16px) 0 40px;
    z-index: 1000;
    opacity: 0;
    visibility: hidden;
    transition: opacity 250ms ease, visibility 0s linear 250ms;
}

.mobile-menu.is-open {
    opacity: 1;
    visibility: visible;
    transition: opacity 250ms ease, visibility 0s linear 0s;
}

.mobile-nav-links {
    display: flex;
    flex-direction: column;
    width: calc(100% - var(--gutter));
    max-width: 520px;
    margin-inline: auto;
}

.mobile-nav-link {
    display: flex;
    align-items: center;
    min-height: 56px;
    font-family: var(--font-display);
    font-size: 26px;
    font-weight: 600;
    letter-spacing: -0.03em;
    color: var(--color-primary-text);
    border-bottom: 1px solid var(--color-border);
}

.mobile-nav-link:hover {
    color: var(--color-white);
}

.mobile-menu-actions {
    display: grid;
    gap: 12px;
    width: calc(100% - var(--gutter));
    max-width: 520px;
    margin: 28px auto 0;
}

.mobile-menu-actions .btn {
    width: 100%;
}

/* THE scroll lock. One mechanism, class based. The gallery lightbox used to set
   document.body.style.overflow directly, so two different systems owned the
   same property: closing the lightbox while the drawer was open cleared the
   inline style and silently unlocked the page under the open drawer. Both now
   add and remove this class, and .is-locked is the only thing that locks. */
body.menu-open,
body.is-locked {
    overflow: hidden;
}

/* =============================================================================
   7. MOBILE ACTION BAR
   Two thumb-reachable actions below the tablet breakpoint. z-index sits under
   the mobile menu (1000) and the gallery modal (9999). The body gets matching
   bottom padding so the footer is never covered.
   ============================================================================= */
.action-bar {
    display: none;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 900;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    padding: 9px 14px calc(9px + env(safe-area-inset-bottom));
    background-color: rgba(6, 15, 28, 0.97);
    border-top: 1px solid var(--color-border);
    transition: transform 250ms ease;
}

.action-bar .btn {
    min-height: 48px;
    width: 100%;
    font-size: 14px;
    padding: 0 12px;
}

.action-bar.is-hidden {
    transform: translateY(130%);
}

/* =============================================================================
   8. HOME PAGE SECTIONS
   ============================================================================= */

/* Subpages have no hero, so their first section would sit under the fixed nav. */
.subpage-main {
    padding-top: var(--nav-height);
}

/* --- HERO ---------------------------------------------------------------- */
.hero {
    position: relative;
    display: flex;
    align-items: center;
    overflow: hidden;
    padding: calc(var(--nav-height) + var(--hero-pad)) 0 var(--hero-pad);
    min-height: 620px;
}

.hero-bg-container {
    position: absolute;
    inset: 0;
    z-index: 0;
    overflow: hidden;
    background-color: var(--color-bg);
}

.hero-bg {
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Anchor the crop to the LEFT edge, Blake's direction 2026-08-13. The van is
       at the left edge of assets/hero-jobsite.jpg and the default 50% centred
       everything, which threw away the side of the photo worth showing. Since
       the hero form landed the hero is tall enough that the crop is horizontal
       at every width (217px thrown away at 1440, 1076px at 900, 1862px at 390),
       so this is the difference between the van being on screen and not.
       The vertical half stays centred. */
    object-position: left center;
}

.hero-overlay {
    position: absolute;
    inset: 0;
    z-index: 1;
}

/* Contrast. Measured on assets/hero-jobsite.jpg, whose left third is a white
   van in direct sun, which is exactly where the headline sits. A left to right
   scrim carries the text column; the vertical gradient carries the rest.
   Do not lighten these values without re-checking the headline against the van.

   LIGHTENED TWICE on Blake's direction: 2026-08-13 to show more of the van,
   then again 2026-08-13 ("a bit less dark"). Both times it went exactly as far
   as the measurement allowed and no further.

   The binding constraint is NOT the white text, which has headroom to spare.
   It is the red "Construction." in .hero-title .accent. Contrast is sampled
   against the LIGHTEST pixel actually rendered behind the headline box at
   1440x900 with all hero text hidden, which is the sunlit flank of the van.
   Sampling with the text VISIBLE reads the anti aliased glyph edges instead of
   the photo and returns the same wrong number for every stop set. Hide
   .hero-content and .hero-form-panel before you measure.

     stop set                        lightest bg   white   #F05563   #FF8A93
     0.95 0.93 0.78 0.50 0.42        (old)         11.56    3.40      -
     0.88 0.86 0.72 0.46 0.40        51,59,68      11.35    3.34      -
     0.80 0.78 0.64 0.40 0.34 (now)  66,73,82       9.10    2.68     4.04
     0.72 0.70 0.56 0.34 0.28        82,89,98       7.08    2.09     3.14
     0.64 0.62 0.48 0.28 0.22       100,106,113     5.47    1.61     2.42

   Read that table as: with the old #F05563 the scrim was maxed out at
   0.88/0.86, which was the last row it passed. Going lighter than that
   required moving the headline red one shade, so the accent word uses
   --color-accent-hero (#FF8A93). 0.72/0.70 is the lightest row that red
   clears; the row below it FAILS and is written out above as the fence.

   Blake then asked for it a bit darker again on 2026-08-13, so it now sits one
   row up at 0.80/0.78. Note what that does NOT buy: #F05563 is still at 2.68
   here and still fails, so darkening one step is not a route back to the
   original red. Restoring #F05563 means going all the way back to 0.88/0.86
   and giving up both lightening steps.

   A local radial scrim behind the copy column was tried as a way to keep
   #F05563 at a lighter global scrim and it does not work: the van blows out to
   ~118,124,131 there, so even a 0.85 local ellipse only reached 1.20 on the
   red. Do not re-attempt it.

   Re-run the measurement, do not eyeball it, and re-measure if the photo, the
   headline size or either accent colour ever changes. */
.hero-photo .hero-overlay {
    background:
        linear-gradient(100deg, rgba(9, 17, 28, 0.8) 0%, rgba(9, 17, 28, 0.78) 30%, rgba(9, 17, 28, 0.64) 44%, rgba(9, 17, 28, 0.4) 66%, rgba(9, 17, 28, 0.34) 100%),
        linear-gradient(to bottom, rgba(9, 17, 28, 0.3) 0%, rgba(9, 17, 28, 0.5) 100%);
}

.hero-content {
    position: relative;
    z-index: 2;
    color: var(--color-white);
}

.hero-eyebrow {
    color: var(--color-steel);
    margin-bottom: 14px;
}

.hero-title {
    color: var(--color-white);
    max-width: 16ch;
}

.hero-title .accent {
    color: var(--color-accent-hero);
}

.hero-subtitle {
    margin-top: 20px;
    font-size: var(--text-lead);
    line-height: 1.6;
    color: rgba(255, 255, 255, 0.9);
    max-width: 46ch;
}

/* Hero rating badge, 2026-08-14. Sits between the subtitle and the CTA. It is an
   <a> to the Google listing, so it needs the link resets: no underline, and the
   score must not inherit the site link colour. */
.hero-rating {
    display: inline-flex;
    align-items: center;
    gap: 12px;
    margin-top: 24px;
    text-decoration: none;
    color: var(--color-white);
}

.hero-rating-score {
    font-family: var(--font-display);
    font-weight: 700;
    font-size: 26px;
    line-height: 1;
    letter-spacing: -0.02em;
    color: var(--color-white);
}

.hero-rating-stars {
    display: inline-flex;
    align-items: center;
    gap: 3px;
}

.hero-rating-stars svg {
    width: 21px;
    height: 21px;
    /* Gold, not var(--color-accent). See the note in index.html: the filled red
       CTA is directly below this and red stars competed with it. */
    fill: #FBBC04;
    flex: 0 0 auto;
}

.hero-rating-g {
    width: 21px;
    height: 21px;
    flex: 0 0 auto;
    /* The G is the only multi colour mark on the page, so its own fills are set
       per path in the markup and must not be overridden here. */
}

/* Hover is a lift on the whole badge, not an underline. An underline under "5.0"
   alone would read as a typo rather than a link affordance. */
.hero-rating:hover .hero-rating-score,
.hero-rating:focus-visible .hero-rating-score {
    color: var(--color-accent-hero);
}

.hero-actions {
    margin-top: 28px;
}

.hero-note {
    margin-top: 20px;
    font-size: var(--text-small);
    color: var(--color-steel);
}

/* Hero entrance. CSS only, scoped to .js-ready so the hero is never invisible
   with JS off. Opacity plus an 8px lift, 420ms, four short steps. No parallax
   and no per-word animation: both were deleted on 2026-08-11. */
.js-ready .hero-eyebrow,
.js-ready .hero-title,
.js-ready .hero-subtitle,
.js-ready .hero-rating,
.js-ready .hero-actions,
.js-ready .hero-note {
    opacity: 0;
    animation: heroRise 420ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}

.js-ready .hero-eyebrow {
    animation-delay: 60ms;
}

.js-ready .hero-title {
    animation-delay: 120ms;
}

.js-ready .hero-subtitle {
    animation-delay: 200ms;
}

.js-ready .hero-rating {
    animation-delay: 240ms;
}

.js-ready .hero-actions {
    animation-delay: 280ms;
}

.js-ready .hero-note {
    animation-delay: 340ms;
}

@keyframes heroRise {
    from {
        opacity: 0;
        transform: translateY(8px);
    }

    to {
        opacity: 1;
        transform: none;
    }
}

/* --- HERO LEAD FORM ------------------------------------------------------
   Added 2026-08-13. Puts a second instance of the quote form in the hero next
   to the headline, so the top of the page has a capture and not only a phone
   number. Layout only: every field, focus ring and consent style is the
   existing .quote-form system, reused untouched, with .quote-form-compact
   tightening the two things that do not fit above the fold.

   The hero overlay is a left to right scrim tuned against the white van in the
   photo's left third, so the copy column keeps the dark end of it. The form
   sits on the light end, which is why the panel carries its own near opaque
   background rather than leaning on the scrim. Do not thin the panel and do
   not lighten the overlay to compensate: the headline contrast is measured
   against those exact overlay values.
   ------------------------------------------------------------------------ */
.hero-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 420px);
    gap: 56px;
    align-items: center;
}

/* FORM PARKED 2026-08-13. With .hero-form-panel gone the two column rule above
   would hold a 420px track open for nothing and push the headline into the
   left 60% of the hero for no reason. This collapses it to one column and caps
   the copy at its own measure so the subtitle does not run the full 1440.
   Written as :not(:has()) on purpose: it costs nothing while the form is here,
   and it reverts by itself the moment the markup is pasted back. Do not
   "simplify" it by editing .hero-grid directly. */
.hero-grid:not(:has(.hero-form-panel)) {
    grid-template-columns: minmax(0, 1fr);
}

.hero-grid:not(:has(.hero-form-panel)) .hero-copy {
    max-width: 720px;
}

.hero-form-panel {
    background-color: rgba(6, 15, 28, 0.94);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    padding: 24px;
}

.hero-form-title {
    font-size: 22px;
    line-height: 1.25;
    color: var(--color-white);
    margin: 0;
}

.hero-form-lead {
    font-size: var(--text-small);
    line-height: 1.55;
    color: var(--color-text-muted);
    margin: 8px 0 16px;
}

/* There is deliberately NO compact modifier. A `.quote-form-compact` class
   existed here on 2026-08-13 and was deleted the same day on Blake's direction:
   it shortened the textarea, stripped the consent block's border and dropped
   the consent type to 12px, which made the hero read as a different, lesser
   component than the one in block 12. The two instances are the same form and
   they look the same. Do not reintroduce a size variant. If the panel needs to
   be shorter, cut a field from BOTH forms, do not restyle one of them. */

/* Same entrance as the rest of the hero, one step behind the note. */
.js-ready .hero-form-panel {
    opacity: 0;
    animation: heroRise 420ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
    animation-delay: 400ms;
}

/* --- TRUST STRIP --------------------------------------------------------- */
.trust-bar {
    background-color: var(--color-surface);
    border-block: 1px solid var(--color-border);
    padding-block: 26px;
}

/* stretch, not start. The red accent bar is the item's left border, so with
   start the bars ran to each item's own text height and the four came out at
   four different lengths (measured 54px to 101px at 768). stretch squares them
   to the tallest item in the row, which is what the rule is meant to read as. */
.trust-strip {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 18px var(--grid-gap);
    align-items: stretch;
}

/* CENTRED 2026-08-14, Blake's direction. The red left border and its 14px
   padding are gone and the column is centre aligned, so the icon now carries
   the item instead of a rule doing it. The .trust-strip align-items: stretch
   above is left in place: it no longer has a border to square up, but it keeps
   the four columns equal height, which is what centres the icons as a row. */
.trust-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 5px;
    text-align: center;
}

/* Footer profile links, added 2026-08-13. A row, not a stacked list, so it reads
   as a pair of buttons rather than as two more NAP lines. */
.footer-profiles {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 18px;
    list-style: none;
    padding: 0;
}

.footer-profiles a {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 8px 14px;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    font-size: 14px;
    text-decoration: none;
    transition: border-color 160ms ease, color 160ms ease;
}

.footer-profiles a:hover,
.footer-profiles a:focus-visible {
    border-color: var(--color-border-hover);
    color: var(--color-primary-text);
}

.footer-profile-icon {
    width: 17px;
    height: 17px;
    color: var(--color-accent-soft);
    flex: none;
}

/* Trust strip icons, added 2026-08-13 on Blake's direction. Inline SVG rather
   than an icon font or a sprite: the strip is on 19 pages, and inline stroke
   paths inherit currentColor, so one CSS rule recolours all of them. An external
   <use href="sprite.svg#id"> would NOT inherit the host page's colour, which is
   the whole reason it was not used.
   These sit above the value in the existing flex column, so the red left border,
   the type sizes and the grid are all untouched. */
/* 22px to 38px, 2026-08-14, Blake's direction, at the same time the red left
   border came off .trust-item. The icon is the item's only visual anchor now,
   so it is sized to act as one. These are 24-box stroke paths scaled up, not
   raster, so there is nothing to upscale badly. */
.trust-icon {
    width: 38px;
    height: 38px;
    color: var(--color-accent-soft);
    margin-bottom: 10px;
    flex: none;
}

.trust-item-value {
    font-family: var(--font-display);
    font-size: 16px;
    font-weight: 600;
    letter-spacing: -0.015em;
    color: var(--color-primary-text);
}

/* .trust-item-note was DELETED 2026-08-14 on Blake's direction. All 76 spans
   came off across 19 pages in one pass, so the rule went with them rather than
   being left as dead CSS. The strip is now icon, then one line, nothing else.
   Restoring it means restoring both. */

/* The strip used to be one big anchor to /license/ carrying an aria-label, so
   the hover state lived on the container. It is a plain grid now and the items
   that have a source of proof are the links: the two licence facts to
   /license/, the rating to the Google listing it was read from. The fourth item
   has nowhere to point, so it stays a span. Existing tokens only: the value
   goes to --color-white and picks up the same underline the site's other text
   links use. */
a.trust-item {
    color: inherit;
    text-decoration: none;
}

a.trust-item:hover .trust-item-value,
a.trust-item:focus-visible .trust-item-value {
    color: var(--color-white);
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
}

/* .trust-label is still used by the four trade pages. */
.trust-label {
    display: block;
    font-family: var(--font-body);
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: var(--tracking-eyebrow);
    color: var(--color-text-muted);
    margin-bottom: 10px;
}

/* Inherited single-line variant, still used by the trade pages. */
.trust-line {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: baseline;
    gap: 6px var(--space-md);
    color: inherit;
}

.trust-fact {
    font-size: 15px;
    color: var(--color-text);
    display: inline-flex;
    align-items: baseline;
    gap: 6px;
}

.trust-fact strong {
    color: var(--color-accent-soft);
    font-weight: 600;
}

.trust-fact+.trust-fact::before {
    content: "\00b7";
    color: var(--color-border);
    margin-right: 6px;
}

.trust-line-more {
    font-size: 12px;
    font-weight: 600;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--color-accent-soft);
    border-bottom: 1px solid currentColor;
    padding-bottom: 2px;
}

.trust-line:hover .trust-line-more,
.trust-line:focus-visible .trust-line-more {
    color: var(--color-white);
}

/* --- CARDS --------------------------------------------------------------- */
.card {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    padding: var(--card-pad);
    transition: border-color 200ms ease, background-color 200ms ease;
}

/* The old hover border #2C4260 measured 1.69:1 against the card's own surface
   and 1.34:1 against the resting border, so on a dark navy panel the hover was
   effectively invisible. --color-border-hover is 3.12:1 on surface and 3.47:1
   on bg, which clears the 3:1 non-text floor, and the surface steps up one
   notch at the same time so the change does not rest on the 1px line alone. */
.card:hover {
    border-color: var(--color-border-hover);
    background-color: var(--color-surface-hover);
}

/* --- SERVICES ------------------------------------------------------------ */
.services {
    background-color: var(--color-bg);
}

.services-grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: var(--grid-gap);
}

.services-grid-4 {
    grid-template-columns: repeat(4, 1fr);
}

.service-card {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    text-align: left;
    background: var(--color-surface);
}

.service-icon {
    width: 34px;
    height: 34px;
    margin-bottom: 16px;
    color: var(--color-accent);
    stroke: currentColor;
    fill: none;
    flex: 0 0 auto;
}

/* Only the margin is this class's own. It is always on an h3, and font-size,
   font-weight and letter-spacing were verbatim copies of the base h3 rule. */
.service-title {
    margin-bottom: 14px;
}

.service-points {
    display: grid;
    gap: 9px;
    margin-bottom: 24px;
}

.service-points li {
    position: relative;
    padding-left: 16px;
    font-size: 15px;
    line-height: 1.5;
    color: var(--color-text-muted);
}

.service-points li::before {
    content: "";
    position: absolute;
    left: 0;
    top: 0.62em;
    width: 7px;
    height: 1px;
    background: var(--color-accent);
}

.service-desc {
    color: var(--color-text-muted);
    line-height: 1.65;
}

.service-card .service-link {
    margin-top: auto;
}

/* --- DIFFERENTIATOR ------------------------------------------------------ */
.diff {
    background-color: var(--color-surface);
}

.diff-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 48px var(--space-lg);
    align-items: start;
}

.diff-body {
    font-size: var(--text-lead);
    line-height: 1.6;
    color: var(--color-text-muted);
    margin-top: 20px;
    max-width: 46ch;
}

.diff-points {
    display: grid;
    gap: 12px;
    align-self: center;
}

.diff-point {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 18px 20px;
    background: var(--color-bg);
    border: 1px solid var(--color-border);
    border-left: 3px solid var(--color-accent);
    border-radius: var(--radius-md);
    font-size: 15px;
    color: var(--color-text);
}

/* --- WHY CHOOSE US ------------------------------------------------------- */
.why {
    background-color: var(--color-bg);
}

.why-grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: var(--grid-gap);
}

.why-item {
    padding-top: 20px;
    border-top: 2px solid var(--color-accent);
}

/* Icon slot on the home page .why-grid. Scoped by its own class, NOT by
   .why-item, because .why-item also builds the owners grid on /about/ and the
   per-trade grids on the service pages, and neither of those carries an icon. */
/* MATCHED TO THE TRUST STRIP 2026-08-14, Blake's direction: "as big as the ones
   above near hero". 26px to 38px, the size .trust-icon now runs. */
.why-icon {
    display: block;
    width: 38px;
    height: 38px;
    color: var(--color-accent-soft);
    margin-bottom: 14px;
}

/* MATCHED TO .trust-item-value 2026-08-14, Blake's direction, "same size titles
   as above". It was 12px uppercase on --tracking-label and is now the trust
   strip's exact type: 16px display face, sentence case, tight tracking.
   The uppercase came off deliberately rather than being kept at 16px: these
   labels run to "Real project experience", and uppercase plus label tracking at
   16px wraps to two lines in a four column grid. The HTML never changed, the
   casing was only ever a text-transform. */
.why-label {
    display: block;
    font-family: var(--font-display);
    font-size: 16px;
    font-weight: 600;
    letter-spacing: -0.015em;
    color: var(--color-primary-text);
    margin-bottom: 10px;
}

.why-text {
    font-size: 15px;
    line-height: 1.6;
    color: var(--color-text-muted);
}

/* --- RECENT WORK --------------------------------------------------------- */
.portfolio {
    background-color: var(--color-surface);
}

.portfolio-header {
    max-width: var(--measure-prose);
    margin: 0 auto var(--space-section-head);
    text-align: center;
}

.portfolio-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--grid-gap);
}

/* Sparse modifier. For the area pages that only have one or two captioned
   photos, where a fixed three column track would leave visible holes. Replaces
   an inline style="grid-template-columns:..." that was on chula-vista, del-mar
   and lemon-grove (removed 2026-08-11). --narrow additionally caps the width so
   a single card does not stretch across the container.
   Specificity is deliberately .portfolio-grid.portfolio-grid--sparse (0,2,0) so
   it outranks the plain .portfolio-grid entries in the section 14 responsive
   block, exactly as the inline style it replaces used to. auto-fit already
   collapses to two tracks at tablet and one at mobile on its own. */
.portfolio-grid.portfolio-grid--sparse {
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}

.portfolio-grid--narrow {
    max-width: var(--measure-prose);
    margin-inline: auto;
}

.portfolio-card {
    position: relative;
    display: block;
    width: 100%;
    padding: 0;
    aspect-ratio: 4 / 3;
    overflow: hidden;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    text-align: left;
    color: inherit;
    font: inherit;
}

/* .portfolio-card is TWO elements now. /work/ still uses <button> cards that
   open the lightbox. The home page's six became <figure> on 2026-08-14, Blake:
   "dont make them clickable". Everything above is shared; what differs is the
   affordance, so the pointer cursor and the image zoom stay on the interactive
   one only. */
button.portfolio-card {
    cursor: pointer;
}

figure.portfolio-card {
    /* <figure> ships with a 1em inline margin in the UA sheet. The buttons never
       had it, so without this the home grid gains gutters /work/ does not. */
    margin: 0;
    transition: transform 260ms ease, border-color 260ms ease;
}

/* "make these barely move on hover but dont open them", Blake 2026-08-14.
   3px, which is about a fifth of what the interactive cards do. The point is to
   acknowledge the cursor, not to imply a click: note that the CURSOR stays the
   default arrow and the image does not zoom, so nothing here reads as "clickable"
   the way the /work/ cards do.

   hover: hover and pointer: fine gate it to real pointers. A touch device
   resolves :hover on tap and then keeps it until the next tap somewhere else, so
   ungated this would leave one card stuck lifted on a phone. */
/* .portfolio-grid is in the selector for SPECIFICITY, not for scoping, and it is
   load bearing. The scroll reveal sets `transform: none` on
   `.js-ready .js-stagger > .is-visible`, which is three classes. A bare
   `figure.portfolio-card:hover` is only two plus an element, so it lost and the
   card sat perfectly still on hover. Measured, not assumed: the first attempt
   read `transform: none` under the cursor. */
@media (hover: hover) and (pointer: fine) {
    .portfolio-grid figure.portfolio-card:hover {
        transform: translateY(-3px);
        border-color: var(--color-border-hover);
    }
}

/* The card image is wrapped in <picture> for the webp source set. <picture> is
   an inline box with no height of its own, so height: 100% on the <img> would
   resolve to auto and the photo would stop filling the card. This gives the
   wrapper the card's box back. Same reason for .hero-bg-container picture. */
.portfolio-card picture,
.hero-bg-container picture {
    display: block;
    width: 100%;
    height: 100%;
}

.portfolio-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 400ms ease;
}

/* Portrait photos in the 4/3 card. object-fit: cover centers by default, which
   on a 3:4 source shows only the middle 56% of the frame. That is fine for the
   wide jobsite shots, but 20230427_115800 puts the charger unit in the top
   third, and a centered crop clips it. This anchors the crop to the top of the
   frame so the subject stays whole. Added 2026-08-11 with the EXIF rotation
   fix. Apply it only to a card whose subject sits high in a portrait source. */
.portfolio-img--top {
    object-position: center top;
}

button.portfolio-card:hover .portfolio-img {
    transform: scale(1.015);
}

.portfolio-overlay {
    position: absolute;
    left: 0;
    bottom: 0;
    width: 100%;
    padding: 56px 20px 18px;
    background: linear-gradient(to top, rgba(4, 10, 18, 0.94) 0%, rgba(4, 10, 18, 0.72) 55%, rgba(4, 10, 18, 0) 100%);
    color: var(--color-white);
}

.portfolio-eyebrow {
    display: block;
    /* Was 11px, the only label in the system at that size. 12px matches
       .eyebrow so every uppercase label on the site is one tier. */
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: var(--tracking-eyebrow);
    color: var(--color-accent-soft);
    margin-bottom: 6px;
}

.portfolio-title {
    display: block;
    font-size: 17px;
    font-weight: 600;
    line-height: 1.25;
    letter-spacing: -0.015em;
    color: var(--color-white);
}

/* The caption is split across two display lines using only its own words. No
   word is added, removed or changed: see the CAPTION RULE in index.html. */
.portfolio-title span {
    display: block;
    font-weight: 500;
    font-size: 14px;
    color: rgba(255, 255, 255, 0.78);
    margin-top: 3px;
}

.portfolio-more {
    text-align: center;
    margin-top: var(--space-section-head);
}

/* --- REVIEWS -------------------------------------------------------------
   Everything is scoped under .acp-reviews, so no inherited rule can reach the
   verbatim quote text. The inherited word-highlight script that used to rewrite
   a review's innerHTML into per-word spans was deleted on 2026-08-11. Do not
   reintroduce any script that rewrites the contents of a quote: the text on the
   page has to stay byte identical to assets/google-reviews.json.
   ------------------------------------------------------------------------- */
.acp-reviews {
    background: var(--color-bg);
}

.acp-reviews .acp-rating-summary {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 8px 14px;
    margin-top: 18px;
}

.acp-reviews .acp-stars,
.acp-reviews .acp-review-stars {
    display: inline-flex;
    align-items: center;
    gap: 3px;
}

.acp-reviews .acp-stars svg {
    width: 20px;
    height: 20px;
    fill: var(--color-accent);
    flex: 0 0 auto;
}

.acp-reviews .acp-review-stars svg {
    width: 15px;
    height: 15px;
    fill: var(--color-accent);
    flex: 0 0 auto;
}

.acp-reviews .acp-rating-score {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 18px;
    letter-spacing: -0.02em;
    color: var(--color-primary-text);
}

.acp-reviews .acp-reviews-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    align-items: stretch;
    gap: var(--grid-gap);
}

/* REVIEWS MARQUEE (home page only). The service pages still use the static
   .acp-reviews-grid above, so do not delete it.

   Motion: the track holds the five real cards plus a JS made aria-hidden clone
   of the same five, so it is exactly 200% wide and translating it -50% lands
   the clone precisely where the original started. That is why the keyframe is
   -50% and not some tuned pixel value: change the clone count and you must
   change the percentage to match, or the loop will visibly jump.

   The animation runs on the TRACK, and .acp-reviews-marquee is the clipping
   window. Duration is set from the card count so adding reviews later slows the
   track rather than speeding it up: about 9s per card feels right at desktop
   widths. Edge fade is a mask so it works over any section background. */
.acp-reviews .acp-reviews-marquee {
    overflow: hidden;
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 72px, #000 calc(100% - 72px), transparent 100%);
    mask-image: linear-gradient(to right, transparent 0, #000 72px, #000 calc(100% - 72px), transparent 100%);
}

.acp-reviews .acp-marquee-track {
    display: flex;
    align-items: stretch;
    gap: var(--grid-gap);
    width: max-content;
    will-change: transform;
}

/* Only animate once JS has cloned the cards. Without the clone a -50% shift
   would slide the real cards half way off screen and leave dead space. */
.acp-reviews .acp-marquee-track.is-looping {
    animation: acp-marquee-scroll var(--marquee-duration, 90s) linear infinite;
}

@keyframes acp-marquee-scroll {
    from { transform: translate3d(0, 0, 0); }
    to   { transform: translate3d(-50%, 0, 0); }
}

/* Pause so a visitor can actually finish reading a card. focus-within covers
   keyboard users tabbing to the links inside a card. */
.acp-reviews .acp-reviews-marquee:hover .acp-marquee-track,
.acp-reviews .acp-reviews-marquee:focus-within .acp-marquee-track {
    animation-play-state: paused;
}

.acp-reviews .acp-marquee-track .acp-review-card {
    flex: 0 0 clamp(280px, 30vw, 380px);
}

/* Reduced motion: no animation at all, and the window becomes a normal
   horizontal scroller so the content is still reachable. The clone is left in
   place but stays aria-hidden, so a screen reader still reads five reviews. */
@media (prefers-reduced-motion: reduce) {
    .acp-reviews .acp-marquee-track,
    .acp-reviews .acp-marquee-track.is-looping {
        animation: none;
    }

    .acp-reviews .acp-reviews-marquee {
        overflow-x: auto;
        scroll-snap-type: x mandatory;
    }

    .acp-reviews .acp-marquee-track .acp-review-card {
        scroll-snap-align: start;
    }
}

.acp-reviews .acp-review-card {
    display: flex;
    flex-direction: column;
    height: 100%;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-top: 3px solid var(--color-accent);
    border-radius: var(--radius-md);
    padding: var(--card-pad);
}

.acp-reviews .acp-review-stars {
    margin-bottom: 16px;
}

/* The quote is VERBATIM. Do not add ::before or ::after quote marks: the curly
   quotes are real characters in the markup, so copying the text off the page
   reproduces exactly what Google shows. */
.acp-reviews .acp-review-quote {
    margin: 0 0 24px;
    flex: 1 1 auto;
}

.acp-reviews .acp-review-quote p {
    font-size: 15px;
    line-height: 1.65;
    color: var(--color-text);
}

.acp-reviews .acp-review-quote p+p {
    margin-top: 12px;
}


.acp-reviews .acp-review-by {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-top: auto;
    padding-top: 18px;
    border-top: 1px solid var(--color-border);
}

.acp-reviews .acp-review-monogram {
    flex: 0 0 38px;
    width: 38px;
    height: 38px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 1px solid var(--color-border);
    border-radius: 50%;
    background: var(--color-bg);
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 16px;
    color: var(--color-steel);
}

.acp-reviews .acp-review-who {
    display: flex;
    flex-direction: column;
    line-height: 1.3;
}

.acp-reviews .acp-review-name {
    font-style: normal;
    font-weight: 600;
    font-size: 15px;
    color: var(--color-primary-text);
}

.acp-reviews .acp-review-src {
    font-size: 12px;
    color: var(--color-text-muted);
    margin-top: 2px;
}

/* .acp-reviews-cta held the "Read all 14 reviews on Google" link, DELETED
   2026-08-14 on Blake's direction. The rule went with its only user rather than
   being left as dead CSS. What did NOT change: 5.0 and 14 are still the only
   numbers this section may carry, and the four quotes are still verbatim. */

/* --- HOW IT WORKS -------------------------------------------------------- */
.acp-process {
    background: var(--color-surface);
}

.acp-process-list {
    list-style: none;
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--grid-gap);
}

.acp-process-step {
    background: var(--color-bg);
    border: 1px solid var(--color-border);
    border-top: 3px solid var(--color-accent);
    border-radius: var(--radius-md);
    padding: var(--card-pad);
    text-align: left;
}

/* Numbers and titles enlarged 2026-08-13, Blake's direction. The step number was
   15px, the same size as body copy, so the numbered sequence read as a label
   rather than as the thing structuring the section. It is now 32px, which makes
   the 01/02/03/04 run scannable at a glance.
   The title went 12px to 15px and KEPT its uppercase and tracking: at 15px the
   letter-spacing that makes 12px uppercase legible starts to look loose, so it
   is tightened a step here rather than inherited. */
.acp-process-num {
    display: block;
    font-family: var(--font-display);
    font-size: 32px;
    line-height: 1;
    font-weight: 700;
    letter-spacing: 0.02em;
    color: var(--color-accent-soft);
    margin-bottom: 18px;
}

.acp-process-title {
    font-size: 15px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--color-primary-text);
    margin-bottom: 12px;
}

.acp-process-desc {
    font-size: 15px;
    line-height: 1.6;
    color: var(--color-text-muted);
}

/* --- SERVICE AREA -------------------------------------------------------- */
.areas {
    background: var(--color-bg);
}

.areas-links {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 12px;
}

.area-link {
    display: flex;
    align-items: center;
    min-height: 48px;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-left: 3px solid var(--color-accent);
    border-radius: var(--radius-sm);
    padding: 0 16px;
    font-size: 15px;
    font-weight: 500;
    color: var(--color-text);
    transition: background-color 200ms ease, color 200ms ease;
}

.area-link:hover,
.area-link:focus-visible {
    background: var(--color-surface-hover);
    color: var(--color-white);
}

/* Office location map. Keyless embed (maps.google.com q= plus output=embed):
   no API key, no billing account, no external JS. aspect-ratio reserves the
   height before the lazy iframe paints, which is what stops the layout shift.
   Never give the iframe a fixed pixel height. */
.areas-map {
    margin-top: var(--space-section-head);
}

.areas-map-frame {
    aspect-ratio: 16 / 7;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--color-surface);
}

.areas-map-frame iframe {
    display: block;
    width: 100%;
    height: 100%;
    border: 0;
}

.areas-map-caption {
    margin-top: 14px;
    text-align: center;
    font-size: var(--text-small);
    color: var(--color-text-muted);
}

/* --- FAQ -----------------------------------------------------------------
   <details>/<summary>: the answer is in the DOM and readable with JS off, and
   the disclosure is keyboard operable with no script at all.
   ------------------------------------------------------------------------- */
.faq {
    background: var(--color-surface);
}

.faq-list {
    max-width: var(--measure-list);
    margin-inline: auto;
    border-top: 1px solid var(--color-border);
}

.faq-item {
    border-bottom: 1px solid var(--color-border);
}

.faq-item>summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 20px;
    min-height: var(--tap-min);
    padding: 22px 0;
    cursor: pointer;
    list-style: none;
    font-family: var(--font-display);
    font-size: var(--text-lead);
    font-weight: 600;
    letter-spacing: -0.015em;
    color: var(--color-primary-text);
}

.faq-item>summary::-webkit-details-marker {
    display: none;
}

.faq-item>summary::after {
    content: '';
    flex: 0 0 12px;
    width: 12px;
    height: 12px;
    border-right: 2px solid var(--color-accent-soft);
    border-bottom: 2px solid var(--color-accent-soft);
    transform: rotate(45deg) translate(-3px, -3px);
    transition: transform 220ms ease;
}

.faq-item[open]>summary::after {
    transform: rotate(-135deg) translate(-3px, -3px);
}

.faq-item>summary:hover {
    color: var(--color-white);
}

.faq-answer {
    padding: 0 0 24px;
    max-width: 68ch;
}

.faq-answer p {
    font-size: 15px;
    line-height: 1.65;
    color: var(--color-text-muted);
}

.faq-answer p+p {
    margin-top: 12px;
}

.faq-answer a {
    color: var(--color-accent-soft);
    text-decoration: underline;
}

.faq-answer a:hover,
.faq-answer a:focus-visible {
    color: var(--color-white);
}

/* --- CONTACT ------------------------------------------------------------- */
.cta-section {
    background-color: var(--color-bg);
    position: relative;
}

.cta-content {
    max-width: var(--max-width);
    margin-inline: auto;
}

/* .cta-headline is DELETED. It was always on an h2, and all four of its
   declarations were copies of the base h2 rule and the base heading colour.
   The class is still in the markup on 20 pages as a hook; it now resolves to
   nothing, which is correct. Do not re-add a rule that restates the base. */

.cta-info {
    display: grid;
    gap: 6px;
    margin-top: 24px;
    font-size: 15px;
    color: var(--color-text-muted);
}

.cta-info strong {
    color: var(--color-primary-text);
    font-weight: 600;
}

.cta-info a {
    color: var(--color-primary-text);
    text-decoration: underline;
    text-decoration-color: rgba(233, 237, 243, 0.4);
}

/* AA FIX. This hover used --color-accent (#E5202F) for link TEXT on the
   contact band. Not AA at 15px. --color-accent-soft is 5.66:1 here. */
.cta-info a:hover,
.cta-info a:focus-visible {
    color: var(--color-accent-soft);
    text-decoration-color: currentColor;
}

.cta-buttons {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-inline);
    margin-top: 28px;
}

/* The 12 area pages open with a centred section head and then put the CTA row
   directly after it as a sibling. .cta-buttons is a plain flex row, so the
   buttons packed against the container's left edge while the heading above
   them stayed centred: measured 394px off centre at 1440, 276px at 1024 and
   148px at 768. Centre the row when it follows that header. */
.section-head.is-centered+.cta-buttons {
    justify-content: center;
}

.contact-grid {
    display: grid;
    grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
    gap: 48px var(--space-lg);
    align-items: start;
}

/* FORM PARKED 2026-08-13. Same reasoning as .hero-grid above: one column while
   the quote form is out, self reverting when it returns. */
.contact-grid:not(:has(.quote-form)) {
    grid-template-columns: minmax(0, 1fr);
    max-width: 640px;
}

/* =============================================================================
   9. TRADE PAGE RULES (.svc-)
   Used by the four /services/ pages. Every class is prefixed .svc- so nothing
   leaks into another page.
   ============================================================================= */
.svc-band-bg {
    background-color: var(--color-bg);
}

.svc-band-surface {
    background-color: var(--color-surface);
}

.svc-list {
    margin: var(--space-section-head) auto 0;
    max-width: var(--measure-grid);
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 12px;
}

.svc-list li {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-left: 3px solid var(--color-accent);
    border-radius: var(--radius-sm);
    padding: 16px 20px;
    color: var(--color-text);
    font-size: 15px;
    line-height: 1.6;
    text-align: left;
}

.svc-mesh-section {
    border-top: 1px solid var(--color-border);
}

.svc-mesh {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-md);
    max-width: var(--measure-grid);
    margin-inline: auto;
    text-align: left;
}

.svc-links {
    display: grid;
    gap: 10px;
}

/* The global rule is a { color: inherit }, so every link has to be given a
   colour explicitly or it reads as body text. */
.svc-links a {
    color: var(--color-steel);
    border-bottom: 1px solid transparent;
    transition: color 200ms ease, border-color 200ms ease;
}

.svc-links a:hover,
.svc-links a:focus-visible {
    color: var(--color-white);
    border-bottom-color: currentColor;
}

/* -----------------------------------------------------------------------------
   ADDED 2026-08-11 with the service page rebuild. Six rules, all .svc- scoped,
   deliberately placed HERE and not at the end of the file: the responsive block
   in section 14 has to stay downstream of them or it loses the cascade.
   -------------------------------------------------------------------------- */

/* Trade hero. Reuses the home page .hero-* type, on a plain band rather than a
   photo, because .subpage-main already carries the fixed nav clearance. */
.svc-hero {
    border-bottom: 1px solid var(--color-border);
}

.svc-hero .hero-title {
    max-width: 20ch;
}

.svc-hero .hero-subtitle {
    max-width: 56ch;
}

/* Grouped service clusters. Three or two cards, never a single bullet wall. */
.svc-groups {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--grid-gap);
}

/* Trade review block. One or two quotes, centred. The home page's three up grid
   would leave a hole. Specificity is .acp-reviews.svc-review so it outranks the
   .acp-reviews .acp-reviews-grid rules in the responsive block. */
.acp-reviews.svc-review .acp-reviews-grid {
    max-width: var(--measure-list);
    margin-inline: auto;
    grid-template-columns: 1fr;
}

.acp-reviews.svc-review-2 .acp-reviews-grid {
    max-width: var(--measure-pair);
    grid-template-columns: repeat(2, 1fr);
}

/* Per-area context under a trade page's coverage heading. One tile per city:
   the exact-match link plus ONE line naming what that trade covers there. The
   anchor wraps the whole tile, so the pointer target IS the tile and none of
   the ::after touch-target overlays in section 13C are needed here. The grid
   and the tile chrome are .svc-list, which already drops to one column at 767. */
.svc-where li {
    padding: 0;
}

.svc-where-link {
    display: grid;
    gap: 6px;
    padding: 16px 20px;
}

.svc-where-city {
    font-size: 15px;
    font-weight: 600;
    line-height: 1.3;
    color: var(--color-text);
}

/* 15px, the same tier as .service-points li. This is a supporting line inside a
   tile, not a running paragraph, so it is deliberately not in the 767px bump. */
.svc-where-note {
    font-size: 15px;
    line-height: 1.5;
    color: var(--color-text-muted);
}

.svc-where li:hover,
.svc-where li:focus-within {
    background: var(--color-surface-hover);
    border-color: var(--color-border-hover);
}

.svc-where li:hover .svc-where-city,
.svc-where li:focus-within .svc-where-city {
    color: var(--color-white);
}

/* Sibling trade links under the service area list. */
.svc-alt-trades {
    margin-top: 40px;
    text-align: center;
    font-size: 15px;
    color: var(--color-text-muted);
}

@media (max-width: 1024px) {

    .svc-groups {
        grid-template-columns: repeat(2, 1fr);
    }

    /* All four trade pages put three cards in this grid, so dropping to two
       columns left the third one alone on row two beside an empty cell. The
       odd one out spans the full width instead. Guarded with :nth-child(odd)
       so a page that ever carries an even number is untouched. */
    .svc-groups>:last-child:nth-child(odd) {
        grid-column: 1 / -1;
    }
}

@media (max-width: 767px) {

    .svc-groups,
    .acp-reviews.svc-review-2 .acp-reviews-grid {
        grid-template-columns: 1fr;
    }
}

/* Mid page CTA. The service list and the closing band sit a long scroll apart
   on all four trade pages, so the existing button pair repeats once between
   them. No new component: it is .cta-buttons, centred, on a slim surface band
   between two --color-bg sections, so the band itself is the only thing that
   marks it. --space-md, not --space-lg: this is a rest stop, not a section. */
.svc-midcta {
    padding-block: var(--space-md);
    background-color: var(--color-surface);
    border-block: 1px solid var(--color-border);
}

.svc-midcta .cta-buttons {
    margin-top: 0;
    justify-content: center;
}

/* =============================================================================
   10. LICENSE PAGE RULES (.cred-)
   ============================================================================= */
.creds {
    background: var(--color-bg);
}

.creds-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--grid-gap);
    max-width: var(--measure-grid);
    margin: var(--space-section-head) auto 0;
}

.cred-item {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-top: 3px solid var(--color-accent);
    border-radius: var(--radius-md);
    padding: var(--card-pad);
    text-align: left;
}

.cred-label {
    display: block;
    font-size: 12px;
    font-weight: 600;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--color-text-muted);
    margin-bottom: 10px;
}

.cred-value {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 22px;
    letter-spacing: -0.02em;
    color: var(--color-primary-text);
    line-height: 1.2;
}

.cred-value.is-small {
    font-size: 17px;
}

.creds-verify {
    margin-top: 32px;
    font-size: 15px;
    color: var(--color-text-muted);
    text-align: center;
}

.creds-verify a {
    color: var(--color-accent-soft);
    text-decoration: underline;
}

.creds-verify a:hover,
.creds-verify a:focus-visible {
    color: var(--color-white);
}

/* =============================================================================
   11. GALLERY MODAL
   Images only. The inherited PDF viewer branch (embed, iOS iframe swap,
   fallback panel, mobile PDF media queries) was deleted on 2026-08-11: this
   site has never served a PDF from the gallery.
   ============================================================================= */
.gallery-modal {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(3, 8, 14, 0.96);
    z-index: 9999;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}

.gallery-modal.active {
    display: flex;
}

.gallery-close {
    position: absolute;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 48px;
    height: 48px;
    background: rgba(255, 255, 255, 0.08);
    border: 1px solid rgba(255, 255, 255, 0.22);
    border-radius: var(--radius-sm);
    color: var(--color-white);
    font-size: 24px;
    line-height: 1;
    cursor: pointer;
    z-index: 10;
}

.gallery-close:hover {
    background: rgba(255, 255, 255, 0.16);
}

.gallery-close {
    top: 20px;
    right: 20px;
    font-size: 28px;
}

.gallery-content {
    position: relative;
    width: min(94vw, 1200px);
    /* svh, not vh: on iOS vh is the address-bar-collapsed height, so the
       dialog overflowed the visible viewport until the bar hid itself. */
    height: 84svh;
    display: flex;
    align-items: center;
    justify-content: center;
}

.gallery-img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: var(--radius-md);
}

/* DELETED 2026-08-11 and not to be restored: .gallery-nav, .gallery-prev,
   .gallery-next and .gallery-counter. One card is one photo, so there was
   never anything to page through and the counter never had a value. */

/* =============================================================================
   12. FORMS
   The A2P 10DLC consent checkbox is deliberately UNCHECKED and NOT required:
   consent may never be a condition of doing business.
   ============================================================================= */
.quote-form {
    text-align: left;
}

.form-row {
    margin-bottom: var(--space-field);
}

.form-row-split {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-field);
}

.quote-form label {
    display: block;
    font-size: 13px;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--color-primary-text);
    margin-bottom: 7px;
}

.quote-form .label-optional {
    font-weight: 400;
    color: var(--color-text-muted);
}

.quote-form input,
.quote-form select,
.quote-form textarea {
    width: 100%;
    min-height: 54px;
    padding: 14px 16px;
    background-color: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    color: var(--color-text);
    font-family: inherit;
    /* 16px stops iOS zoom on focus */
    font-size: 16px;
    line-height: 1.4;
}

.quote-form textarea {
    /* 96px, cut from 130px on 2026-08-13 so the hero panel clears the fold on a
       850px tall laptop. Changed on .quote-form, so BOTH forms move together and
       the hero one never becomes a smaller variant of the other. */
    min-height: 88px;
    resize: vertical;
}

/* Focus ring, fixed 2026-08-11. The old rule killed the global focus-visible
   outline on every field and replaced it with a 0.22 alpha red glow that
   measured 1.28:1 against the panel, well under the 3:1 non-text contrast
   floor. Now: the mouse-click path (:focus:not(:focus-visible)) gets the
   border tint only, and the keyboard path keeps the same solid 2px outline
   the rest of the site uses, which measures over 5:1 on both bg and surface. */
.quote-form input:focus,
.quote-form select:focus,
.quote-form textarea:focus {
    border-color: var(--color-accent-soft);
}

.quote-form input:focus:not(:focus-visible),
.quote-form select:focus:not(:focus-visible),
.quote-form textarea:focus:not(:focus-visible) {
    outline: none;
    box-shadow: 0 0 0 3px rgba(240, 85, 99, 0.22);
}

.quote-form input:focus-visible,
.quote-form select:focus-visible,
.quote-form textarea:focus-visible {
    outline: 2px solid var(--color-accent-soft);
    outline-offset: 2px;
    box-shadow: none;
}

.quote-form input::placeholder,
.quote-form textarea::placeholder {
    color: var(--color-placeholder);
}

.consent-row {
    display: flex;
    align-items: flex-start;
    gap: 12px;
    margin: var(--space-field) 0;
    padding: 12px;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    background-color: rgba(0, 0, 0, 0.22);
}

.consent-row input[type="checkbox"] {
    width: 20px;
    height: 20px;
    min-height: 20px;
    flex: 0 0 20px;
    margin-top: 2px;
    padding: 0;
    accent-color: var(--color-accent);
}

.consent-row label {
    font-size: 13px;
    font-weight: 400;
    line-height: 1.55;
    letter-spacing: normal;
    color: var(--color-text-muted);
    margin: 0;
}

.consent-row a {
    color: var(--color-accent-soft);
    text-decoration: underline;
}

.consent-row a:hover,
.consent-row a:focus-visible {
    color: var(--color-white);
}

.quote-form .btn {
    width: 100%;
}

.form-status {
    margin-top: 14px;
    font-size: var(--text-small);
    min-height: 1.2em;
}

.form-status[data-state="error"] {
    color: var(--color-error);
}

.form-status[data-state="ok"] {
    color: var(--color-success);
}

/* Visible instruction for the "Tell us about the job" textarea. It used to be
   the placeholder, which disappears the moment anyone types and is skipped by
   some screen readers, so it is a real element wired with aria-describedby. */
.form-hint {
    font-size: var(--text-small);
    line-height: 1.5;
    color: var(--color-text-muted);
    margin-top: -3px;
    margin-bottom: 7px;
}

.quote-form input[aria-invalid="true"],
.quote-form select[aria-invalid="true"],
.quote-form textarea[aria-invalid="true"] {
    border-color: var(--color-error);
}

/* Centred and stepped up from 15px to the lead size on Blake's direction,
   2026-08-11. It is the fallback path for anyone who will not fill the form in,
   so it reads as its own line under the status message, not as fine print. */
.form-callback {
    margin-top: 14px;
    font-size: var(--text-lead);
    text-align: center;
    color: var(--color-text-muted);
}

.form-callback a {
    color: var(--color-primary-text);
    font-weight: 600;
    text-decoration: underline;
}

.form-callback a:hover,
.form-callback a:focus-visible {
    color: var(--color-accent-soft);
}

/* PENDING placeholder. Deliberately loud. Every one is an unanswered question
   in OPEN-QUESTIONS.md. Delete one only when the client supplies the real
   content, never by inventing an answer. */
.pending {
    display: block;
    font-family: var(--font-mono);
    font-size: 12.5px;
    line-height: 1.55;
    color: var(--color-error);
    background: rgba(229, 32, 47, 0.09);
    border: 1px dashed rgba(240, 85, 99, 0.55);
    border-radius: var(--radius-sm);
    padding: 10px 12px;
    text-align: left;
}

.pending::before {
    content: 'NEEDS CLIENT INPUT';
    display: block;
    font-weight: 600;
    letter-spacing: var(--tracking-label);
    font-size: 10.5px;
    color: var(--color-accent-soft);
    margin-bottom: 5px;
}

.pending-block {
    max-width: var(--measure-prose);
    margin: var(--space-md) auto 0;
}

/* =============================================================================
   13. FOOTER
   ============================================================================= */
.footer {
    background-color: var(--color-ink);
    color: var(--color-text-muted);
    padding: 72px 0 32px;
    border-top: 1px solid var(--color-border);
}

/* Four columns since 2026-08-11. Markup order is NAP, Services, Service Areas,
   Company, and the tracks are in that same order. They were not: the comment
   said "NAP, Services, Company, Service Areas" and the wide 1.1fr track sat in
   position four, which handed the extra width to Company's four links and
   squeezed Service Areas, the column that actually needs it because it holds
   the twelve city pages in its own two-up list below. */
.footer-grid {
    display: grid;
    grid-template-columns: 1.5fr 1fr 1.1fr 1fr;
    gap: var(--space-section-head) var(--space-md);
    margin-bottom: 48px;
}

.footer-logo {
    display: inline-flex;
    margin-bottom: 20px;
}

.footer-logo-img {
    height: 60px;
    width: auto;
    display: block;
}

/* Footer column labels. These were <h4> until 2026-08-11, which put an H2 to H4
   jump in the outline of every page on the site. They are labels, not sections,
   so they are <p class="footer-heading"> now. `line-height: normal` reproduces
   the UA heading line box exactly, so the swap is visually a no-op. */
.footer-col .footer-heading {
    color: var(--color-white);
    font-family: var(--font-body);
    font-size: 12px;
    font-weight: 600;
    line-height: normal;
    text-transform: uppercase;
    letter-spacing: var(--tracking-label);
    margin-bottom: 18px;
}

.footer-col ul li {
    margin-bottom: 9px;
    font-size: 15px;
}

.footer-col a {
    transition: color 200ms ease;
}

/* AA FIX. This hover used --color-accent (#E5202F), 4.03:1 on the #050D18
   footer ground. --color-accent-soft is 5.8:1 there. */
.footer-col a:hover,
.footer-col a:focus-visible {
    color: var(--color-accent-soft);
}

.footer-bottom {
    padding-top: 26px;
    border-top: 1px solid var(--color-border);
    display: flex;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 10px var(--space-md);
    font-size: var(--text-small);
}

.footer-legal a {
    text-decoration: underline;
}

/* The site credit link. This carried style="text-decoration: underline;" inline
   on all 21 pages, which is 21 places to edit for one declaration. */
.footer-credit-link {
    text-decoration: underline;
}

.footer-bottom a:hover,
.footer-bottom a:focus-visible {
    color: var(--color-accent-soft);
}

/* =============================================================================
   13B. BREADCRUMBS AND THE FOOTER CITY COLUMN
   Added 2026-08-11 with the SEO pass. Both use existing tokens only: no new
   colours, no new type sizes, no new radii.

   The trail is an <ol> inside a <nav aria-label="Breadcrumb">. Separators are
   their own aria-hidden <li>, not CSS content, so a screen reader reads the
   trail and never the punctuation. The current page is a <span aria-current>,
   never a self link. Keep all three of those properties if you restyle this.
   ============================================================================= */
.breadcrumb {
    padding-block: 20px 4px;
}

.breadcrumb-list {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    list-style: none;
    margin: 0;
    padding: 0;
    font-size: var(--text-label);
    line-height: 1.4;
}

.breadcrumb-item {
    color: var(--color-text-muted);
}

.breadcrumb-item a {
    color: var(--color-text-muted);
    text-decoration: none;
    transition: color 200ms ease;
}

.breadcrumb-item a:hover,
.breadcrumb-item a:focus-visible {
    color: var(--color-text);
    text-decoration: underline;
}

.breadcrumb-item [aria-current="page"] {
    color: var(--color-steel);
    font-weight: 500;
}

/* --color-border measured 1.4:1 against the page here, so the separators were
   effectively invisible and the trail read as run-together words. --color-text-muted
   is the muted TEXT token and is legible. They stay aria-hidden either way. */
.breadcrumb-sep {
    color: var(--color-text-muted);
    user-select: none;
}

/* Footer column four. Two-up so twelve cities do not stretch the footer. */
.footer-col-areas ul {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--space-sm);
}

/* Trade pages label their city links "Plumbing in La Mesa" rather than
   "La Mesa", so the tile has to be allowed to wrap onto a second line. */
.areas-links .area-link {
    padding-block: 8px;
    line-height: 1.3;
}

/* =============================================================================
   13C. TOUCH TARGETS
   Added 2026-08-11. Measured on an iPhone 12 emulation, these links were 16px
   to 27px tall: the breadcrumb trail 16px, the footer column links 19px, the
   review link 16px, and every underlined "View X" link 27px. All under the
   44px minimum pointer target.

   The mechanism is a transparent ::after overlay, not padding. Every one of
   these links draws its own rule with border-bottom, so padding would push
   that rule away from the text and change the design. The overlay grows the
   hit area only: it is out of flow, so nothing reflows, and the visual density
   of the trail, the footer and the card CTAs is unchanged.

   Scoped to coarse pointers so a mouse keeps the tight hover regions.
   ============================================================================= */
@media (pointer: coarse),
(max-width: 767px) {

    .text-link,
    .service-link,
    .portfolio-more a,
    .svc-link,
    .breadcrumb-item a,
    .footer-col ul li a,
    .footer-bottom a {
        position: relative;
    }

    .text-link::after,
    .service-link::after,
    .portfolio-more a::after,
    .svc-link::after,
    .breadcrumb-item a::after,
    .footer-col ul li a::after,
    .footer-bottom a::after {
        content: "";
        position: absolute;
        left: 0;
        right: 0;
        top: 50%;
        min-height: var(--tap-min);
        height: 100%;
        transform: translateY(-50%);
    }

    /* The breadcrumb trail and the footer city column are two-up grids of short
       links sitting close together. A 44px tall overlay on each would collide
       with its neighbour and steal taps, so those two get the width they need
       and stop at the row height they already have. */
    .breadcrumb-list {
        gap: 8px 12px;
        row-gap: 4px;
    }

    .breadcrumb-item a::after {
        left: -6px;
        right: -6px;
    }

    .footer-col ul li {
        margin-bottom: 0;
    }

    .footer-col ul li a {
        display: inline-block;
        padding-block: 12px;
    }

    .footer-col ul li a::after {
        display: none;
    }

    /* The consent checkbox itself, up from 20px. The label is the click
       extender and already is one, so the box only has to be big enough to
       hit on its own. The input's checked and required attributes are
       deliberately untouched: see the A2P note in index.html. */
    .consent-row input[type="checkbox"] {
        width: 24px;
        height: 24px;
        min-height: 24px;
        flex: 0 0 24px;
        margin-top: 0;
    }

    .consent-row label {
        min-height: 24px;
        padding-block: 2px;
    }

    /* Trust strip items became links in the multi-page pass. Two stacked lines
       of text already clear the floor at every width measured, but the strip is
       the one place a one-line item could appear, so the floor is stated rather
       than inferred. No overlay here: these have padding of their own and no
       underline rule to push away from the text. */
    a.trust-item {
        min-height: var(--tap-min);
    }
}

/* =============================================================================
   14. RESPONSIVE
   Consolidated 2026-08-11 from twelve ad hoc widths to five, serving
   375 / 390 / 430 / 768 / 1024 / 1280 / 1440.
     1199  nav row swaps to the drawer   (measured, see below)
     1024  four across becomes two
      767  two becomes one, action bar appears
      374  tightest gutter
   ============================================================================= */

/* NAV BREAKPOINT. RE-MEASURED 2026-08-11 in Chromium, same method as the
   2026-08-10 pass, because the row was rebuilt: five links (Services, Our Work,
   Service Areas, Reviews, About) plus a phone link plus a filled estimate
   button, and the wordmark dropped "Pros".
   Measured natural widths with the row forced visible at a 1600px viewport:
     logo lockup   246.4px
     link row      781.5px  (Services 62.5, Our Work 68.1, Service Areas 100.1,
                             Reviews 59.5, phone 113.9, estimate button 178.3,
                             About 43.1, six 26px gaps)
   Container gutter is 64px, so the row sits flush against the lockup at
   246.4 + 781.5 + 64 = 1091.9px. Holding the same 64px minimum separation the
   previous pass held puts the true floor at 1155.9px.
   1199 clears that floor with 108px of separation at 1200px, and it is the
   value the previous nav used, so it stays. The row is WIDER than the old
   four-link row but the lockup is NARROWER, and the two nearly cancel.
   License and Insurance is deliberately not in this row: it lives in the trust
   strip, the footer and the mobile drawer. */
@media (max-width: 1199px) {

    .nav-links {
        display: none;
    }

    .nav-toggle {
        display: inline-flex;
    }
}

@media (max-width: 1024px) {
    :root {
        --section-pad: 80px;
        --hero-pad: 96px;
        --gutter: 60px;
    }

    .services-grid,
    .services-grid-4,
    .why-grid,
    .portfolio-grid,
    .acp-reviews .acp-reviews-grid,
    .acp-process-list,
    .creds-grid,
    .svc-mesh {
        grid-template-columns: repeat(2, 1fr);
    }

    .areas-links {
        grid-template-columns: repeat(3, 1fr);
    }

    .diff-grid,
    .contact-grid {
        grid-template-columns: 1fr;
        gap: 40px;
    }

    .footer-grid {
        grid-template-columns: 1fr 1fr;
    }

    /* Hero form stacks under the copy. The 540px floor this rule used to set
       comes off at the same width: with the form in the flow the hero is tall
       on its own content, and a floor would only add dead space under the
       panel. The panel is capped at the copy's own measure so it does not
       stretch into a 900px wide field on a tablet. */
    .hero {
        min-height: 0;
    }

    .hero-grid {
        grid-template-columns: 1fr;
        gap: 40px;
    }

    .hero-form-panel {
        max-width: 520px;
    }
}

/* Trust strip only. The 4-column row used to hold to 767px, which gave each
   item a 159px track at 768: "MKA Services Inc dba American Construction" broke
   to four lines in it and the row stopped reading as four facts. Two columns
   from 900px down. Below 375px it drops to one, further down this file. */
@media (max-width: 900px) {
    .trust-strip {
        grid-template-columns: repeat(2, 1fr);
    }
}

@media (max-width: 767px) {
    :root {
        --section-pad: 64px;
        --hero-pad: 76px;
        --gutter: 40px;
        --grid-gap: 16px;
        --card-pad: 20px;
        --nav-height: 64px;
        --text-h3: 22px;
    }

    /* Body copy back to 16px on phones. These eight are running paragraphs and
       link text, not labels, and 15px is under the comfortable reading floor on
       a handheld. The 12-14px tier (eyebrows, captions, the consent block, the
       footer meta) is deliberately NOT in this list: those are labels and are
       meant to sit below the body size. */
    .why-text,
    .diff-point,
    .acp-process-desc,
    .faq-answer p,
    .cta-info,
    .area-link,
    .svc-alt-trades,
    .btn {
        font-size: 16px;
    }

    body {
        padding-bottom: calc(var(--action-bar-height) + env(safe-area-inset-bottom));
    }

    .action-bar {
        display: grid;
    }

    .nav-logo-img {
        height: 48px;
    }

    .brand-mark {
        font-size: 16px;
    }

    .brand-sub {
        font-size: 10px;
    }

    .hero {
        min-height: 0;
    }

    .hero-title {
        max-width: none;
    }

    .services-grid,
    .services-grid-4,
    .why-grid,
    .portfolio-grid,
    .acp-reviews .acp-reviews-grid,
    .acp-process-list,
    .creds-grid,
    .svc-mesh,
    .svc-list,
    .form-row-split {
        grid-template-columns: 1fr;
    }

    .areas-links {
        grid-template-columns: repeat(2, 1fr);
    }

    .footer-grid {
        grid-template-columns: 1fr;
        gap: 32px;
    }

    .footer-bottom {
        flex-direction: column;
        gap: 12px;
    }

    .hero-actions .btn,
    .cta-buttons .btn {
        width: 100%;
    }

    .areas-map-frame {
        aspect-ratio: 4 / 5;
    }

    .mobile-nav-link {
        font-size: 22px;
        min-height: 52px;
    }

    .gallery-content {
        width: 94vw;
        height: 76svh;
    }
}

/* Phones only. Scoped under 480px so 768px keeps the spec's 46px clamp floor.
   The home hero H1 is "Plumbing. Electrical." / "HVAC. Construction." with a
   hard <br> between, and the intent is exactly those two lines.

   A flat 42px held that only down to 412px. Measured 2026-08-11 with the fonts
   loaded, the widest line stops fitting at 41px on a 390px phone, 39px at 375,
   38px at 360 and 33px at 320, so 320-390 was rendering FOUR lines, not two.
   Fixed with a fluid step: the widest available size at each width is about
   0.113 x the container's inner width, and the container is the viewport less
   a 36-40px gutter, so 11vw - 4.5px tracks it with roughly 1.3px of headroom
   at the tightest point (375px). Capped at 42px, which is where 413px and up
   already sat and is the bottom of the design system's 42-48px mobile H1
   range. Floor of 30px is a guard, not a size anything reaches above 300px. */
@media (max-width: 479px) {
    :root {
        --text-hero: clamp(30px, calc(11vw - 4.5px), 42px);
    }
}

@media (max-width: 374px) {
    :root {
        --gutter: 36px;
    }

    .trust-strip {
        grid-template-columns: 1fr;
    }
}

/* =============================================================================
   15. REDUCED MOTION
   One block. The old pair (a blanket `animation: none !important` plus a
   softer duration-clamp block) fought each other and the blanket one left
   JS-revealed elements stuck at opacity 0.
   ============================================================================= */
@media (prefers-reduced-motion: reduce) {

    html {
        scroll-behavior: auto;
    }

    *,
    *::before,
    *::after {
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.001ms !important;
        scroll-behavior: auto !important;
    }

    .hero-eyebrow,
    .hero-title,
    .hero-subtitle,
    .hero-rating,
    .hero-actions,
    .hero-note,
    .js-scroll,
    .js-fade-up,
    .js-stagger>*,
    .js-ready .js-stagger>* {
        opacity: 1 !important;
        transform: none !important;
    }

    button.portfolio-card:hover .portfolio-img,
    .portfolio-grid figure.portfolio-card:hover {
        transform: none;
    }
}

/* =============================================================================
   16. NAV DROPDOWNS
   Added 2026-08-11. Services and Service Areas each open a panel listing every
   page under them, so the four trade pages and the twelve city pages are one
   hover away instead of buried behind an anchor jump.

   Pure CSS, no JS, and that is deliberate: the panel opens on :hover for a
   mouse and on :focus-within for a keyboard, so tabbing into the parent link
   reveals the panel and the next Tab lands on its first item. Nothing here
   needs aria-expanded, because nothing here is a button with state.

   visibility:hidden in the closed state, not opacity alone, is what keeps the
   closed links out of the tab order. The visibility transition carries a delay
   rather than a duration so the panel stays reachable while it fades.

   Below 1199px .nav-links is display:none and the drawer takes over, so every
   rule in the desktop half of this section is dead weight on a phone by
   design. The drawer half renders the same links as indented sub-items.
   ============================================================================= */
.nav-item {
    position: relative;
    display: flex;
    align-items: center;
    /* Full header height, so the panel's top:100% is the bottom edge of the
       header and there is no dead gap for the pointer to fall through. */
    align-self: stretch;
}

.nav-dropdown {
    position: absolute;
    top: 100%;
    left: 50%;
    z-index: 1002;
    display: grid;
    gap: 2px;
    min-width: 220px;
    max-width: calc(100vw - 32px);
    padding: 10px;
    background-color: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-panel);
    opacity: 0;
    visibility: hidden;
    transform: translateX(-50%) translateY(-6px);
    /* CLOSING transition, plus hover intent. The 300ms delay is the forgiving
       half: it keeps the panel open while the pointer crosses the gap between
       the parent link and the panel, or clips a corner on the way to an item,
       so the menu stops snapping shut mid-travel. visibility waits out the
       delay plus the fade (300 + 180) so the links stay clickable while the
       panel is still visible. */
    transition: opacity 180ms ease 300ms, transform 180ms ease 300ms, visibility 0s linear 480ms;
}

/* Twelve cities is too tall for one column. Column flow, six rows, so the
   first six cities fill the left column in document order. */
.nav-dropdown-2col {
    grid-auto-flow: column;
    grid-template-rows: repeat(6, auto);
    grid-template-columns: repeat(2, minmax(148px, 1fr));
    column-gap: 8px;
    min-width: 352px;
}

.nav-dropdown-link {
    display: flex;
    align-items: center;
    min-height: 40px;
    padding: 0 12px;
    border-radius: var(--radius-sm);
    font-family: var(--font-body);
    font-size: var(--text-small);
    font-weight: 500;
    line-height: 1.3;
    color: var(--color-steel);
    white-space: nowrap;
    transition: background-color 150ms ease, color 150ms ease;
}

.nav-dropdown-link:hover,
.nav-dropdown-link:focus-visible {
    background-color: var(--color-border);
    color: var(--color-white);
}

/* OPENING on hover. 120ms of intent, so sweeping the pointer across the nav row
   on the way somewhere else does not flash both panels open behind it. Short
   enough that a deliberate hover still feels immediate. */
.nav-item:hover>.nav-dropdown {
    opacity: 1;
    visibility: visible;
    transform: translateX(-50%) translateY(0);
    transition: opacity 180ms ease 120ms, transform 180ms ease 120ms, visibility 0s linear 120ms;
}

/* OPENING on focus. Listed after the hover rule so it wins when both match.
   No delay at all: hover intent is a pointer problem, and a keyboard user who
   has tabbed onto the parent link has already declared intent. Making them
   wait 120ms for the panel would just look like the site had stalled. */
.nav-item:focus-within>.nav-dropdown {
    opacity: 1;
    visibility: visible;
    transform: translateX(-50%) translateY(0);
    transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 0s;
}

/* OPENING on tap. A coarse pointer never fires :hover, so above 1199px, where
   this row is the only nav there is, the panel was unreachable on a touch
   tablet and the parent link simply navigated away. The chrome script's section
   2b puts .is-open on the .nav-item on the first tap and the second tap follows
   the link. Same open state as the two rules above, and no delay: a tap is a
   deliberate act, so hover intent does not apply to it. */
.nav-item.is-open>.nav-dropdown {
    opacity: 1;
    visibility: visible;
    transform: translateX(-50%) translateY(0);
    transition: opacity 180ms ease, transform 180ms ease, visibility 0s linear 0s;
}

/* Drawer. No hover on a phone, so the sub-links are simply always there,
   indented under their parent. A tap-expand would add a button, a state and a
   handler for no gain: the drawer already scrolls. */
.mobile-nav-sub {
    display: flex;
    flex-direction: column;
    padding: 4px 0 10px 18px;
    border-bottom: 1px solid var(--color-border);
}

.mobile-nav-sublink {
    display: flex;
    align-items: center;
    min-height: var(--tap-min);
    font-family: var(--font-body);
    font-size: 16px;
    font-weight: 500;
    color: var(--color-text-muted);
}

.mobile-nav-sublink:hover,
.mobile-nav-sublink:focus-visible {
    color: var(--color-white);
}

/* Section 15 already clamps every transition duration. This kills the
   visibility delay too, so the panel snaps open and shut with no motion. */
@media (prefers-reduced-motion: reduce) {

    .nav-dropdown,
    .nav-item:hover>.nav-dropdown,
    .nav-item:focus-within>.nav-dropdown,
    .nav-item.is-open>.nav-dropdown {
        transition: none;
    }
}

/* =============================================================================
   17. CONTENT DEPTH SECTIONS (.acp-depth, .acp-prose, .acp-table)
   Added 2026-08-12 with the content depth pass: the coverage section on the
   twelve area pages and the educational section on the four trade pages.

   Only tokens already in section 2 are used here. No new colour, no new
   radius, no new spacing step.

   BANDING. Painting the new band --color-surface would butt it against the
   FAQ's surface below and read as one very long band, so it stays on
   --color-bg and is separated from its neighbours by a hairline. That is the
   same device .svc-mesh-section already uses, not a new pattern.

   CORRECTED 2026-08-12. The original note here said the run was ".services then
   .faq on an area page, .why then .faq on a trade page". The trade half was
   wrong: .acp-reviews sits between .acp-depth and .faq on the trade pages
   (services/hvac/index.html sections at 456, 498, 595, 669), and .acp-reviews
   is also --color-bg with no border of its own, so that boundary was 231px of
   unbroken navy with no separator. The .acp-depth + .acp-reviews rule below
   gives it the same hairline the band above .acp-depth already has.
   ============================================================================= */
.acp-depth {
    background-color: var(--color-bg);
    border-top: 1px solid var(--color-border);
}

/* Trade pages only, by adjacency: .acp-depth is followed by the trade review
   band, same --color-bg, so without this the two read as one band. */
.acp-depth+.acp-reviews {
    border-top: 1px solid var(--color-border);
}

/* Reading column for the new body copy. --measure-list, not --measure-prose:
   these are multi-paragraph blocks sitting under a centred section head, and
   at 780px they read narrower than the FAQ column directly below them. */
.acp-prose {
    max-width: var(--measure-list);
    margin-inline: auto;
    text-align: left;
}

.acp-prose p {
    font-size: var(--text-body);
    line-height: 1.75;
    color: var(--color-text-muted);
}

.acp-prose p+p {
    margin-top: 20px;
}

.acp-prose strong {
    color: var(--color-text);
    font-weight: 600;
}

.acp-prose a {
    color: var(--color-accent-soft);
    border-bottom: 1px solid transparent;
    transition: color 200ms ease, border-color 200ms ease;
}

.acp-prose a:hover,
.acp-prose a:focus-visible {
    color: var(--color-white);
    border-bottom-color: var(--color-accent);
}

.acp-prose h3 {
    font-size: var(--text-h3);
    line-height: 1.2;
    color: var(--color-primary-text);
    margin-top: var(--space-md);
    margin-bottom: 14px;
}

.acp-prose h3:first-child {
    margin-top: 0;
}

/* A qualitative list inside a prose block. Same left rule as .svc-list li so
   the two components read as the same family. */
.acp-prose ul {
    list-style: none;
    display: grid;
    gap: 10px;
    margin-top: 20px;
    margin-bottom: 20px;
    padding: 0;
}

/* A paragraph that follows the list is a new thought, not a fifth bullet.
   Without this the list had a 20px top margin and no bottom margin, so the
   following <p> started at a measured 0px gap and, at 390 where it also sits
   20px to the left of the bullet text, read as an item that lost its marker.
   The 32px here collapses with the list's 20px bottom margin to 32px, the same
   break the table wrapper uses, and it is three times the 10px gap between
   bullets, so the list clearly ends. */
.acp-prose ul+p {
    margin-top: var(--space-md);
}

.acp-prose ul li {
    position: relative;
    padding-left: 20px;
    font-size: 15px;
    line-height: 1.65;
    color: var(--color-text-muted);
}

.acp-prose ul li::before {
    content: "";
    position: absolute;
    left: 0;
    top: 0.62em;
    width: 8px;
    height: 2px;
    background-color: var(--color-accent);
}

/* -----------------------------------------------------------------------------
   COMPARISON TABLE.
   The wrapper is what scrolls, never the page. overflow-x on the wrapper plus
   a min-width on the table is the whole mechanism: below roughly 560px the
   three columns no longer fit, the wrapper takes the horizontal scroll, and
   <body> keeps its own width. tabindex="0" on the wrapper is required, not
   decorative: a scrollable region has to be reachable by keyboard, and the
   role/aria-label pair on it is what gives that stop an accessible name.

   THE CAPTION SITS OUTSIDE THE WRAPPER, as .acp-table-caption, and the table
   points at it with aria-labelledby. Moved out 2026-08-12: inside the table it
   inherited the table's own min-width of 560px, so at a 348px wrapper it
   clipped mid-phrase instead of wrapping. A caption is not tabular data and it
   has no business inside a horizontal scroll region.

   The 32px bottom margin is not decorative. It was `var(--space-md) auto 0`,
   which put 32px above the table and a measured 0px below it, so the paragraph
   after the table started flush against its bottom border.
   -------------------------------------------------------------------------- */
.acp-table-caption {
    max-width: var(--measure-list);
    margin: var(--space-md) auto 10px;
    font-family: var(--font-body);
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: var(--tracking-label);
    color: var(--color-text-muted);
    text-align: left;
}

.acp-table-wrap {
    max-width: var(--measure-list);
    margin: var(--space-md) auto;
    overflow-x: auto;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    background-color: var(--color-surface);
    -webkit-overflow-scrolling: touch;
}

.acp-table-caption+.acp-table-wrap {
    margin-top: 0;
}

.acp-table-wrap:focus-visible {
    outline: 2px solid var(--color-accent-soft);
    outline-offset: 2px;
}

.acp-table {
    width: 100%;
    min-width: 560px;
    border-collapse: collapse;
    text-align: left;
    font-size: 15px;
    line-height: 1.6;
}

.acp-table th,
.acp-table td {
    padding: 14px 20px;
    border-bottom: 1px solid var(--color-border);
    vertical-align: top;
}

.acp-table thead th {
    font-family: var(--font-display);
    font-size: var(--text-small);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: var(--tracking-label);
    color: var(--color-primary-text);
    background-color: var(--color-surface-hover);
    white-space: nowrap;
}

/* Row header column. The red hairline on its left edge is the same 3px accent
   rule .svc-list li carries, so the table sits in the existing family. */
.acp-table tbody th {
    font-family: var(--font-body);
    font-weight: 600;
    color: var(--color-text);
    border-left: 3px solid var(--color-accent);
    white-space: nowrap;
}

.acp-table td {
    color: var(--color-text-muted);
}

.acp-table tbody tr:last-child th,
.acp-table tbody tr:last-child td {
    border-bottom: 0;
}

@media (max-width: 767px) {
    .acp-table th,
    .acp-table td {
        padding: 12px 16px;
    }

    /* SCROLL AFFORDANCE. Below 767 the third column is off the right edge and
       macOS overlay scrollbars render nothing, so there was no cue that the
       table scrolls. Two background layers, no JS, existing tokens only:
       layer 1 fades to --color-bg at the box's right edge and is `scroll`, so
       it stays pinned there; layer 2 is a 36px block of --color-surface,
       attachment `local`, so it travels with the content and parks over layer 1
       once the wrapper is scrolled all the way right. The cue therefore
       disappears exactly when there is nothing left to scroll to. */
    .acp-table-wrap {
        background-image:
            linear-gradient(to left, var(--color-bg), transparent),
            linear-gradient(to left, var(--color-surface), var(--color-surface));
        background-position: right center, right center;
        background-repeat: no-repeat;
        background-size: 28px 100%, 36px 100%;
        background-attachment: scroll, local;
    }
}

/* CREW AND FLEET BAND: REMOVED 2026-08-13, same day it was built. The rules
   below are dead and are kept only so the next person does not re-derive them.

   It was dropped because both photos turned out to be PATRIOT AIR CONDITIONING's
   fleet and crew, not ACP's, despite living in ACP's own media library. The
   reasoning is written out in full at the "6b." comment in index.html. Read that
   before adding any crew imagery.

   If ACP ever supplies real crew photos, the two sizing lessons worth keeping:
   cap the grid so no image is drawn above its native pixel size, and do NOT use
   object-fit: cover to force a letterboxed fleet strip to match the height of a
   near square crew shot, which upscaled a 190px tall source by 1.67x. */

/* "One team for the whole job" photo. See the section comment in index.html for
   why this image is not cleared and must stay caption free.

   The bullets moved inside the copy column when the photo was added, so the two
   grid columns are now copy and photo rather than copy and bullets. align-items
   is centre here, not the start the bare .diff-grid uses, because a 16:9 photo
   next to a short copy block looks dropped when both are top aligned. */
/* ENLARGED 2026-08-13, Blake's direction. The grid was an even 1fr 1fr, so the
   photo got half the container and sat small next to a short copy block. The
   photo column is now the wider of the two. The copy is a single paragraph plus
   four bullets and does not need half the page.
   Ratio, not a fixed width, so it still collapses to one column at 1024. */
/* ENLARGED TWICE on 2026-08-14, Blake's direction: 0.82/1.18 to 0.70/1.30, then
   to 0.64/1.36 with the column gap cut to 48px. That is 738px of photo at 1440
   before, 786px after. The source is 1400x788, so 786 CSS px is the practical
   ceiling, and the img sizes hint was corrected from 46vw to 58vw in the same
   pass because 46vw was under requesting and let the browser pick the 800w file
   for a 738px slot. */
/* ENLARGED AGAIN 2026-08-14, Blake: "photo should fit better in this space and
   be larger". The problem was VERTICAL, not width. Measured at 1440: the copy
   column ran 517px tall and the photo only 442, so a 75px band of empty navy sat
   under the picture and the two columns did not line up at the bottom.

   Fixed by filling the row instead of centring in it: align-items is now
   stretch, so the figure takes the full row height, and .diff-img switches to
   object-fit cover. Columns also went 0.64/1.36 to 0.60/1.40.

   THE CROP, measured rather than guessed. The box becomes about 806x545, which
   is 1.48:1 against the source's 1.78:1, so cover keeps roughly 83% of the
   width, centred, losing about 8% off each side. What is at those edges: trees
   and open sky on the left, hedge on the right. The dome of the building sits at
   about 78% across, which stays comfortably inside the crop. This is why the
   earlier note here said a 4:3 crop would "take the building off" and this one
   does not: 4:3 would have cut to 75% of the width, and the dome is right on
   that line.

   Anything that makes the copy column TALLER makes this crop tighter, because
   the photo grows to match it. If a fifth bullet or another paragraph is ever
   added, re-measure before assuming the building survived. */
.diff-grid:has(.diff-figure) {
    align-items: stretch;
    column-gap: 64px;
    grid-template-columns: minmax(0, 0.60fr) minmax(0, 1.40fr);
}

/* CENTRED IN THE SLACK, 2026-08-14, Blake: "center photo between right side of
   section and the text", plus "a bit larger".

   The photo was flush to the container's right edge, so all the leftover room
   was OUTSIDE the container: the page's own centring margin. This pulls the
   figure out into it until the air on its left (the column gap) matches the air
   on its right (the window edge), and the figure grows by exactly what it gains.

   --page-margin is that leftover room, computed rather than measured off one
   screen. Note what it is NOT: the obvious calc((100vw - 100%) / 2) is wrong
   here and was tried first. A margin percentage resolves against the element's
   containing block, and for a grid item that is its GRID AREA, not .container,
   so 100% came back as the photo column's ~795px and the figure blew out to
   1054px wide with a horizontal scrollbar behind it. The max(32px, ...) branch
   is the 1200 to 1264 band, where .container is still 100% - var(--gutter) and
   has not yet hit its 1200px cap.

   min(0px, ...) is a guard, not decoration: above 1328 the pull is negative and
   moves the figure out, below it the expression turns positive, which would pull
   the figure IN and shrink it. Zero is the floor.

   The scrollbar makes 100vw about 15px wider than the real client width, so the
   figure lands a few px further right than the arithmetic says and keeps ~57px
   of air rather than 64. It CANNOT overflow, which is the part that matters: the
   error only ever eats into a gap that was positive to begin with.

   Scoped to 1200 and up, matching the object-fit rule below. Under that the
   photo is uncropped and still inside the container. */
@media (min-width: 1200px) {
    .diff-grid:has(.diff-figure) .diff-figure {
        --page-margin: max(32px, (100vw - 1200px) / 2);
        margin-right: min(0px, calc(64px - var(--page-margin)));
    }
}

.diff-grid .diff-points {
    margin-top: 28px;
}

.diff-figure {
    margin: 0;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--color-surface);
}

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

/* Cover only while the grid is genuinely two columns. Below 1025 the grid
   collapses to one, the row height is auto, and a height:100% child of an auto
   height row has nothing to resolve against: the photo would fall back to its
   own height and the object-fit would be doing nothing, or worse, collapse.
   Stacked on a phone there is no empty column to fill anyway, so the natural
   16:9 is the right answer there and it keeps the full frame uncropped. */
/* 1200, not 1025, and the number is measured. The crop tightens as the copy
   column narrows, because a narrower column is a taller column and the photo
   grows to match it. Width of the source kept, centred, by viewport:

     1025   70%   loses 15.1% a side   dome at the very edge
     1100   79%   loses 10.4% a side
     1200   86%   loses  7.1% a side
     1280   90%   loses  5.1% a side
     1440   88%   loses  6.1% a side

   Below 1200 it eats into the building, so those widths keep the old behaviour:
   full 16:9 frame, vertically centred, no crop. The empty band this leaves is
   the lesser problem. Re-measure with the loop in the session scratchpad if the
   copy or the bullet count ever changes. */
@media (min-width: 1200px) {
    .diff-figure .diff-img {
        height: 100%;
        object-fit: cover;
        object-position: center;
    }
}

@media (min-width: 1025px) and (max-width: 1199px) {
    .diff-grid:has(.diff-figure) {
        align-items: center;
    }
}

@media (max-width: 1024px) {
    .diff-grid:has(.diff-figure) {
        grid-template-columns: 1fr;
        /* Undo the stretch with the columns. One column means the figure would
           stretch to a row it alone defines, which is a no-op at best. */
        align-items: start;
    }
}

/* ==========================================================================
   OWNER PORTRAITS AND FLEET PHOTO, /about/ ONLY
   Added 2026-08-13. These are the FIRST verified people-and-vehicle photos on
   the site. Everything above about crew imagery not being cleared referred to
   the harvested Trucks.jpg and Team-Group.jpg, which were read off the client's
   WordPress library and turned out to be PATRIOT AIR CONDITIONING vans. These
   are different files, from a different place, and they were checked before use:
   the van decal reads AMERICAN CONSTRUCTION, PLUMBING, ELECTRIC & HVAC, and the
   polo star is the same mark. Provenance is written up in
   assets/work/SOURCE-ANDREA-2026-08-13.md.

   SIZING. The three portraits are 400, 480 and 424px square, and that is all the
   client has. The avatar is capped at 120px so nothing is ever drawn above its
   native pixel size even on a 2x screen (120 * 2 = 240, under 400). Do not grow
   this past 200px without larger source files. */
.owner-item {
    display: flex;
    flex-direction: column;
}

.owner-avatar {
    width: 120px;
    height: 120px;
    border-radius: 50%;
    overflow: hidden;
    margin-bottom: 18px;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
}

.owner-avatar img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* The fourth owner has no photo on file. His card carries his initials rather
   than a stock face or an empty hole, so the row reads as deliberate. It is
   typographic on purpose: a silhouette icon would imply a photo exists. */
.owner-monogram {
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: var(--font-display);
    font-size: 30px;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--color-text-muted);
}

/* Fleet photo band. RESIZED 2026-08-13 on Blake's note that it was too large and
   did not fit. It was the full 1140px container at native 4:3, which is 855px
   tall and ate a whole viewport on its own.

   Two changes, and the second is the one that actually fixed it. Capping the
   width alone still leaves a tall box, because the wasted space in this frame is
   vertical: roughly the top third is empty sky and the bottom quarter is empty
   lawn, with the vehicles sitting in a band across the middle. So the box is
   16:9 and the image covers it, positioned at 42% from the top, which trims sky
   and grass and keeps every vehicle in frame. Verified against the rendered
   page, not guessed: at 940px the four vehicles all sit inside the crop.

   Cropping is safe here where it was NOT safe for the crew band: this source is
   5712px on the long edge, so the served 2000px file is a downscale at every
   breakpoint. Cropping a large photo is fine. Stretching a small one is not. */
.fleet-figure {
    margin: 0 auto;
    max-width: 940px;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--color-surface);
    aspect-ratio: 16 / 9;
}

.fleet-figure img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center 42%;
}

/* Below the cap the container is already narrower than 940px, so the frame goes
   back to something closer to the native shape rather than a letterbox slot on a
   phone, where a 16:9 crop of this photo leaves the vehicles very small. */
@media (max-width: 767px) {
    .fleet-figure {
        aspect-ratio: 4 / 3;
    }
}

/* TRADE PAGE PHOTO PAIR. Added 2026-08-13 for /services/construction/, which had
   no photograph of its own trade on it until the client sent bathroom photos.
   A fixed aspect box with object-fit cover is correct HERE and was wrong for the
   crew band, because both sources are 1600px on the long edge: cropping a large
   photo is fine, stretching a small one is not. */
.svc-photo-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: var(--grid-gap);
}

.svc-photo {
    margin: 0;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--color-surface);
    aspect-ratio: 4 / 3;
}

.svc-photo img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

@media (max-width: 767px) {
    .svc-photo-grid {
        grid-template-columns: 1fr;
    }
}

/* =============================================================================
   18. ESTIMATE QUIZ (block 3B) + EXIT INTENT MODAL
   Added 2026-08-14. The reference this was modelled on is a light page with a
   blue accent; this site is dark navy, so nothing here copies its palette. Every
   colour below is an existing token.

   The quiz form itself carries .quote-form as well as .quiz-form, so fields,
   labels, the consent row and the status line all come from section 12 and there
   is only ever one set of form styles to maintain.
   ============================================================================= */
.quiz-section {
    background: var(--color-bg);
}

.quiz-card {
    max-width: var(--measure-pair);
    margin: 0 auto;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-md);
    padding: var(--card-pad);
    /* The reference marks the top of its card with a heavy accent rule. Same
       idea, on brand red, and it doubles as the thing that visually anchors the
       progress bar directly beneath it. */
    border-top: 3px solid var(--color-accent);
}

.quiz-progress {
    height: 5px;
    background: var(--color-border);
    border-radius: 999px;
    overflow: hidden;
    margin-bottom: 32px;
}

.quiz-progress-fill {
    display: block;
    height: 100%;
    width: 25%;
    background: var(--color-accent);
    border-radius: inherit;
    transition: width 260ms cubic-bezier(0.22, 0.61, 0.36, 1);
}

.quiz-question {
    font-family: var(--font-display);
    font-size: var(--text-h3);
    font-weight: 700;
    letter-spacing: -0.02em;
    line-height: 1.2;
    color: var(--color-primary-text);
    margin-bottom: 20px;
}

/* Focused by script on every step change so the answer group is announced. It
   is never a tab stop, so it must never look like one. */
.quiz-question:focus {
    outline: none;
}

.quiz-options {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
}

.quiz-option {
    display: block;
    width: 100%;
    min-height: var(--tap-min);
    padding: 16px 18px;
    text-align: left;
    background: var(--color-bg);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-sm);
    color: var(--color-text);
    font-family: var(--font-body);
    font-size: var(--text-body);
    font-weight: 500;
    line-height: 1.3;
    cursor: pointer;
    transition: background-color 140ms ease, border-color 140ms ease;
}

.quiz-option:hover {
    background: var(--color-surface-hover);
    border-color: var(--color-border-hover);
}

/* The answer that was chosen, visible again when someone steps back. Border and
   text both move, so the state does not rest on colour alone. */
.quiz-option[aria-pressed="true"] {
    border-color: var(--color-accent);
    color: var(--color-white);
}

/* An odd option on the last row spans both columns rather than leaving a hole.
   This is what carries "Not sure yet" on the five-option trade step. */
.quiz-options> :last-child:nth-child(odd) {
    grid-column: 1 / -1;
}

.quiz-foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-inline);
    margin-top: 24px;
    padding-top: 18px;
    border-top: 1px solid var(--color-border);
}

.quiz-back {
    min-height: var(--tap-min);
    padding: 0 4px;
    background: none;
    border: 0;
    color: var(--color-text-muted);
    font-family: var(--font-body);
    font-size: var(--text-small);
    font-weight: 600;
    letter-spacing: 0.02em;
    cursor: pointer;
}

.quiz-back:hover,
.quiz-back:focus-visible {
    color: var(--color-text);
}

/* Back is hidden on step 1, and the count must stay on the right when it is. */
.quiz-back[hidden]+.quiz-count {
    margin-left: auto;
}

.quiz-count {
    font-size: var(--text-small);
    color: var(--color-text-muted);
    margin: 0;
}

.quiz-form .btn {
    width: 100%;
}

/* -----------------------------------------------------------------------------
   Exit intent modal
   Starts `hidden` in the markup, so this rule set only ever describes the open
   state. With JS off the attribute is never removed and none of this applies.
   -------------------------------------------------------------------------- */
.exit-modal {
    position: fixed;
    inset: 0;
    z-index: 200;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-sm);
}

.exit-modal[hidden] {
    display: none;
}

.exit-backdrop {
    position: absolute;
    inset: 0;
    background: rgba(6, 15, 28, 0.82);
}

.exit-panel {
    position: relative;
    width: 100%;
    max-width: 520px;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-top: 3px solid var(--color-accent);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-panel);
    padding: 44px var(--card-pad) var(--card-pad);
    text-align: center;
}

.exit-close {
    position: absolute;
    top: 8px;
    right: 8px;
    width: var(--tap-min);
    height: var(--tap-min);
    background: none;
    border: 0;
    color: var(--color-text-muted);
    font-size: 26px;
    line-height: 1;
    cursor: pointer;
}

.exit-close:hover,
.exit-close:focus-visible {
    color: var(--color-text);
}

.exit-eyebrow {
    display: block;
    color: var(--color-accent-soft);
    margin-bottom: 12px;
}

.exit-title {
    font-family: var(--font-display);
    font-size: 32px;
    font-weight: 700;
    letter-spacing: -0.02em;
    line-height: 1.1;
    color: var(--color-primary-text);
    margin-bottom: 14px;
}

.exit-body {
    font-size: var(--text-body);
    line-height: 1.6;
    color: var(--color-text-muted);
    margin-bottom: 24px;
}

.exit-cta {
    width: 100%;
}

.exit-note {
    margin-top: 16px;
    font-size: var(--text-small);
    color: var(--color-text-muted);
}

@media (max-width: 640px) {

    /* One column below 640. Two 50% columns of option text wrap to three lines
       each on a phone, which is worse than a taller list. */
    .quiz-options {
        grid-template-columns: 1fr;
    }

    .quiz-card {
        padding: 20px;
    }

    .exit-title {
        font-size: 26px;
    }
}
