/* Locksmith Connections — PPC landing pages.
   Page speed is a Quality Score input and a slow page costs money on every click, for ever. One stylesheet,
   ONE self-hosted latin-subset variable face (29 KB), no framework, no JS beyond the two small inline
   scripts the pages carry. */

/* ── TYPE ──────────────────────────────────────────────────────────────────────────────────
   Geist throughout, matching the CRM. It replaced IBM Plex Sans + Plex Sans Condensed on 25 Aug 2026.
   Still avoided on purpose: Inter (two competitors and the default AI face), Open Sans and Roboto (two
   more), Montserrat (one).

   ⛔ WHAT THE OLD PAIRING WAS DOING THAT GEIST HAS TO KEEP DOING. Two things, both load-bearing:
   · The condensed cut existed to fit the longest H1 in the set — "Locksmith Dispatch Serving Virginia
     Beach VA" — on one line. Geist has no condensed cut, so .page-h1 was re-measured against that exact
     string, not against Chicago's short one. See THE TYPE SCALE below.
   · Tabular figures are why the rate card's price column lines up without a monospace face. Geist has
     them, and font-variant-numeric:tabular-nums is still set on every figure that needs it. */
/* ── GEIST, ONE VARIABLE FILE ─────────────────────────────────────────────────────────────
   Matches the CRM, and self-hosted for the same reason everything else here is: no third-party request on
   a page whose whole job is to load fast on a phone on cellular.

   The weight axis means ONE 29 KB file covers 400 and 700, replacing three static Plex files totalling
   66 KB — and one preload instead of two, which matters more than the bytes because a preload is a
   render-blocking round trip.

   ⚠️ format('woff2'), NOT format('woff2-variations'). The CRM still carries the older keyword; it is
   deprecated, and a browser that does not recognise a format string skips that src ENTIRELY rather than
   falling back within it. Plain woff2 is understood everywhere and loads the variable axes fine.

   ⛔ GEIST HAS NO CONDENSED CUT, and Plex Condensed was carrying the H1, both phone numbers, the sticky
   bar and every uppercase label. Bold Geist is materially wider, so the sizes and tracking below were
   re-measured rather than swapped — see THE TYPE SCALE. */
@font-face{font-family:'Geist';src:url(/fonts/geist-latin.woff2) format('woff2');
  font-weight:100 900;font-style:normal;font-display:swap;
  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}

/* ── PALETTE ──────────────────────────────────────────────────────────────────────────────────
   BLUE GROUND FROM THE LOGO, AVA'S BLUE FOR ACTION. Settled 25 Aug 2026 against the FINAL mark.

   ⛔ WHERE EACH PART COMES FROM, BECAUSE THEY COME FROM DIFFERENT PLACES:
   · ACTION (#71D2F5) is the Ava Connects palette, copied verbatim from crm/web/src/index.css. Not
     sampled, not approximated, not tuned here — the CRM solved it and the two properties should agree.
   · BRAND (#0082D7) is sampled from the final logo: it is the wordmark, 14.7% of Logo-Horizontal.png.
   · The INK family is that same blue taken down to near-black, at hue 201° against the wordmark's 204°.

   ⚠️ THE PREVIOUS NEUTRALS WERE TEAL AND THAT WAS A LEFTOVER, NOT A CHOICE. They were sampled from the
   OLD logo, whose wordmark was teal (#0D97AB). That wordmark no longer exists — the final mark is an
   amber key with a #00C3EC→#0082D7 gradient and a flat #0082D7 wordmark. A teal ground beside a blue mark
   was the same "palette ignores the brand" problem that started this, one iteration later and subtler.
   The old ink sat at hue 190°, 14° green of the mark; #04283C sits at 201°. Every pair measured at least
   as well after the move as before it — text on the band went 13.38 → 14.40, headings 12.97 → 13.97.

   THE ROLE RULES ARE THE WHOLE PALETTE, AND THEY ARE CONTRAST, NOT TASTE:
   · #71D2F5 is a FILL. 1.6:1 on white, so never text on light and never a link. Call button, sticky bar,
     header phone chip — grounds, with --action-ink on top.
   · #00619F (--brand-deep) CARRIES TEXT. 5.98:1 on the page ground. Links, and anything at body size.
   · #0082D7 (--brand, the wordmark itself) is for rules, ticks and focus rings at the 3:1 UI bar. It
     measures 3.71:1 on the page ground, so it is NOT a text colour — using the wordmark colour for body
     copy is the obvious-looking mistake here, and it fails AA by three quarters of a point.
   Amber held the action role until 25 Aug and was removed from the palette entirely rather than demoted,
   because a fill colour left lying around is a fill colour that ends up as text. It survives only where
   it belongs: inside the logo artwork, as the key.

   ⚠️ THIS PAGE IS LIGHT WITH ONE DARK BAND, so any token borrowed from the CRM has to come from the
   matching theme. Light-theme values are the default here; dark-theme variants are only correct inside
   the hero. Mixing them is how "invisible on light" bugs happen — see --acc-text below.

   ⛔ EVERY COLOUR ON THE PAGE COMES FROM A TOKEN BELOW. There are no colour literals further down this
   file, deliberately: this palette change had to be a token change, and the next one has to be too. */
:root{
  /* ⛔ THE BAND IS A NEUTRAL, AND THAT IS THE DECISION — NOT A LEFTOVER.
     It was #04283C, the logo's blue taken down. That tinted the photograph toward teal, and once the scrim
     came down by half the tint was suddenly visible: a sunset behind a blue wash reads as dusk, which is
     the mood being fixed. At hue 206° and only 58% saturation this is barely a colour, so the photograph
     keeps its own — the sky stays orange.

     The band no longer carries the brand. It does not need to: the logo and the photograph carry it, and
     the brand blue lives in the button, which is where it should be pulling the eye. Chosen from three
     rendered options on 26 Aug; it also measured best of the three, 6.6:1 with the shadow. */
  --ink:#0A1218; --ink-2:#151F27; --ink-3:#24323D;
  --ink-rgb:10,18,24;
  /* the page — neutrals biased toward the brand blue, never pure white */
  --paper:#F2F5F8; --card:#FBFCFD; --rule:#D3DCE4; --rule-2:#E4EAF0;
  /* ⛔ --brand IS A LOGOTYPE COLOUR AND NOTHING ELSE. It is the wordmark (#2da5d0) exactly, and after the
     26 Aug artwork change that is ALL it may be: it measures 2.59:1 on the page ground and 2.76:1 on a
     card, so it fails the 4.5:1 text bar AND the 3:1 bar for focus rings, UI boundaries and meaningful
     graphics. It was 3.71:1 as #0082D7, which cleared the 3:1 bar — the change did not just cost it body
     text, it cost it every affordance it was carrying.
     ⛔ THE ONLY THINGS LEFT POINTING AT IT are .brand-text (the wordmark itself) and FOUR purely
     decorative rules: the rate card's top border, the prose checklist chevron, the .numintext
     underline, and the hero tick's disc on hover. None of those carries meaning that is lost if it
     goes unseen — the tick's meaning is its label, and the disc is gold at 1.95:1 at rest anyway.
     Do not add a fifth. If one is proposed, the test is whether anything is LOST when it is not seen.
     ⛔ EVERYTHING ELSE USES --brand-deep. #0B6E9E is 5.13:1 on paper and 5.47:1 on a card, so it clears
     both bars with room: links, eyebrows, the footer, the site-wide focus ring, the zip input's focus
     state, the hero tick's hover DISC, and the active carousel dot.
     ⚠️ IT WAS #00619F AND THE REASON FOR MOVING IT WAS HUE, MEASURED: the wordmark is 196°, the old
     accent 203°, the new one 200° — a 7° gap closed to 4°. The stated diagnosis was that #00619F is "a
     true navy at 223°"; it is not, it is a deep cyan-blue seven degrees off the mark. What actually made
     it read as foreign is saturation and depth — 100% S at 31% L against the mark's 64% S at 50% L — and
     #0B6E9E (87% S, 33% L) only partly closes that. The change is an improvement and it clears every
     bar, so it ships; the premise is recorded here because the next person will re-derive it.
     ⚠️ NO INTERMEDIATE BLUE WAS INTRODUCED. A fourth value between the two would have to be justified at
     every bar it sits near, and --brand-deep already carries this work. */
  --brand:#2da5d0; --brand-lift:#00C3EC; --brand-deep:#0B6E9E; --brand-darker:#004E80;
  /* ⛔ ONE STROKE. There was a 6px spread behind the ring at .22 alpha and it earned nothing: it made the
     state look softer without making it more findable, and a second layer is a second thing to get wrong
     on some surface nobody checked. The ring alone carries WCAG 1.4.11 at 5.13:1 on --paper and 5.47:1
     on --card.
     ⛔ AND IT IS NEVER A TRANSLUCENT ACCENT. rgba(113,210,245,.5) is Ava Connects' focus colour and Ava is
     a DARK interface: over this site's light ground it composites to about #B8E8FA, roughly 1.3:1 against
     a 3:1 bar — a ring that is decoration rather than an indicator. Any focus colour here has to be solid
     and measured against --paper AND --card. */
  /* The key's own body colour, by histogram: 79.3% of the opaque pixels in Key-Only.png. A FILL only --
     it measures about 2.1:1 on white, fine for a shape and disqualifying for a word. */
  --gold:#FBA51D;
  /* ⛔ THE ACTION COLOUR, TAKEN VERBATIM FROM THE CRM (crm/web/src/index.css), NOT APPROXIMATED.
     It means CALL, and it is a FILL — never text, never a link. ~1.6:1 against white, so as ink on a
     light page it is unusable; as a ground with --action-ink on it, it is one of the strongest pairings
     available. Amber is gone from the palette rather than demoted, so there is nothing left to reach for.

     ⚠️ --action-ink IS #264E61 AND NOT NEAR-BLACK, ON PURPOSE. That is the CRM's own tuned pairing,
     with their note: "softer than the dark theme ink, still ~6:1 on --action". Near-black measures higher
     and looks harder; this is the value that was actually designed against this blue, so it is the value
     used. Do not "improve" it by darkening.

     ⚠️ THE CRM HAS TWO THEMES AND THESE ARE THE LIGHT ONES. --action-hover is #62C0E0 (light) and not
     #4DA3C4 (dark), because our fills sit on a light page and the gentler darken is the one tuned for it. */
  /* THE CALL BUTTON. #71D2F5 is Alon's, chosen by name, and it stays.
     /!\ THE NUMBER IS WHITE AT ABOUT 1.6:1 AND THAT IS A DECISION, NOT AN OVERSIGHT. It has been
     measured, rendered and chosen: the digits carry at 32px/650, which is where the weight actually comes
     from. The LABEL underneath genuinely did not carry — it is small uppercase — so that is --action-ink
     at 5.2:1, which is the line that needed fixing.
     X DO NOT ADD A BORDER, OUTLINE, SHADOW OR DARKER FILL TO COMPENSATE. Every one of those has been
     proposed and declined. If this is revisited it is the FILL that changes, not decoration bolted onto
     it, and that is Alon's call to make. */
  --action:#71D2F5; --action-hover:#62C0E0; --action-ink:#264E61; --action-num:#FFFFFF;
  /* ⛔ THE ORIGINAL THEME'S GREEN, NOT THE CRM'S --ok. #2ECC71 is the value the old site used for the
     live dot and the one Alon has been looking at for years. Nothing else on the page uses it. */
  --live:#2ECC71;
  --action-rgb:113,210,245;

  /* The accent tint: how the CRM lights an ACTIVE row — lit, not filled. Used here for the same job.
     ⛔ THE TEXT COLOUR ON THIS TINT DEPENDS ON THE GROUND, AND GETTING IT WRONG IS A DOCUMENTED BUG.
     The CRM's dark theme uses near-white cyan #D7F7FF; its light theme overrides that to #0B6A8C with the
     comment "was near-white → invisible on light". This site is a LIGHT page with ONE dark band, so it
     needs both: --acc-text on the hero band, --acc-text-light everywhere else. */
  --acc-hover:#43A7C9; --acc-text:#D7F7FF; --acc-text-light:#0B6A8C;
  --acc-tint:linear-gradient(90deg,rgba(113,210,245,.16),rgba(113,210,245,.04));
  /* type */
  --hi:#F4F9FB;            /* near-white, on the band */
  --body:#1E272E;          /* body copy on paper */
  --slate:#4C5A64;         /* secondary text and structure */
  --dim:#93A8B5;           /* secondary text on the band */
  --dim-rgb:147,168,181;
  --faint:#5E6B75;         /* the quietest text there is — and still 4.9:1, because "quiet" is not a
                              licence to fail AA. #66777C looked right and measured 4.29. */
  /**
   * ⛔ ONE RADIUS FOR EVERY TAPPABLE THING. The header chip got 10px on its own and left every other
   * button on the site square, which reads as an oversight rather than a decision. It is a token now, and
   * no component invents its own — if a control needs a different corner, the token is wrong.
   */
  --radius:10px;
  --wrap:1180px;
}

*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--paper);color:var(--body);
  font:400 16px/1.6 'Geist',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
  /* ⛔ NO BOTTOM RESERVE ON THE BODY ANY MORE, AND THAT IS WHAT CLOSED THE SEAM. It was 84px of --paper
     under a --card footer, and the bar is 67-70px depending on the width — its number is on a vw clamp —
     so 14 to 17px of the WRONG COLOUR showed between the footer's last line and the top of the bar. No
     fixed number can match a height that moves, so the reserve moved into the footer instead: the slack
     is now the footer's own colour and invisible whatever the bar measures. See .site-foot below. */
  padding-bottom:0}
img{max-width:100%;height:auto;display:block}
/* ══ EVERY :hover IN THIS FILE IS GATED, AND THIS IS WHY ══════════════════════════════════════════════

   ⛔ A FINGER RESTING ON THE CALL BUTTON WHILE SCROLLING TRIGGERED :hover AND IT STUCK. iOS synthesises
   a hover on touch and holds it until something else is tapped, so the primary action of the page sat in
   its pressed colour indefinitely — a button that looks held down and is not.

   ⛔ THE MEDIA QUERY IS THE MECHANISM AND IT IS NOT DEVICE DETECTION. It is evaluated against the input
   actually in use rather than against a user-agent string, so a phone with a mouse plugged into it gets
   hover and the same phone touched does not. No JavaScript, nothing to keep in step with a device list,
   and it cannot be wrong about a device nobody has tested.

   ⛔ :focus-visible IS NEVER INSIDE THE GATE. Four rules paired a hover selector with a focus-visible one
   and each was SPLIT rather than wrapped whole. Keyboard focus has nothing to do with whether a pointer
   exists; gating it would have broken keyboard use on a touchscreen laptop — the one device where both
   inputs are real. .zip-results li.on was split out for the same reason: it is the keyboard highlight.

   ⚠️ AND TOUCH GETS NOTHING, WHICH TOOK TWO GOES TO GET RIGHT. The first version of this gate paired it
   with :active rules left ungated, on the reasoning that a press state is honest on any input. iOS fires
   :active on touch-down before it knows a tap from a scroll, so that reproduced the identical symptom.
   :active is gated too now — see the block at the foot of this file. */
a{color:var(--brand-deep)}
@media (hover: hover) and (pointer: fine){a:hover{color:var(--brand-darker)}}

/* == HOVER EASING =====================================================================================
   ONE DURATION FOR THE WHOLE SITE, HELD IN ONE PLACE. --hover is .14s ease. The reference value was Ava
   Connects' .1s, which is right for a 15px link and slightly quick for a 64px button; .14s covers both
   without reading as lag. It is a variable rather than a repeated literal because the failure this fixes
   is not "nothing eases" - it is "some things ease and the thing beside them snaps", and a literal
   copied to nine selectors drifts the moment one of them is touched.

   /! FOUR PROPERTIES, NAMED - NEVER "all". "all" sweeps up outline and transform, and both are
   forbidden below for reasons that are not stylistic. Every transition in this file lists its properties.

   NEVER outline. The focus ring appears on the frame the key lands, not 140ms later: somebody tabbing
   at speed has to see where they are while they are still moving. outline appears in no list here, and
   because "all" is never used it cannot arrive by accident.

   NEVER transform. Nothing moves on hover - it only changes colour. This also protects the scroll
   reveal, which rises 16px on a transform of its own: a blanket transform transition would have made it
   ease twice, at two different speeds, which is the exact fault this change exists to remove.

   /!\ THREE ELEMENTS ARE NOT IN THE LIST BELOW BECAUSE THEY ALREADY CARRY A TRANSITION OF THEIR OWN -
   .sticky, .review-arrow and .review-dot::before. transition is a shorthand: a later declaration
   REPLACES the earlier one rather than adding to it, so those three carry the full list inline at their
   own rules. If you add a property here, add it there too. */
:root{--hover:.14s ease}

a,
.head-call,.head-call .num,
.call-btn,.call-num,
.sticky-num,
.foot-nav a,.foot-links a,.foot-tel,
.hero-tick-mark .disc,
.review-dot,
.zip-box input,.zip-results li{
  transition:background-color var(--hover),color var(--hover),box-shadow var(--hover),fill var(--hover)}

/* THE KILL SWITCH IS THE SAME LIST, VERBATIM, PLUS THE THREE THAT CARRY THEIR OWN. transition:none,
   not a zero duration - a reader who has asked for no motion gets none. */
@media (prefers-reduced-motion:reduce){
  a,
  .head-call,.head-call .num,
  .call-btn,.call-num,
  .sticky,.sticky-num,
  .foot-nav a,.foot-links a,.foot-tel,
  .hero-tick-mark .disc,
  .review-arrow,.review-dot,.review-dot::before,
  .zip-box input,.zip-results li{transition:none}
}
/* The ring has to be visible on the dark band AND the light body, so it takes the wordmark blue
   rather than the action colour, which would all but vanish on white. 3.71 on light, 3.77 on the band. */
/* == THE FOCUS RING IS A HALO, NOT A HAIRLINE =========================================================
   A 2px ring in the accent, plus a wider low-alpha glow behind it drawn with a second box-shadow. The
   ring is what carries the 3:1 requirement — 5.13:1 on --paper and 5.47:1 on --card — and the glow is
   decoration that makes it findable on a busy band without thickening the line itself.

   /!\ box-shadow, NOT outline, FOR THE GLOW. An outline cannot be blurred or stacked; box-shadow can do
   both, and a spread shadow follows border-radius so the halo is round on the carousel dots and square
   on the inputs without a second rule for each.

   /!\ outline:2px solid transparent STAYS. Windows High Contrast Mode discards box-shadow entirely and
   would leave those elements with no visible focus at all; a transparent outline is forced to a system
   colour there, so the ring survives where the halo cannot. */
:focus-visible{outline:2px solid var(--brand-deep);outline-offset:2px}
/* /!\ max(), SO THIS IS 20px EVERYWHERE EXCEPT WHERE IT IS NOT ENOUGH. viewport-fit=cover hands the page
   the whole screen, notch included, so in LANDSCAPE on a notched iPhone the left inset is about 44px and
   a flat 20px gutter would put the first characters of every line under the sensor housing. In portrait
   and on every other device the inset is 0 and this is the 20px it has always been — so none of the
   header measurements taken at 320-1280 move. */
.wrap{max-width:var(--wrap);margin:0 auto;
  padding-left:max(20px, env(safe-area-inset-left));
  padding-right:max(20px, env(safe-area-inset-right))}
h1,h2,h3{font-family:'Geist',sans-serif;font-weight:700;
  letter-spacing:-.012em;text-wrap:balance;color:var(--ink);margin:0}
.eyebrow{font-family:'Geist',sans-serif;font-weight:700;font-size:12px;letter-spacing:.16em;
  text-transform:uppercase;color:var(--brand-deep)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--action);flex:0 0 auto;display:inline-block}

/* ── HEADER ─────────────────────────────────────────────────────────────────────────────────────────────
   No nav on an area page or its children: the only action available is to call. .site-nav only ever renders
   inside header-nav, which only the six generic pages use. */
.site-head{background:var(--ink);color:var(--paper)}
/* /!\ NO WRAP, EVER. It used to be flex-wrap:wrap, so below ~347px the chip dropped onto its own row and
   left a hole in the top right. A header that reflows into two rows on a small phone looks broken in a way
   a slightly smaller header does not. Everything in here scales instead; the wordmark is the only thing
   allowed to leave, and only at the very bottom of the range. */
.head-inner{display:flex;align-items:center;justify-content:space-between;gap:16px;
  padding-top:12px;padding-bottom:12px;flex-wrap:wrap;min-width:0}
/* == THE BRAND LOCKUP ===================================================================================
   Logo-Horizontal.png, measured at ONE threshold throughout (50% alpha, on the flat #0082D7 wordmark
   blue so the key's gradient globe cannot leak into the mask):

       key ink        196 x 316
       LOCKSMITH      510 wide, cap 68, baseline y143
       CONNECTIONS    652 wide, cap 68, baseline y254
       baseline gap   111   (Photoshop's 111pt leading, to the pixel)

   TWO LINES, UPPERCASE, flat #0082D7 -- the caps come from All Caps being on in the text object, not
   from the copy.

   /!\ MEASURE EVERYTHING AT THE SAME THRESHOLD OR DO NOT MEASURE AT ALL. An earlier pass read cap
   heights at 25/255 and widths at 128/255. At 25/255 antialiasing counts as ink, so the cap came back
   as 70 rather than 68 and the block as 181 rather than 178. Mixing the two inflated the block ratio to
   0.5728 and fed a cap error into the scale solve below. Both are corrected; neither was visible as a
   wrong-looking number at the time, which is the whole problem with mixed thresholds.

   /!\ THE PIXELS ARE THE CHECK, NOT THE SOURCE. The Character panel is the source -- see the wordmark
   rule below. These measurements exist to verify it, and they do: predicted 511.7 and 653.0 against
   measured 510 and 652.

   /!\ THE RATIO IS HELD IN calc(), NOT BAKED INTO A FONT-SIZE. Change --key-h and the wordmark follows.
   A hard-coded 16px would be correct at 52px and silently wrong at every other size, which is exactly how
   a lockup drifts: somebody bumps the key for a retina fix and the proportion quietly stops matching.

   /!\ --key-ink DISCOUNTS THE PNG'S OWN PADDING. logo-key.png is 1440x2716 with 31px of transparency top
   and bottom, so a CSS height of 52px draws only 50.8px of key. Measuring the ratio against the box
   rather than the ink would make the wordmark 2% large — invisible alone, visible next to the artwork,
   which is the one comparison this is going to be judged by.

   X NOT TITLE CASE. A comment here used to assert the artwork was title case, "Locksmith Connections" in
   Geist Bold, and set the header to match. The artwork is uppercase and has always been uppercase; the
   claim was never checked against the file. It is two lines of caps, and so is this.

   X TRACKING IS ZERO. Photoshop reports VA 0. An earlier pass set .1em because uppercase "usually wants
   tracking" -- but the artwork is a PNG and cannot be re-tracked, so anything the CSS adds is a
   difference from the mark it sits beside, not an improvement to it. */
.brand{
  /* /!\ THE WHOLE LOCKUP SCALES, MARK AND WORDMARK TOGETHER, because the wordmark is sized FROM the key
     — see --wm-block below. Shrinking the pair is what buys the room the header used to buy by throwing
     the wordmark away at 460px. */
  --key-h:clamp(34px, 8.2vw, 52px);
  --key-ink:calc(var(--key-h) * 0.9772);
  /* The artwork's block -- cap-top of line one to cap-bottom of line two -- over the key's ink height.
     X IT IS 0.5628, AND IT WAS 0.5728 FOR A WHILE. The old figure was measured off the PNG at a 25/255
     alpha threshold, which counts antialiasing as ink and inflates the block and the cap alike -- the
     same defect that produced the bogus cap height and, through it, the fitted scaleX(.8596) below.
     This one is derived from the spec instead of from pixels: 93 x (111/93 + 0.71875) = 177.8 over the
     key's 316. Nothing thresholded, nothing fitted. */
  --wm-block:calc(var(--key-ink) * 0.5628);
  /* block = leading + cap = 111/93 + 0.71875, so the block lands where the artwork puts it */
  --wm-size:calc(var(--wm-block) / 1.9123);
  display:flex;align-items:center;gap:calc(var(--key-h) * 0.019);text-decoration:none;color:var(--hi)}
.brand .logo-mark{flex:0 0 auto;display:block;line-height:0}
/* X HEIGHT DRIVES THE KEY, WIDTH FOLLOWS - NEVER A SQUARE BOX.
   The mark is 1440x2716, nearly 2:1 taller than wide. It was previously a square 640px source in a
   26x26 box with object-fit:contain, which was harmless then and would now letterbox the key into about
   14px of the 26px available, shrinking it by half for no reason. Fixing the HEIGHT and letting the
   width follow renders it at its own proportions and still reserves a stable box, because the aspect
   ratio is baked into the <img> width/height attributes by picture(). */
.brand .logo-mark img{display:block;height:var(--key-h);width:auto}
/* == THE FOOTER LOCKUP IS THE HEADER LOCKUP AT ANOTHER SIZE ============================================
   X IT USED TO BE A FLAT PNG OF THE WHOLE MARK while the header composed it from a key plus CSS text.
   Two constructions of one logo cannot help but diverge — the artwork tucks CONNECTIONS partly under the
   key, which text beside an image cannot do — and they did.

   /!\ --key-h IS THE ONLY THING THE FOOTER SETS. Everything else in .brand is derived from it:
   --key-ink, --wm-block, --wm-size, the 96% horizontal scale, the 111/93 leading, tracking 0 and the
   colour. That is what makes "one wordmark, one source" true in the cascade and not just in a comment.

   /!\ FIXED, NOT clamp(34px, 8.2vw, 52px). The header's key scales because it is competing with a phone
   chip for one row. The footer's has a column to itself, so it stays 40px on every device. */
/* /!\ 68px BECAUSE THAT IS WHAT MAKES THE LOCKUP 176px WIDE — the exact width the flat image occupied,
   which is the number the footer column was laid out around. Measured, not picked: 40 gives 104px, 56
   gives 145, 64 gives 166, 68 gives 176 on the nose. */
.foot-brand .brand{--key-h:68px}
/* ⛔ DO NOT DELETE UNTIL EVERY PAGE HAS BEEN RE-PUBLISHED. Pages published before this change still
   carry [logo kind="lockup"] markup, and with these two rules gone they would render a 3168px image. */
.foot-brand .logo-lockup{display:block;line-height:0}
.foot-brand .logo-lockup img{display:block;width:176px;height:auto}
/* Present to a screen reader, absent to everything else. */
.vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
  clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
.brand-text{display:flex;flex-direction:column;justify-content:center;line-height:1.15;gap:0}
/* == THE WORDMARK IS THE PHOTOSHOP SPEC, NOT A FITTED CURVE ============================================
   Alon's Character panel, applied literally and in full:

     Geist Bold 700, All Caps (TT is on -- that is why the artwork is uppercase)
     size 93pt   leading 111pt -> line-height 111/93 = 1.1935
     tracking VA 0             -> letter-spacing 0
     vertical 100%   HORIZONTAL 96%   -> scaleX(.96), and the vertical axis is untouched
     baseline shift -7pt       -> no CSS effect; it moves the line within its own box

   Checked against the exported PNG, at 93px with tracking 0 and 96% horizontal:

     LOCKSMITH    predicted 511.7   artwork 510   +0.33%
     CONNECTIONS  predicted 653.0   artwork 652   +0.15%

   The residual is the antialiasing threshold in the pixel measurement, not a real difference.

   X DO NOT SOLVE FOR A SCALE FACTOR HERE, AND THIS RULE ONCE DID. An earlier pass measured the artwork,
   measured Geist, and fitted scaleX and letter-spacing to make the two match -- landing on scaleX(.8596)
   and +0.0372em. It reproduced both lines to the pixel and it was still wrong, because a two-parameter
   fit against two measurements has no residual left to expose an error. Two compounding errors hid inside
   it, and a fitted constant is a number nobody can ever check against anything.

   /!\ THE ERROR WAS THE CAP HEIGHT, and it is worth naming because it is easy to repeat. Geist's cap is
   0.71875 of font-size, but canvas actualBoundingBoxAscent QUANTISES TO WHOLE PIXELS -- at font-size 100
   it returns 71, which reads as 0.71 and is 1.22% low. Normalising both the artwork and the font through
   that bad cap, then letting free tracking absorb what was left, turned a 4% horizontal scale into a
   fitted 14% one. Measure the cap at 2000px, where the same quantisation is 0.05%.

   X IT IS NOT A FONT-LOADING BUG, which was the other suspect. The shipped woff2 carries fvar, gvar and
   HVAR; font-weight:700, font-variation-settings:'wght' 700 and font-synthesis:none all measure an
   identical 739.26px, so the wght axis is genuinely being set and nothing is being synthesised.

   /!\ scaleX, NOT scaleY. The panel's 96% is the HORIZONTAL scale; vertical is 100%. This rule had the
   axis the wrong way round for a while, which left the wordmark full width and 4% short in height.

   /!\ THE WIDTH IS WHAT BREAKS THE LINE, NOT THE MARKUP, and that is deliberate. The block carries
   <b>Locksmith<br>Connections</b>, but the CSS must not DEPEND on that <br>, because the markup lives in
   D1 and reaches visitors only through a publish while the stylesheet is a static asset that ships with a
   deploy. Between the two there is a window where new CSS meets old HTML. Held at CONNECTIONS' own
   advance width, "LOCKSMITH CONNECTIONS" wraps at its space to exactly the same two lines, so the header
   is correct on both sides of that window and correct again afterwards.

   7.393em is that advance at tracking 0, measured off the font rather than guessed.

   /!\ A TRANSFORM DOES NOT SHRINK THE LAYOUT BOX, so the element still reserves its full un-condensed
   width and would leave 4% of dead space before the phone chip. -0.296em is 4% of the 7.4em above, which
   gives back exactly what the scale takes.

   /!\ THE TAGLINE IS EXEMPT from all of the above. It has no artwork to match -- see below. */
.brand-text b{font-family:'Geist',sans-serif;font-weight:700;text-transform:uppercase;
  font-size:var(--wm-size);line-height:calc(111 / 93);
  letter-spacing:0;color:var(--brand);
  width:7.4em;
  transform:scaleX(.96);transform-origin:left center;display:block;
  margin-right:-0.296em}
/* == THE HEADER HAS NO TAGLINE ==========================================================================
   X REMOVED FROM THE BLOCK *AND* HIDDEN HERE, ON PURPOSE. "24HR LOCKSMITH DISPATCH" set 151px wide
   against a 106px wordmark, so the lockup read as a tagline with a logo attached rather than the other
   way round -- and Alon's artwork has no tagline in it at all. The <i> is gone from header.html, but the
   markup lives in D1 and only reaches visitors on a publish, while this stylesheet ships with a deploy.
   Between the two, production serves the old markup. This rule is what makes that window safe; it can be
   deleted once a publish has been through, and there is no harm in leaving it.

   /!\ THE SAME PUBLISH-WINDOW REASONING AS THE LINE BREAK ABOVE. Any header change has to be correct
   against BOTH the markup that is live now and the markup that ships next. */
.brand-text i{display:none}
/* == THE MARK ALONE, BELOW THE MEASURED BREAKPOINT ======================================================
   X THE WORDMARK IS NOT SHRUNK TO FIT - IT IS REMOVED. Two earlier proposals both compromised the
   artwork's proportion to squeeze the name in beside a 230px phone chip: one shrank the key, one broke the
   artwork's proportion. Dropping the wordmark removes the width constraint rather than negotiating with
   it, which is what responsive logo behaviour normally is - the mark carries the brand, the name is
   redundant with the <title>, the aria-label and the footer lockup, all of which stay.

   /!\ THE LOCKUP ALREADY SCALES ON ITS OWN, AND THAT IS WHY THE DROP POINT IS SO LOW. --key-h is
   clamp(34px, 8.2vw, 52px), so the whole lockup is at its 34px floor — 88px wide, wordmark included — from
   414px down. It costs 88px there, not the 135px the old arithmetic charged it. The chip is the immovable
   half of this, a tappable phone number plus its dispatch line, and that is not up for negotiation on a
   page whose only job is the call. */

/* /!\ TWO DROP POINTS, BECAUSE THERE ARE TWO HEADERS. A generic page carries a 44px hamburger that an
   area page does not, so "where does the lockup stop fitting" has two different answers and one number can
   only be right for one of them. 484 was the generic page's answer applied to both, which hid the wordmark
   across 162px of width where an area page still had room for it — the same mistake 506 made before it.

   SWEPT 600 -> 320 IN 1px STEPS, media query neutralised, html{overflow-y:hidden} so the layout viewport
   is the width asked for rather than the width minus a desktop scrollbar. Wrap is detected by the row's
   HEIGHT, not by scrollWidth: .head-inner is flex-wrap:wrap, so it never overflows — it wraps, silently,
   and an overflow test reads zero at every width. That is what made the earlier arithmetic wrong.

       area / sibling / service-areas    lockup 88 + chip 212 + gap 6 + gutters 22  -> holds to 337
       generic (+ 44px toggle, 2 gaps)   with the spacing ladder below              -> holds to 437

   ⚠️ THESE MOVED WHEN THE STATUS DOT GAINED A 5px MARGIN. They were 323 and 407 against a 207px chip;
   the chip is 212 now and both points shifted by 14 and 30. Anything that changes the chip's width
   invalidates them — re-run the sweep, do not adjust them by the difference.

   /!\ THE GENERIC FIGURE IS 437 BECAUSE THE SPACING LADDER MOVED, NOT BECAUSE THE CHIP GAVE WAY. At the
   old ladder it was 468. Bringing the header's gap to 8px at 480 and the page gutter to 14px at 430 buys
   61px, and the wordmark keeps every one of them. The chip never shrinks below legibility and the toggle
   keeps its 44px tap target; only the air between them changes.

   /!\ THE NAME STAYS IN THE ACCESSIBILITY TREE. .brand already carries aria-label="Locksmith
   Connections", so display:none on the text costs a screen reader nothing. Do not swap this for
   visibility:hidden or a zero font-size, both of which keep the box and reintroduce the width problem
   this exists to remove. */

/* == THE SPACING LADDER =================================================================================
   /!\ THE HEADER'S GAP CLOSES BEFORE THE PAGE'S GUTTER DOES, and they are separate queries for that
   reason. The gap is header-only, so it can close at 480 without touching anything else. The gutter is
   .wrap, which every band on the page uses — closing it early would leave the header's logo out of line
   with the content below it, so it closes once, at 430, for the whole page at the same time. */
@media (max-width:480px){
  .head-inner{gap:8px}
}
@media (max-width:430px){
  .head-call{padding-left:11px;padding-right:11px}
  .wrap{padding-left:14px;padding-right:14px}
}
/* The last few pixels, for 320-wide devices. Same principle: air, never the number. */
@media (max-width:330px){
  .head-inner{gap:6px}
  .head-call{padding-left:9px;padding-right:9px}
  .wrap{padding-left:11px;padding-right:11px}
}

/* == BELOW THE DROP POINT: THE MARK ALONE, AND THE CHIP TAKES THE WIDTH IT VACATED =======================
   X THE CHIP DOES NOT STAY PINNED RIGHT. .head-inner is justify-content:space-between, so removing the
   wordmark used to leave the freed width sitting as a hole between a 18px key and a chip against the right
   edge. flex:1 on the stack spends that width on the number instead, which is the one thing on a landing
   page that is worth more when the screen is smaller.

   X AND IT IS NOT CENTRED ON THE ROW EITHER. Centring the chip moves the hole rather than removing it —
   half on each side instead of all of it on one. The chip GROWS; only its contents are centred, inside a
   box that now reaches the gutter.

   /!\ THE NUMBER GROWS WITH THE ROOM, FROM THE FLOOR AT 320. Above the drop point --num-size is
   clamp(16px, 4.6vw, 22px) and the wordmark is competing for the same width. Below it there is no
   competition, so the digits climb from the same 16px floor at 320 to the same 22px ceiling — reached at
   374 on a generic page and immediately on an area page, which has no hamburger to pay for. Measured, not
   guessed: 22px digits make the chip 254px, and at 320 a generic row has 224px to give it.

   /!\ min-width:0 IS LOAD-BEARING. A flex item's default min-width:auto is its content, and .head-call-note
   carries a city name; without this a long city would stop the stack shrinking and put the header back on
   two rows on exactly the pages this is meant to fix.

   /!\ THE BASIS IS 0, NOT auto, AND IT IS DEFENSIVE RATHER THAN A FIX. flex:1 1 auto gives a growing item
   a base size of its own max-content, and flex-wrap decides using the BASE size, before any shrinking —
   so an item that COULD have shrunk gets a line of its own instead. That has never been observed here;
   basis 0 removes the possibility rather than relying on the chip staying narrow enough.

   ⛔ HOW TO MEASURE WHETHER THIS ROW HAS WRAPPED, because two wrong answers were shipped before the
   right one. Row HEIGHT is blind: a wrapped 44px hamburger barely changes a row that already holds a
   46px chip. Comparing each child's `top` is worse than blind — it reports two rows at EVERY width,
   because a centred line puts a short .brand and a tall .head-call-stack at different tops by design.
   The test is whether the children's vertical SPANS OVERLAP: items on one flex line overlap, a wrapped
   item does not. On that test the live page is one row from 600 to 308. */
@media (max-width:436px){
  .site-head[data-nav] .brand-text{display:none}
  /* /!\ 260px IS THE CHIP AT ITS CEILING PLUS SIX. 22px digits make the pill 254px wide — measured,
     and recorded in the note above this block — so a 260px cap lets the number reach the size the
     growth exists for and stops the moment the pill would start carrying empty space instead.
     X WITHOUT IT flex:1 1 0 SPENDS EVERY REMAINING PIXEL ON THE BOX. That was the instruction and it
     went too far: at the top of this range the pill ran gutter to hamburger with nothing either side,
     which is a slab rather than a control. The number is unchanged — what is removed is the air inside
     the pill, not the legibility the growth bought.
     /!\ THE SPARE WIDTH GOES BACK TO THE ROW, NOT TO A MARGIN. .head-inner is justify-content:
     space-between, so whatever the cap declines is distributed between the three items — which is
     exactly the clear gap either side that the cap is there to produce. */
  .site-head[data-nav] .head-call-stack{flex:1 1 0;min-width:0;max-width:260px}
  .site-head[data-nav] .head-call{justify-content:center;
    --num-size:clamp(16px, calc(16px + (100vw - 320px) / 9), 22px)}
}
@media (max-width:336px){
  .site-head:not([data-nav]) .brand-text{display:none}
  .site-head:not([data-nav]) .head-call-stack{flex:1 1 0;min-width:0;max-width:260px}
  .site-head:not([data-nav]) .head-call{justify-content:center;--num-size:22px}
}

/* == THE THREE LONG CITIES, AND WHY THE FLOOR IS 400 ====================================================
   The note under the chip is width:0 + min-width:100%, so it gets exactly the BUTTON's width and a long
   city wraps to a second line inside it rather than widening the header — see the note on
   .head-call-note for why that trade is the right one. Measured at 444 with the button at its natural
   240: only three cities of 122 need more.

       Prince George County    258   (+18)
       Minneapolis - St. Paul  255   (+15)
       Southern Maryland       243   (+3)
       Colorado Springs        232   fits, and so does every one below it

   260 is that worst case plus two, and it is the SAME constant the 336 block below uses — the chip at
   its ceiling. Above 336 the button was never being squeezed: free space measured 68 / 70 / 64 / 54 / 36
   / 20 from 460 down to 345 and never reached zero. What narrows it is --num-size: clamp(16px, 4.6vw,
   22px) shrinking the DIGITS with the viewport, so the pill gives up width voluntarily while free space
   sits unused beside it. This asks for a floor, not for growth.

   /!\ 400, NOT 394. The arithmetic is brand 88 + gap 8 + chip 260 = 356 against the content width, and
   at 394 the content box is 354 — two pixels short, which would push the header onto two rows on the
   three pages this exists to fix. 400 gives 372 and clears it by 16.

   /!\ 336 TO 400 IS DELIBERATELY LEFT ALONE. There is no room there: the caption needs 258 and the
   widest the stack can be beside a visible wordmark is 221. Below 336 the block underneath already
   solves it — the wordmark hides and the chip takes 260 — which is why this header renders BETTER at
   330 than at 380. Alon found that; it is not a bug, it is the two rules meeting.

   ⛔ justify-content:center IS NOT OPTIONAL HERE. .head-call has no justify-content of its own, so it
   defaults to flex-start. That is invisible while the button is exactly as wide as its contents, and the
   moment a min-width makes it wider the handset and the number sit against the left edge with the slack
   piled on the right. The 336 block already pairs the two for the same reason. */
@media (min-width:400px){
  .site-head:not([data-nav]) .head-call{min-width:260px;justify-content:center}
}

/* ── THE HEADER STRIP SWITCH ───────────────────────────────────────────────────────────────
   sw_head_band, independent of sw_hero_band. It was briefly tied to the hero band, which cannot express a
   white header above a dark hero — the combination actually worth testing. Two switches, four states.

   ⚠️ THE CHIP DOES NOT DOMINATE THE WORDMARK ON WHITE — MEASURED, AND IT IS THE OPPOSITE PROBLEM.
   The worry was that --action would shout against a white strip. It does not: #71D2F5 against --card is
   1.67:1, so the chip's own EDGE all but disappears and it reads as tinted text rather than as a button.
   Its digits are fine (5.22:1); it is the boundary that goes. Against the dark strip the same chip sits
   at 8.91:1 and needs nothing.

   WCAG 1.4.11 asks 3:1 for a UI component that has to be identifiable as one, so this is a real shortfall
   on light and not a matter of taste. The fix is a 1px --action-ink edge, ONLY on light — the fill colour
   is untouched, because dimming or restyling the call CTA to suit a header background would be the wrong
   trade on a page whose only job is a phone call.

   The wordmark meanwhile goes to --ink at 14.88:1 and the tagline to --slate at 6.92:1, so the name
   carries the strip and the chip stays the one saturated object in it. */
/* /!\ NO EDGE ON THE CHIP, DELIBERATELY. A border-color rule used to sit here on a chip whose own rule
   sets border:0, so it never rendered — and when that was found and fixed with an inset shadow, the fix
   was declined along with every other way of outlining this control. The fill is the fill. */
body.head-light .site-head{background:var(--card);color:var(--body);border-bottom:1px solid var(--rule)}
body.head-light .brand{color:var(--ink)}
/* The generic pages carry a nav; on white it needs the light-ground pair, not the on-dark one. */
body.head-light .site-nav a{color:var(--slate)}
@media (hover: hover) and (pointer: fine){body.head-light .site-nav a:hover{color:var(--brand-deep)}}

/* ⛔ NO STROKE, AND A RADIUS. The border was the same colour as the fill — invisible on the dark header,
   a hard edge on the light one, and doing nothing either way. The whole site was square-cornered and read
   hard; 10px is enough to soften the one element you are meant to press without making it a pill.

   ⚠️ THE ICON IS BIGGER THAN THE DIGITS, WHICH IS THE POINT. It was 15px against 19px type and read as an
   afterthought. Geist's cap height is about 0.71em, so at 22px the digits cap out around 15.6px — the icon
   is set to 1.5x that, 23px, which makes it the largest thing in the chip. A phone icon smaller than the
   number is a decoration; larger, it is the verb. */
/* == THE CHIP AND ITS DISPATCH LINE =====================================================================
   X THE LINE IS OUTSIDE THE BUTTON, not inside it. The chip is the phone number and nothing else; the
   line underneath is a label ON the chip, the same relationship the hero button has with its own note.
   Putting it inside would make the tap target taller for no gain and would put two type sizes inside one
   pill.

   X AND IT IS NOT "CALL NOW - {CITY} DISPATCH 24/7". The hero button's note already says exactly that,
   and with the hero centred and its button moved up the two sit close enough to read as a stutter. Short
   form here, full form there.

   /!\ [opt] WRAPS IT, so the six generic pages and three legal pages -- which have no city -- get no line
   at all rather than the words "DISPATCH 24/7" floating without a place, or a leading space. */
.head-call-stack{display:flex;flex-direction:column;align-items:stretch;gap:5px}
/* /!\ width:0 + min-width:100% IS WHAT KEEPS THE BREAKPOINT CITY-INDEPENDENT, and it is not a hack for
   its own sake. The line carries [page_city], so its natural width is a property of the DATA: "Chicago
   dispatch 24/7" sets 248px and "Minneapolis - St. Paul dispatch 24/7" sets far more. Left to size the
   column, the widest city would decide where the header collapses and the breakpoint would be a
   different number on every one of the 123 area pages. width:0 removes it from the flex column's
   intrinsic width, min-width:100% then stretches it to whatever the BUTTON needs, and a long city wraps
   to a second line inside that width instead of widening the header. */
.head-call-note{width:0;min-width:100%;
  font-family:'Geist',sans-serif;font-weight:700;font-size:10px;letter-spacing:.13em;line-height:1.3;
  text-transform:uppercase;color:var(--dim);text-align:center}
body.head-light .head-call-note{color:var(--slate)}
/* /!\ ONE SIZE DRIVES THE WHOLE CHIP. The number takes font-size:1em and the icon is sized in em off the
   same box, so a change here moves BOTH and they cannot drift apart at a width nobody measured. That is
   the bug in item 4: anything that resized the digits alone left the icon behind. */
/* /!\ .45em, NOT A PIXEL COUNT, AND THE SAME FIGURE ON ALL THREE BUTTONS. em here is --num-size, so the
   gap tracks the number it separates: tight on the header chip's small type, open on the bar's large
   type, identical in proportion. Three hard-coded values - 10px here, 9px on .call-row, another on the
   bar - were why the same component looked differently spaced in different places. */
.head-call{--num-max:22px;--num-size:clamp(16px, 4.6vw, 22px);font-size:var(--num-size);
  display:flex;align-items:center;gap:.45em;text-decoration:none;background:var(--action);
  border:0;border-radius:var(--radius);padding:9px 16px;min-height:46px}
.head-call{box-shadow:0 1px 2px rgba(var(--ink-rgb),.10),0 3px 8px rgba(var(--ink-rgb),.10)}
@media (hover: hover) and (pointer: fine){.head-call:hover{background:var(--action-hover)}}
/* The chip is on screen at every scroll position, so it earns the pulse more than anything else does.
   Smaller than the call button's, because it sits beside 19px digits rather than 30px ones. */
/* The handset belongs to the number, so it takes the number's colour. The sticky bar's icon sits beside
   the word CALL instead, so it keeps --action-ink with that label. */
/* == ONE SOURCE FOR THE NUMBER AND ITS HANDSET, ON ALL THREE SURFACES ===================================
   X THEY HAD DRIFTED, AND THIS IS WHY. The colour was set per surface, so the hero button ended up with a
   white icon and a DARK number while the header chip and the sticky bar had both white. Its rule carried
   `color:var(--action-num)` followed on the next line by `color:var(--action-ink)` — the later declaration
   won, silently, and only that one surface was wrong.

   /!\ THE ICON IS IN THE SAME SELECTOR LIST AS THE NUMBER, deliberately. The handset belongs to the digits
   it sits beside; if a future change moves one it has to move the other, and putting them in one rule is
   what makes that unavoidable rather than remembered.

   /!\ THE LABEL IS NOT IN HERE. .call-note and .sticky-note stay --action-ink on all three — that is the
   line that carries the contrast, and it is a different job from the number. */
/* /!\ TRACKING OPENS UP AS THE NUMBER SHRINKS. At 368px the digits ran together — tabular figures set
   tight are one block at small sizes, and a phone number is the one string on the page read digit by
   digit. The em value is constant but the SPACE it buys grows relative to the glyph as the size falls,
   which is the wrong way round; this scales it against the ceiling so a small number gets MORE. */
.head-call .num,.call-btn .call-num,.sticky-num,
.head-call .phone-icon,.call-btn .phone-icon,.sticky .phone-icon{color:var(--action-num)}
.head-call .online-indicator{width:.45em;height:.45em}
.head-call .num{font-family:'Geist',sans-serif;font-size:1em;white-space:nowrap;
  font-variant-numeric:tabular-nums}
/* == THE NAV, AND THE HAMBURGER THAT REPLACES IT ========================================================
   /!\ THE FIT POINT IS MEASURED, NOT ROUND, AND IT IS NOT ARITHMETIC EITHER. Adding the parts up gave
   871; binary-searching the real layout gave 885. The difference is the chip's dispatch line, flex
   min-content and sub-pixel rounding, none of which a sum catches. Measured: one row holds to 885 and
   breaks at 884, so the nav collapses at 884.

   X THE CHIP NEVER COLLAPSES AND NEVER SHRINKS BELOW LEGIBILITY. It is the only thing on the page that
   earns money; the nav is what gives way.

   /!\ THE SAME <nav> IS THE ROW AND THE PANEL. Below the fit point it comes out of flow and hangs under
   the header instead of being duplicated — one list to keep in step, and a screen reader reads five links
   rather than ten.

   /!\ THE PANEL OVERLAYS, IT DOES NOT PUSH. position:absolute so opening it cannot move the hero down;
   a header that grows on tap shifts everything the thumb was aiming at. */
.site-nav{display:flex;gap:18px;flex-wrap:wrap;order:3;width:100%}
.nav-toggle{display:none}
.site-nav a{color:var(--dim);text-decoration:none;font-size:14px;padding:6px 0}
/* ── THE SOCIAL ROW IN THE NAV ──────────────────────────────────────────────────────────────────────────
   ⚠️ HIDDEN ON DESKTOP, SHOWN IN THE MOBILE PANEL. On a wide screen the nav is one row that must not wrap,
   and five more links is what makes it wrap; in the panel there is vertical room and they cost nothing.
   The footer carries them at every width, so nothing is unreachable either way.
   ⚠️ NOT ICONS. An icon set is five more requests or an inline sprite to maintain, and "Facebook" as a
   word is what a screen reader announces anyway. */
.nav-social{display:none}
.nav-social a{color:var(--dim);text-decoration:none;font-size:13px;padding:6px 0}
@media (hover: hover) and (pointer: fine){.nav-social a:hover{color:var(--brand-lift)}}
body.head-light .nav-social a{color:var(--slate)}
@media (hover: hover) and (pointer: fine){.site-nav a:hover{color:var(--brand-lift)}}

@media (max-width:884px){
  .site-head{position:relative}
  /* out of the row, under the header, full width */
  .site-nav{position:absolute;top:100%;left:0;right:0;order:0;width:auto;
    display:none;flex-direction:column;gap:0;
    background:var(--card);border-top:1px solid var(--rule);
    /* ⛔ THE PANEL HAS TO READ AS SOMETHING ON TOP OF THE PAGE. At .12 opacity the shadow was there and
       nobody could see it, so the open menu looked like part of the header that had grown. Alon: "lets
       have a box shadow on the opened menu nav so it will be clear that its a menu on top of a page". */
    box-shadow:0 14px 34px rgba(var(--ink-rgb),.26), 0 2px 6px rgba(var(--ink-rgb),.10);z-index:30}
  .site-head.nav-open .site-nav{display:flex;animation:nav-in 120ms ease-out}
  .nav-social{display:flex;flex-wrap:wrap;gap:14px;width:100%;
              padding-top:12px;margin-top:4px;border-top:1px solid var(--hair)}
  /* /!\ LEFT-ALIGNED, NOT CENTERED, AND THAT IS THE CONVENTION RATHER THAN A PREFERENCE. Every mobile
     drawer on iOS and Android aligns its items flush left; centering belongs to full-screen overlay menus
     with about five short items. These eight run from "Car Locksmith" to "Car Key Replacement", and
     centering them makes the eye zig-zag down the list instead of scanning one edge.
     /!\ 17px AND 500 RATHER THAN A BIGGER SIZE. At 16px/400 they read as body text that happens to be in
     a panel. The weight is what says "navigation" — going bigger without it just makes larger body text,
     and eight rows already fill the screen at this height. */
  .site-nav a{display:block;padding:15px 20px;font-size:17px;font-weight:500;min-height:52px;
    border-bottom:1px solid var(--rule-2);color:var(--body)}
  .site-nav a:last-child{border-bottom:0}
    /* The panel takes focus when it opens; it is a region, not a control, so it shows no ring.
       :focus-visible should not match it anyway — this is belt and braces against a UA that disagrees. */
    .site-nav:focus,.site-nav:focus-visible{outline:none}
    .site-nav a:focus-visible{background:var(--paper);color:var(--brand-deep)}
  @media (hover: hover) and (pointer: fine){.site-nav a:hover{background:var(--paper);color:var(--brand-deep)}}
  .nav-toggle{display:flex;align-items:center;justify-content:center;order:4;
    /* /!\ NO margin-left. It was 2px, invisible beside a 16px gap, and it became the only hole in
       the row once the chip started filling the width the wordmark vacated. The space between these
       two is .head-inner’s gap and nothing else, so there is one number to change, not two. */
    /* X NO BORDER AND NO BACKGROUND. It drew a box around three bars that already read as a control;
       the outline said "button" a second time, in a header where nothing else is boxed.
       /!\ THE 44x44 TAP TARGET IS UNCHANGED AND IS NOT WHAT THE BORDER WAS FOR. A target is a region a
       thumb can hit, not a region an eye can see — removing the drawn box takes away nothing a finger
       was using.
       /!\ THE FOCUS RING IS A DIFFERENT THING AND IT STAYS. :focus-visible draws an OUTLINE, which is
       painted outside the border box and takes part in no layout, so it is unaffected by any of this.
       border-radius is kept for it alone: an outline follows the radius, so the ring stays rounded. */
    width:44px;height:44px;flex:0 0 auto;padding:0;
    background:transparent;border:0;border-radius:var(--radius);cursor:pointer}
  /* three bars from one element: the middle is the box, the outer two are its pseudo-elements */
  .nav-bars,.nav-bars::before,.nav-bars::after{display:block;width:20px;height:2px;
    background:var(--ink);border-radius:2px}
  .nav-bars{position:relative}
  .nav-bars::before,.nav-bars::after{content:'';position:absolute;left:0}
  .nav-bars::before{top:-6px}
  .nav-bars::after{top:6px}
  .site-head.nav-open .nav-bars{background:transparent}
  .site-head.nav-open .nav-bars::before{top:0;transform:rotate(45deg)}
  .site-head.nav-open .nav-bars::after{top:0;transform:rotate(-45deg)}
}
@keyframes nav-in{from{opacity:0;transform:translateY(-4px)}}
@media (prefers-reduced-motion:reduce){
  .site-head.nav-open .site-nav{animation:none}
  .nav-bars,.nav-bars::before,.nav-bars::after{transition:none}
}

/* ── HERO BAND ──────────────────────────────────────────────────────────────────────────────────────────
   ⛔ THE ORDER INSIDE THIS BAND IS NOT A STYLING DECISION. H1, headline question, city + 24/7 line, urgency
   line, call button. Seven years of tuning on mobile. CSS may change how it looks and must not change what
   comes first.
   ⚠️ A dark ground can cost conversion on emergency services, which is why this is a BAND on a light page
   and not a dark site — and why body.band-light exists so the whole thing can be tested. */
.hero{position:relative;background:var(--ink);overflow:hidden;isolation:isolate}
/* ⛔ THIS LAYER MUST NEVER BE OPAQUE ACROSS THE WHOLE BAND.
   It was, until 25 Aug 2026: a solid ink-to-ink-3 gradient at z-index 1, sitting directly on top of the
   photograph at z-index 0. The photo was not "mostly hidden", it was not visible AT ALL — the opacity on
   .hero-photo was doing nothing, because the layer above it had no alpha anywhere. The band read as a flat
   dark rectangle with text on it, which is exactly what it was.

   It is a SCRIM now, not a fill: dense where the words are, transparent where the photograph is. The
   headline's legibility comes from the dense end of the gradient, not from burying the image. */
/* ⛔ THE SCRIM WAS EATING THE PHOTOGRAPH, AND THE CONTRAST TOOL WAS WHY.
   Every time a measurement wanted margin the scrim got heavier, the checker passed, and the page got a
   little more depressing. The tool was optimising a number while the design got worse — it had climbed to
   .78 at the midpoint, which is not a scrim, it is a coat of paint with a photograph behind it.

   ⛔ LEGIBILITY MOVED TO WHERE THE TEXT IS INSTEAD. Two changes, neither of which touches the image:
     · the wash is roughly halved, so the photograph reads as a photograph
     · the type carries its own shadow — see .hero-head / .hero-city below
   A shadow darkens the ~2px around each letterform and nothing else, which is precisely the area that
   decides whether a word is readable. Painting the whole frame to protect the same letters is the blunt
   version of the same idea, and it costs the picture.

   ⚠️ DO NOT PUSH THESE STOPS BACK UP TO SATISFY tools/contrast-check.mjs. That tool composites flat
   colour and cannot model a text shadow, so it UNDER-reports legibility here by construction. Read its
   numbers as "how much does the wash alone carry", not as a verdict. */
/* ⛔ MOBILE HAS ITS OWN DIRECTION, AND THE OLD ONE WAS BACKWARDS.
   The desktop layout puts text LEFT and photograph RIGHT, so a gradient clearing to the right is correct
   there. At narrow widths the two overlap — the text sits ON the photograph — and the vertical gradient
   inherited from that thinking was strongest in the MIDDLE at 56% and weakest at the very top, which is
   exactly where the eyebrow, the headline and the subtitle are. The serving line was illegible over a
   bright sky and the headline was close behind.

   Strongest at the top, holding through the ticks, clearing to nothing by the bottom — where the call
   button provides its own solid ground and needs no help. Measured at 360/375/390, not carried over. */
.hero-bg{position:absolute;inset:0;z-index:1;
  background:radial-gradient(120% 80% at 78% 12%,rgba(var(--action-rgb),.10) 0%,rgba(var(--action-rgb),0) 62%),
             linear-gradient(180deg,
               rgba(var(--ink-rgb),.52) 0%,
               rgba(var(--ink-rgb),.70) 18%,
               rgba(var(--ink-rgb),.80) 30%,
               rgba(var(--ink-rgb),.80) 46%,
               rgba(var(--ink-rgb),.76) 58%,
               rgba(var(--ink-rgb),.66) 70%,
               rgba(var(--ink-rgb),.40) 82%,
               rgba(var(--ink-rgb),.14) 92%,
               rgba(var(--ink-rgb),0) 100%)}

/* ⚠️ EVERY STOP ABOVE IS MEASURED, NOT CHOSEN BY EYE.
   tools/contrast-check.mjs decodes the three mobile heroes that actually ship, finds the BRIGHTEST pixel
   under each line of text — a white van panel, a lit window, an overcast sky — and solves for the least
   scrim that still clears 4.5:1 against that pixel. The stops are those numbers plus a little headroom.
   The dip at 56% is the urgency line: it is gold, and gold on white is the weakest pairing on the page.
   Lift any of these and the words stop being readable over somebody's bright sky. Re-run the tool.  */
/* ONE <picture>. Which frame loads is decided by `media` on the <source>, never by CSS — a hidden <img>
   still downloads. <picture> is an inline wrapper with no box of its own, so the positioning lives on it
   and the fitting on the <img> inside. */
.hero-photo{position:absolute;inset:0;z-index:0;display:block;opacity:1}
/* ⛔ --focus IS WHY THE TECHNICIAN IS STILL IN THE PICTURE ON A PHONE.
   The five service photographs are 2.36 panoramas and the mobile band is about 0.82, so a crop discards
   roughly two thirds of the width. In every one of them the technician stands in the RIGHT third, so the
   default `center` removes the subject and leaves an empty road. Each service carries its own measured
   percentage; the area hero has a real portrait cut and does not need one, so it falls back to 50%. */
/* ⛔ MIRRORED FRAMES. See MIRROR in pictures.ts for why this is the frame and not the fade: the hero
   text is centred, so the scrim must be dense through the middle and reversing it would strand half the
   headline on bare photograph. Flipping the picture moves a left-hand subject into the clear right-hand
   zone the whole hero design is built around.
   /!\ object-position IS MEASURED ON THE UNFLIPPED SOURCE and the transform is applied after, so the
   focus percentage in FOCUS still refers to the original image. Do not "correct" it to 100 minus itself. */
picture[data-flip] > img{transform:scaleX(-1)}
/* ── THE BLURRED STAND-IN, PAINTED WHILE THE PHOTOGRAPH IS STILL IN FLIGHT ─────────────────────────
   The browser cannot request the hero until it has parsed the HTML, so there is a gap that CANNOT be
   closed — measured on production, text paints at ~300ms and the photograph lands ~250-350ms after it.
   The only question is what fills it, and it used to be white.

   --lqip is a 24px copy of the same frame, inlined in the page as base64 (about 280 characters), so it
   costs no request and paints with the headline. blur() is what makes 24px enough: at that size the
   image is colour and shape only, and the blur removes every trace of blockiness. scale() past 100%
   hides the soft transparent edge blur() leaves around the box.

   /! NO PLACEHOLDER, NO BOX. When --lqip is unset the background-image is invalid at computed-value
   time, so the ::before paints nothing at all rather than a grey rectangle.
   /! IT MIRRORS WITH THE FRAME. data-flip turns the <img> around; without the same transform here the
   blurred version underneath would be the mirror image of the photograph fading in over it. */
/* ── ONE FRAME WHOSE CROP HAS TO MOVE WITH THE WIDTH ────────────────────────────────────────────────
   car-key-locksmith-handover is a 2.36 panorama with its two subjects in the right third: the technician
   at 66-72% and the customer at 85-100%. object-fit:cover shows boxAR/2.36 of it, which is 60% of the
   frame at 1440, 44% at 1010, 40% at 930 and 23% on a phone — so a fixed focal point that centres the
   handover on a wide screen slides onto the customer's hair on a narrow one, and one that centres it
   narrow throws the customer away wide.

   Each value below solves f for a window CENTRED ON 70%, the keys, at that width's slice:
       f = (0.70 - k/2) / (1 - k)
   The inline --focus is the phone value; these only ever step it up. Nothing else on the site needs this
   and nothing else gets it — the selector is one class on one picture. */
/* the phone value lives here too, so nothing is set inline and the steps below can actually win */
.hero-handover{--focus:10%}
@media (min-width:760px){ .hero-handover{--focus:83%} }
@media (min-width:980px){ .hero-handover{--focus:88%} }
@media (min-width:1150px){ .hero-handover{--focus:95%} }
@media (min-width:1360px){ .hero-handover{--focus:100%} }

.hero-photo::before{content:'';position:absolute;inset:0;
  background-image:var(--lqip);background-size:cover;
  background-position:var(--focus,50%) center;
  filter:blur(24px);transform:scale(1.08)}
picture[data-flip].hero-photo::before{transform:scaleX(-1) scale(1.08)}
/* the photograph paints over the stand-in — later sibling, but stated rather than relied upon */
.hero-photo img{position:relative}

.hero-photo img{width:100%;height:100%;object-fit:cover;display:block;
  object-position:var(--focus,50%) center}
/* ⛔ DO NOT DELETE THIS RULE UNTIL EVERY PAGE HAS BEEN RE-PUBLISHED. 26 Aug 2026.
   `.hero-photo-band` was a short-lived section that held the hero picture OUTSIDE the hero, added in
   1be9314 and removed again from the seed the same night. Its markup still sits in every published page
   until a publish rebuilds them — and with the band's own rules gone, `.hero-photo` above resolves its
   `position:absolute` against the page instead of a hero, so the photograph painted full-bleed behind the
   price table and the whole body on every area, sibling and service-areas page. Alon: "it now put the
   background image behind the entire site".
   Neutralising the orphan costs nothing (the hero draws its own picture through `.hero-bg`) and makes the
   ALREADY-PUBLISHED pages render correctly with no publish needed — which matters because the publish
   route is behind Access and only Alon can press it. */
.hero-photo-band{display:none}
/* == THE HERO'S AIR IS A SCALE, NOT A NUMBER ==========================================================
   18px was a gap rather than air: on a phone the headline sat hard against the underside of the header
   strip, and the last tick butted the price table's edge.

   /!\ IT SHRINKS AS THE VIEWPORT GROWS, WHICH IS THE OPPOSITE OF THE USUAL RAMP AND IS THE POINT. On a
   phone the band is tall, the text runs the full width and there is nothing else in the frame, so the
   space has to come from padding. On desktop the column is narrower, the photograph occupies the rest
   of the band, and the same number would read as a hole above the headline.

       375   33.9px      768   29.6px      1280   23.9px

   calc(38px - 1.1vw) is that line; the clamp only catches the extremes — the floor is not reached until
   1454px and the ceiling not until 182px, so across every width anyone uses this is linear.

   ⛔ THE BOTTOM CARRIES THE RATES OVERLAP AND CANNOT SIMPLY MIRROR THE TOP. .rates pulls its card up by
   --rates-overlap (14px here) with a negative margin, so 14px of the bottom padding is underneath the
   card rather than visible beneath the last tick. The +14px is that, given back. The desktop rule's
   78px is a different, hand-tuned number for the 46px overlap up there and is deliberately untouched. */
.hero-inner{position:relative;z-index:2;
  --hero-air:clamp(22px, calc(38px - 1.1vw), 36px);
  padding-top:var(--hero-air);
  padding-bottom:calc(var(--hero-air) + 14px)}
/* == MOBILE: THE PHOTOGRAPH IS AN OVERLAY AGAIN, NOT A BAND ============================================
   X IT WAS A 230px BAND UNDER THE HERO TEXT FOR ONE DAY. The measurements were fine — the price table
   stayed at 14.63% against a 20% bar — and the result was still wrong: without the picture behind
   them the headline, subtitle and serving line read as a stack of text with nothing holding them.
   Alon looked at both and chose the overlay.

   ⛔ THE PICTURE NEVER LEFT <section class="hero"> IN EITHER VERSION, and it must not. .hero-photo is
   position:absolute with no size of its own; the wrapper section that briefly held it on 26 Aug is
   what painted it behind the price table and the whole body on every area page. Reverting here means
   deleting a media query, not moving an element — which is the whole reason that version was built
   as CSS in the first place.

   ⚠️ WHAT THE BAND WAS SOLVING IS STILL TRUE: below 760 the words run the full height of the band, so
   the scrim sits under every one of them and the picture never gets to be a picture. Thinning it is
   not available either — measured over the real frame, a bottom stop of .42 white puts the last
   benefit tick at 4.30:1 and .30 puts it at 3.13:1, both under the 4.5:1 bar. The trade is knowingly
   in favour of the text having something behind it. */

/* ── THE TYPE SCALE ─────────────────────────────────────────────────────────────────
   ⛔ THESE TWO SIZES ARE MEASURED, NOT CHOSEN, AND THEY ARE WHY GEIST WAS NOT A DROP-IN SWAP.
   Plex Sans Condensed was carrying both, and Geist has no condensed cut, so bold Geist is materially
   wider at the same pixel size. Measured at 375px with 335px of usable width:

   · .hero-head "Locked out right now?" was 392px at 37.5px — it went from one line to two.
     It fits on one line at 32px, so the clamp is 8.3vw: 31.1px at 375, and 26.6px at 320, where the
     usable width drops to 280 and the ceiling is 26.8. Both ends verified, not interpolated.
   · .page-h1 worst case is "Locksmith Minneapolis - St. Paul MN", 338px at 19px — three pixels over,
     so it wrapped. 18px with neutral tracking clears it with room to spare.

   ⚠️ IF THE COPY GETS LONGER, RE-MEASURE. The longest H1 is a data question, not a design one: it is
   whatever city name the XML happens to carry. Minneapolis - St. Paul is today's worst case. */
.page-h1{font-size:18px;font-weight:600;color:var(--hi);letter-spacing:0;margin-bottom:20px;
}
.hero-body{display:flex;flex-direction:column;gap:14px}
/* ⚠️ THE SHADOW IS THE SCRIM NOW. It is what lets the wash above be light enough to see through.
   Two layers: a tight dark halo for edge definition against a bright patch, and a wider soft one to lift
   the word off busy detail. Removed under band-light, where the type is dark on a pale ground. */
.hero-head{font-size:clamp(24px,7.7vw,42px);line-height:1.04;color:var(--hi);letter-spacing:-.018em;
}
.hero-city{margin:0;color:var(--hi);font-size:16px;line-height:1.45;
}
.hero-city b{color:var(--hi);font-weight:600}
/* ⛔ NOT display:flex, AND THIS IS THE BUG THAT MADE THE HERO WRAP BADLY ON A PHONE.
   This line is one sentence with an inline <span> in it for the closing time. As a flex row every child
   became a flex ITEM, so the span could not participate in the sentence's line breaking — measured at
   375px, the text ran to x=277, the time jumped to x=309 leaving a 32px hole mid-sentence, and the
   remainder dropped to a third line. A sentence is text flow, not a layout. The dot is what needed
   positioning, so the dot is what gets positioned, and the words are left alone to wrap normally. */
.hero-urgency{margin:0;position:relative;padding-left:16px;
  font-family:'Geist',sans-serif;font-weight:700;font-size:14px;line-height:1.45;color:var(--hi)}
.hero-urgency .dot{position:absolute;left:0;top:.52em}
/* ── THE SIX SERVICE TICKS ──────────────────────────────────────────────────────────────────
   ⛔ THE WHOLE ROW IS THE TAP TARGET, not the words inside it. Same lesson as the sticky bar: a thumb
   landing anywhere on the line has to place the call. min-height is 44px for that reason and not for
   looks, and display:flex on the <a> is what makes the row the hit area rather than the text run.
   Two columns on a phone so all six are above the fold; one column would push the call button under it. */
/* == THE CENTRED HERO ==================================================================================
   /!\ THE PHOTOGRAPH IS INSIDE .hero AND MUST STAY THERE. .hero-photo is position:absolute;inset:0, so
   .hero{position:relative;overflow:hidden} is the only thing giving it a size and a clip. Moving it to a
   section of its own is what painted it behind the entire document on every area, sibling and
   service-areas page. Nothing in this block moves the picture.

   The light ground and the white gradient are body.band-light, the existing sw_hero_band switch — the
   fade is WHITE, so the image is never dimmed, only covered where the words are. */
.hero-centred .hero-inner{text-align:center}
.hero-centred .hero-body{align-items:center;gap:16px}
/* The row is left-aligned from its mark even though the band is centred. */
.hero-centred .hero-tick{text-align:left}
.hero-centred .hero-head{max-width:18ch;margin-inline:auto}
.hero-centred .hero-city{max-width:42ch;margin-inline:auto}
.hero-centred .hero-actions{align-items:center;margin-top:2px}
.hero-centred .call-btn{width:100%;max-width:420px}

/* /! DECORATION, NOT A CONTROL. No href, no focus state, no hover state -- see src/render/ticks.ts.
   A hover colour on something that cannot be clicked is a promise the element does not keep. */
.hero-stars{display:inline-flex;gap:5px;padding:6px 4px}
.hero-star{width:22px;height:21px;fill:var(--gold);flex:0 0 auto;
  filter:drop-shadow(0 1px 2px rgba(var(--ink-rgb),.55))}

/* == THE FOUR BENEFIT TICKS =============================================================================
   Two columns on desktop, one on mobile -- see the media query below. 44px minimum row height because the
   whole row is the tap target, not just the words.

   /!\ align-items:flex-start, NOT center. "Automotive, residential and commercial" wraps to two lines at
   375px; centred, its disc floats between the two lines instead of sitting against the first. */
/* == THE TICK GRID =====================================================================================
   X justify-items:center IS WHAT MADE THEM ZIGZAG. Each row shrink-wrapped to its own text and then
   centred itself, so a short line and a long line began at different x and the marks staggered down the
   page like a staircase.
   /!\ THE GRID IS CENTRED; THE ITEMS INSIDE IT ARE NOT. justify-content places the whole grid,
   justify-items:start makes every row start at the same edge — one straight line of marks per column. */
.hero-ticks{display:grid;grid-template-columns:auto;gap:2px 26px;margin:2px 0;
  justify-content:center;justify-items:start}
.hero-tick{display:flex;align-items:flex-start;gap:9px;min-height:44px;text-decoration:none;
  padding:6px 4px;border-radius:var(--radius);text-align:left;
  color:var(--hi);font-size:15px;line-height:1.3;
}
.hero-tick span{align-self:center}
/* The disc, sized against the line it labels and nudged to sit on the first line's cap band.
   /!\ TWO SHAPES, COLOURED SEPARATELY. The disc carries the state; the mark is white in both, because a
   tick that changes with its background is the hole this used to be. No drop-shadow: there is no
   photograph behind these any more, and a shadow under a solid disc on a pale panel is just grime. */
.hero-tick-mark{width:20px;height:20px;flex:0 0 auto;margin-top:.35em;fill:var(--gold)}
/* /!\ .35em, MEASURED, NOT REASONED. At .1em the disc's centre sat 4.08px above the centre of the line
   it labels — a quarter of a 19.5px line, which is why Alon could see it. The arithmetic you would expect
   ((line-height 19.5 - mark 20) / 2) says the offset should be about zero and it is wrong here: the flex
   line's cross size is set by the 20px mark, not by the text, so the text sits lower inside it than the
   line-height alone predicts.
   Swept on the live hero at 15px/19.5: .1 -> 4.08 high, .25 -> 1.83, .3 -> 1.08, .35 -> 0.33, .4 -> 0.42
   LOW. .35em is the only value inside half a pixel with a round number.
   X DO NOT "FIX" THIS WITH align-items:center ON .hero-tick. Two of the six benefit lines wrap on a
   phone, and centring against a two-line block puts the tick in the gap between the lines. flex-start
   plus this nudge is what keeps it beside the FIRST line at every width.
   ⚠️ THE 16px COPY IN .service-col IS NOT WRONG and was measured too: 16px mark on a 21px line wants
   2.5px and has 2.25px. Leave it. */
.hero-tick-mark .disc{fill:var(--gold)}
.hero-tick-mark .mark{fill:#fff}

/* == HOVER AND FOCUS ON THE DARK BAND ===================================================================
   X --brand IS UNREADABLE HERE AND THAT IS WHY THIS EXISTS. #0082D7 against the #0A1218 band is a blue on
   a near-black blue-grey; on the old tick links the hover state was effectively invisible, so a visitor
   could not tell a row was interactive. --action (#71D2F5) is the light end of the same family and reads
   immediately against the band.

   /!\ FOCUS IS NOT LEFT TO THE BROWSER DEFAULT. A default focus ring is drawn in the UA's colour, which
   on a dark ground is frequently a dark blue -- the same problem in a different place. Every interactive
   thing in this band gets the same visible --action ring. */
/* /!\ --action (#71D2F5) IS THE FOCUS RING ON BOTH PANELS, AND THE HOVER COLOUR ONLY ON THE DARK ONE.
   It was specified for hover and focus because on the dark band --brand disappeared into the navy. On the
   PALE panel the opposite is true: #71D2F5 measures 1.67:1 on --card, so as hover TEXT it would be the
   same bug in the other direction. The ring stays --action on both -- a 3px outline is a shape, not text,
   and reads on either ground -- and the hover text goes to --brand-deep on light. */
/* == THE HOVER STATE IS THE DISC, NOT THE WORDS, AND THAT IS A MEASUREMENT I GOT WRONG ONCE ============
   X THE LABEL USED TO GO --brand-deep ON HOVER AND IT WAS FAILING BADLY. I checked that colour against
   --paper, got 5.98:1, and shipped it — but these words are not on paper, they are over the photograph,
   where the scrim is deliberately thin so the picture reads. Composited over the real frame the hover
   label measured 3.95 / 3.92 / 2.92 / 2.02 at 654 / 375 / 1280 / 950 against a 4.5:1 bar. #0B6E9E is
   lighter again and makes it 3.39 / 3.36 / 2.51 / 1.73.

   /!\ NO BLUE PASSES HERE. The resting --body only just clears it at 4.67:1 at 950px, and every accent
   in the palette is lighter than --body. So the label does not change colour at all: hover is carried by
   the disc, which goes gold -> accent, and by the focus halo. A hover state that makes text HARDER to
   read is worse than no hover state.

   /!\ THE DISC IS A 20px SOLID WITH A WHITE TICK INSIDE IT. The information is the white mark on the
   disc, which is unaffected by what is behind the disc — this is the 3:1 graphic bar, not the 4.5:1 text
   bar, and it is measured against the panel rather than through the scrim. */
/* /!\ --brand, AND IT IS THE ONE UI-ADJACENT PLACE IT IS ALLOWED. The disc does not carry the meaning
   of the line — the label does — and at rest it is gold at 1.95:1, which is already well under any bar
   a graphic would have to clear. It is decoration signalling a hover, so the 3:1 rule for meaningful
   graphics does not apply to it. The LABEL still does not change colour: no blue in the palette clears
   4.5:1 over the photograph, which was measured and settled. */
.hero-tick:focus-visible .hero-tick-mark .disc{fill:var(--brand)}
@media (hover: hover) and (pointer: fine){.hero-tick:hover .hero-tick-mark .disc{fill:var(--brand)}}

body.band-light .hero-tick{color:var(--body)}

.hero-price{margin:0;font-family:'Geist',sans-serif;font-size:13px;color:var(--dim);
  font-variant-numeric:tabular-nums}
.hero-price b{font-weight:700;color:var(--hi)}
.hero-price i{font-style:normal;color:var(--faint);margin:0 .2em}
.hero-price a{color:var(--dim);text-decoration:none;border-bottom:1px solid currentColor}
@media (hover: hover) and (pointer: fine){.hero-price a:hover{color:var(--hi)}}
body.band-light .hero-price{color:var(--slate)}
body.band-light .hero-price b{color:var(--ink)}
body.band-light .hero-price a{color:var(--brand-deep)}

.hero-actions{display:flex;flex-direction:column;gap:14px;margin-top:4px}
.hero-anchor{margin:0;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;color:var(--dim);font-size:13px}
.hero-anchor-label{font-family:'Geist',sans-serif;font-weight:700;font-size:11px;letter-spacing:.15em;
  text-transform:uppercase}
.hero-anchor-range{font-family:'Geist',sans-serif;font-weight:700;font-size:20px;color:var(--hi);
  font-variant-numeric:tabular-nums}
.hero-anchor a{color:var(--dim)}


/* ── THE SWITCHABLE LIGHT BAND ───────────────────────────────────────────────────────────
   Same order, same components, light ground.

   ⛔ THIS VARIANT HAD THE EXACT BUG THE DARK ONE WAS FIXED FOR, AND IT WENT UNNOTICED BECAUSE NOBODY
   LOOKED AT IT. .hero-bg was `linear-gradient(168deg, var(--hi), var(--rule-2))` — no alpha anywhere,
   fully opaque, sitting at z-index 1 directly on top of the photograph, with .hero-photo knocked back to
   16% underneath for good measure. The right half of the hero was not "washed out", it was EMPTY WHITE.
   Identical mistake to the one corrected in .hero-bg above on 25 Aug; the light variant simply never got
   the same treatment. It is a scrim here too now: dense where the words are, clear where the photo is. */
body.band-light .hero{background:var(--card)}
body.band-light .hero-photo{opacity:1}
/* Mobile: vertical scrim, densest under the mid-page copy, easing off top and bottom so the frame reads.
   Lighter overall than the dark variant needs to be — the text here is --ink on white, so the scrim is
   protecting dark type from dark patches of photograph rather than light type from bright ones. */
body.band-light .hero-bg{background:linear-gradient(180deg,
  rgba(255,255,255,.66) 0%,
  rgba(255,255,255,.78) 16%,
  rgba(255,255,255,.88) 34%,
  rgba(255,255,255,.92) 52%,
  rgba(255,255,255,.86) 70%,
  rgba(255,255,255,.70) 86%,
  rgba(255,255,255,.58) 100%)}
/* ⚠️ --body, NOT --slate. Measured over urban-hero-mobile the slate H1 came out at 2.9:1 against a
   near-black pixel at the top of the frame, where the scrim is deliberately at its thinnest so the
   photograph reads. Darkening the type fixes it at 6.6:1; thickening the scrim would have fixed the
   number by hiding the image, which is the mistake this variant already made once. */
body.band-light .page-h1{color:var(--body)}
body.band-light .hero-head{color:var(--ink)}
body.band-light .hero-city{color:var(--body)}
body.band-light .hero-city b{color:var(--ink)}
body.band-light .hero-urgency{color:var(--brand-deep)}
body.band-light .hero-anchor{color:var(--slate)}
body.band-light .hero-anchor-range{color:var(--ink)}
body.band-light .hero-anchor a{color:var(--brand-deep)}

/* ── THE CALL BUTTON ────────────────────────────────────────────────────────────────────────────────────
   AMBER IS A FILL COLOUR AND THIS IS WHAT IT IS FOR. #FBA51D measures about 2.1:1 on white, so it can
   never carry text on a light ground — but as a ground with near-black on top it is one of the
   strongest pairings available. Every amber on this page is a fill: this button, the sticky bar, the
   header chip, the key mark, a rule, a dot. Nowhere is it an accent doing text work.

   The two apparent exceptions are not exceptions. .review .stars is a glyph read as a shape, and
   .sticky-btn is amber ON an ink pill INSIDE the amber bar — an inversion of the fill, which is what
   ties the pill to the bar it sits on. 44px minimum everywhere. */
/* == THE BUTTON HOLDS THE NUMBER AND NOTHING ELSE =====================================================
   X THE LABEL USED TO BE INSIDE THE PILL and it made the button taller without making it any more
   tappable: eleven pixels of tracked small caps that nobody aims at, inflating the one target on the
   page that matters. It sits beneath the pill now, centred, outside it — which is what the header chip
   has always done. Four surfaces, one construction.

   /!\ .call-stack IS THE HEADER'S .head-call-stack, UNDER ANOTHER NAME AND FOR THE SAME REASON. The
   label's size is tied to the number (see .call-note below), so the two have to share a scope; with the
   label outside the button, that scope is the wrapper rather than the button.

   /!\ --call-num IS DECLARED ONCE AND READ TWICE. The stack needs it for the label and the button needs
   it for the digits and the handset. Writing the clamp out twice is how the two drift apart at a width
   nobody measured — and a standalone .call-btn with no wrapper (the router rows) still gets it. */
:root{--call-num:clamp(23px, 6.4vw, 32px)}
.call-stack{--num-max:32px;--num-size:var(--call-num);
  display:flex;flex-direction:column;align-items:stretch;gap:7px}
/* /!\ THE PADDING IS EVEN NOW. 14/12 was top-heavier to sit the label optically inside the pill; with
   nothing under the number there is nothing to balance against. */
.call-btn{--num-max:32px;--num-size:var(--call-num);font-size:var(--num-size);
  display:flex;flex-direction:column;align-items:center;gap:2px;text-decoration:none;
  background:var(--action);border-radius:var(--radius);padding:14px 18px;min-height:44px}
/* ⛔ A STATIC SHADOW, AND NOTHING ABOUT IT MOVES. Soft, low and short — the shape a real object casts
   sitting on the page, not a glow around a coloured rectangle. No spread, no colour from the accent, no
   CHANGE on hover: the value is identical in both states, so although box-shadow is in the shared
   transition list there is nothing for it to animate. A shadow that grew under the pointer would turn
   the primary tap target into something that reacts before it has been pressed, and the one rule this
   button has is that nothing under a thumb may move.
   ⚠️ IT ADDS NO HEIGHT. box-shadow is painted outside the border box and takes part in no layout, so it
   cannot shift anything below it — which is why it is a shadow rather than a border. */
.call-btn{box-shadow:0 1px 2px rgba(var(--ink-rgb),.10),0 3px 8px rgba(var(--ink-rgb),.10)}
@media (hover: hover) and (pointer: fine){.call-btn:hover{background:var(--action-hover)}}
/* /!\ THE SIZE COMES FROM --num-size, NOT A SECOND CLAMP. This rule used to carry its own
   clamp(26px,7.2vw,32px), so the hero button sized its number from one place and its icon from another —
   the exact drift the single-source rule exists to prevent, hiding in the surface it was written for. */
.call-row{display:flex;align-items:center;justify-content:center;gap:.45em;font-size:1em}
.call-num{font-family:'Geist',sans-serif;font-size:1em;line-height:1;white-space:nowrap;
  letter-spacing:-.01em;font-variant-numeric:tabular-nums}

/* The handset, drawn from primitives in src/render/phone-icon.ts — not an icon-font glyph. This comment
   described a Font Awesome path and a .phone-ring WRAPPER, and both are gone: the icon is composed on a
   24-unit grid and .phone-ring is a second class on the same <svg>, not a parent.

   ⛔ THE REASONING BELOW IS STILL TRUE AND IS THE WHOLE POINT: a CSS animation REPLACES the static
   transform on the element it runs on rather than composing with it. That is why there is no mirror on
   .phone-icon any more — one had been re-added, and it made the handset face left at rest and right for
   the seconds it was ringing. The drawing faces the right way on its own; nothing needs flipping.

   ⛔ IF A MIRROR IS EVER GENUINELY NEEDED, THE WRAPPER HAS TO COME BACK WITH IT. Folding scaleX(-1)
   into every keyframe does not work either: mirroring reverses the visual direction of a rotation, so
   the exact angles below would swing the wrong way. One element flips, its parent rotates. */
.phone-ring{display:inline-flex;flex:0 0 auto;
  /* ⛔ KEEP 50% 4px. It pivots near the top of the handset instead of spinning about its middle, which
     is the difference between a phone ringing and a phone tumbling. From the original theme. */
  transform-origin:50% 4px}
/* ⚠️ flex:0 0 auto IS NOT OPTIONAL. In the ringing case the .phone-ring wrapper carries it, but in the
   header chip and the sticky button the <svg> IS the flex item, and flex-shrink defaults to 1 — measured
   at 5.9px wide instead of 15px in the header, squashed by a long phone number sharing the row. */
/* == LET SHRINKING CHILDREN ACTUALLY SHRINK =============================================================
   X min-width:auto IS THE DEFAULT ON A FLEX OR GRID CHILD, and it means "never narrower than my content".
   Below about 290px that is what pushed text outside its own panel: the rates card narrowed with the
   viewport while "Service fee (fixed)" and "$29" refused to, so they sat on the page rather than on the
   white. The background was not shrinking faster than the text — the text was refusing to shrink at all.

   /!\ EVERY TWO-COLUMN ROW NEEDS IT, not just the one that was reported. The rate card, the tick rows, the
   footer bottom bar and the header row are the same construction and would each fail the same way at some
   width; three of them had simply not been looked at below 290px yet.

   /!\ min-width:0 ALONE IS NOT ENOUGH FOR A TABLE. A table establishes its own layout, so the label cell
   also needs a wrap opportunity — overflow-wrap on the header cell — or a long single word holds the
   column open regardless of what its parent permits. */
.rates-card,.rate-table,.hero-ticks,.hero-tick,.foot-bottom,.foot-fine,.head-inner,.sticky-row,
/* ⛔ .foot-nav AND .foot-links JOINED THIS LIST THE DAY THEY WERE ADDED, AND THEY HAD TO. A wrapping
   flex row inside a flex column takes min-width:auto = its MAX-content width, not its min-content — so
   the five service links measured 391px inside a 347px column and pushed the whole document 30px wide
   at 375. It is the same trap every other selector on this line is here for. */
.foot-nav,.foot-links{min-width:0}
.rate-table{table-layout:auto}
.rate-table th{min-width:0;overflow-wrap:anywhere}
.hero-tick span{min-width:0;overflow-wrap:anywhere}
.foot-fine,.foot-copy{min-width:0;overflow-wrap:anywhere}

/* == THE PHONE NUMBER'S WEIGHT =========================================================================
   /!\ 650 IS SET ON THE AXIS, NOT AS font-weight. Geist ships as one variable face with wght 100-900, so
   an arbitrary 650 is available — but font-weight:650 is allowed to snap to a named instance, and 600 or
   700 is exactly what it would snap to. font-variation-settings addresses the axis directly.

   700 outweighed the 700 wordmark and closed the counters on 8, 9, 0 and 4 — the one string on the page
   somebody reads digit by digit. 600 reads thin next to the pill. This sits between them.

   /!\ font-variation-settings DOES NOT INHERIT USEFULLY through font shorthand resets, so it is declared
   on each number rather than once on a parent. */
.head-call .num,.call-num,.sticky-num,.numintext,.foot-tel,.zip-tel{
  font-variation-settings:'wght' 600;font-weight:600;
  /* /!\ POSITIVE AT EVERY SIZE, NOT ONLY WHEN SMALL. This ramped from 0 at the top of the clamp, so the
     BIGGEST numbers on the page — the hero button and the mid-page band — had no tracking at all, and
     "(877) 940-4442" ran its 4-4-4 into one blob. The ramp was right and its floor was wrong: a phone
     number is read digit by digit and transcribed, so it wants space at every size. 0.02em is the base;
     the ramp still opens it further as the size drops, because a small figure needs proportionally more.
     .numintext, .foot-tel and .zip-tel set no --num-size, so the calc falls back to 1em - 1em = 0 and
     they get the 0.02em base — which is the intended answer for a number set at a fixed size. */
  letter-spacing:calc(0.02em + (var(--num-max, 1em) - var(--num-size, 1em)) * 0.028)}

/* == THE PHONE ICON IS SIZED AND PLACED BY THE CAP BAND ==================================================
   X IT WAS FIXED PIXELS IN TWO PLACES AND THAT IS THE WHOLE BUG. .head-call pinned it to 23px and
   .sticky-btn to 15px, so the same glyph measured 1.30x the digits' cap height in the header and 0.87x in
   the hero button -- bigger than the number in one place, smaller in the other, from one drawing. Sized
   in em against the cap band it is the same relationship everywhere and follows any type change for free.

   --icon-box converts a CAP height into a BOX height, because the two are not the same thing: the drawing
   fills 0.8934 of its own viewBox and the rest is the margin that keeps the arcs off the edge. Ink height
   = box x 0.8934, so box = cap / 0.8934. 0.71875 is Geist's cap ratio, the same figure the wordmark uses
   -- measured at 2000px, not re-derived here.

   /!\ 1.18x CAP, NOT 1.00x, AND THE PARITY TARGET WAS THE MISTAKE. Matching cap height exactly makes the
   icon look SMALLER than the digits beside it: a digit fills its cap box almost completely, while a
   handset laid on the diagonal with arcs above it fills far less of the same square. Optical parity needs
   the icon larger than the metric. Only this constant changed -- the single-source sizing below is what
   keeps the ratio holding at every width, and it still does.

   /!\ THE EM IS THE CONTAINER'S, SO EVERY CALL SURFACE SETS ITS font-size TO ITS OWN DIGIT SIZE and the
   number inside takes font-size:1em. .call-btn already worked that way; .head-call did not, which is why
   it needed a pixel override in the first place.

   X NO transform HERE, AND THAT IS NOT A STYLE PREFERENCE. .phone-ring animates transform, and a CSS
   animation REPLACES a static transform rather than composing with it -- the same collision that once
   forced a .phone-ring wrapper to exist just to keep a mirror and a rotation apart. Relative positioning
   does not touch the transform property, so the ringing icon still rings.

   /!\ CENTRED ON THE CAP BAND, NOT THE LINE BOX. A line box reserves descender space that digits never
   use, so centring in it should sit the icon low. The shift is (ascent - descent)/2 - cap/2; for Geist
   that is (1 - 0.2903 - 0.71875)/2 = -0.0045em, which is 0.1px at 22px. Measured before and after: the
   icon was already within 0.2px of the cap band, so this is not what was making it look wrong -- the
   1.30x SIZE was. It is written as the formula anyway so the relationship is guaranteed rather than a
   coincidence of Geist's particular metrics. */
/* /!\ THE ICON IS FIXED, NOT PROPORTIONAL, BELOW ITS FULL SIZE. Sized in em it tracked the number all the
   way down, and by ~500px it was a smudge beside legible digits. --icon-box is now the size it had when
   the number is at its ceiling, held at every width: a handset does not need to shrink to stay in
   proportion, it needs to stay recognisable. Optical alignment to the cap band is unchanged.
   0.71875 is Geist's cap ratio; 1.18 is optical parity (a digit fills its cap box, a diagonal handset does
   not); 0.8934 is the drawing's share of its own viewBox. */
/* ⛔ NO transform HERE, AND THE MIRROR THAT WAS HERE IS WHY THE HANDSET FLIPPED WHEN IT RANG.
   phoneIcon(true) returns class="phone-icon phone-ring" — ONE element with both classes — and the ring
   keyframes set transform:rotate(). A CSS animation REPLACES the static transform on the element it runs
   on rather than composing with it, so the resting icon was mirrored and the ringing icon was not: it
   faced left until the animation started and right for the four seconds it ran.

   ⛔ THE FIX IS THAT THE DRAWING ALREADY FACES THE RIGHT WAY. src/render/phone-icon.ts says so in as many
   words — "NO scaleX(-1), AND THEREFORE NO WRAPPER. It is drawn facing the right way" — and this rule
   contradicted it. Rendering the raw geometry confirms it: handset lower-left, arcs radiating upper-right,
   which is the orientation the ringing state was already showing.

   ⛔ DO NOT PUT A MIRROR BACK. If one is ever genuinely needed it cannot live on this element: it needs
   the .phone-ring wrapper that used to exist purely to keep a mirror and a rotation on separate elements.
   Adding scaleX(-1) here again re-creates exactly this bug, and it only shows for the seconds the icon is
   animating — which is why it survived. */
.phone-icon{--icon-box:calc(var(--num-max, 1em) * 0.71875 * 1.18 / 0.8934);
  width:var(--icon-box);height:var(--icon-box);
  position:relative;top:calc((1em - 0.2903em - 0.71875em) / 2);
  display:block;flex:0 0 auto;fill:currentColor;color:var(--action-ink)}

/* Presence — the original theme's markup and values, laid out with flex.
   ⚠️ The original positioned this with `right:50px` absolute nudges against a fixed-width button. That
   does not survive a fluid layout, so the indicator is a flex item here and the nudges are gone. The
   SIZES and COLOURS are untouched.

   ⚠️ #2ECC71 measures 1.22:1 against this button. It was never a luminance contrast — on the old site's
   darker blue it was 1.64:1 — it reads as a green dot because of HUE, not lightness. Our button is
   lighter, so it reads a little weaker than it used to. Left exactly as specified rather than "fixed"
   with a ring, because this is the value that has been on the site for years. */
/* Presence — the original theme's markup and values, laid out with flex.
   ⚠️ The original positioned this with `right:50px` absolute nudges against a fixed-width button. That
   does not survive a fluid layout, so the indicator is a flex item here and the nudges are gone. The
   SIZES and COLOURS are untouched.

   ⚠️ NO RING. There was briefly a 1.5px --action-ink ring on the dot and halo, added because #2ECC71
   measures 1.22:1 on --action and the halo is 1.16:1 at its brightest. The numbers are real but the
   conclusion was wrong: the ping reads perfectly well on its own — MOVEMENT is what the eye catches here,
   not luminance — and a hard outline around a 14px circle is just noise. Confirmed on the published page.
   Do not re-add it on the strength of the contrast figure alone. */
/* /!\ THE DOT SITS FURTHER FROM THE HANDSET THAN THE HANDSET DOES FROM THE NUMBER, ON PURPOSE. All four
   surfaces lay this row out with a single flex gap, so dot, handset and digits were evenly spaced — and
   evenly spaced reads as one three-part object, with the dot looking like part of the icon. They are not
   one object: the handset belongs to the number and says "call this", the dot is a separate claim about
   the dispatch line being staffed right now. 5px on top of the row's own gap is what separates the claim
   from the label.

   /!\ margin-right, NOT margin-left, SO THE DOT DOES NOT MOVE TOWARDS THE EDGE. Its distance from the
   button's left padding is unchanged; the handset and the number move right instead and the button grows
   by 5px. The number is never squeezed to pay for this.

   ⚠️ THE BUTTON GETTING 5px WIDER MOVES THE HEADER'S MEASURED BREAKPOINTS. The wordmark drop points were
   solved against the chip's width — re-measure them after touching this, do not assume they held. */
/* ⛔ NO margin-right HERE. It used to carry 5px, which sat ON TOP of whichever flex gap the surrounding
   button used - so dot-to-handset was always exactly 5px wider than handset-to-number, on every call
   button on the site. It read as the handset being crowded against the digits, and because the three
   buttons use different gap values it was a different amount of wrong in each one. Spacing belongs to
   the row that lays these out, not to the dot. */
.online-indicator{position:relative;top:calc((1em - 0.2903em - 0.71875em) / 2);
  display:inline-block;width:14px;height:14px;flex:0 0 auto}
.online-indicator .dot,.online-indicator .pulse{position:absolute;width:100%;height:100%;
  border-radius:50%;background:var(--live)}
/* Invisible until something animates it, so reduced-motion gets a clean solid dot and no ghost ring. */
.online-indicator .pulse{opacity:0}
/* /!\ THE LABEL IS LONGER THAN THE NUMBER ROW THAT SIZES THE BUTTON, so left alone it wraps to two lines
   at EVERY width, not just narrow ones — the button is only as wide as its widest child, and that child
   is the number. Three things keep it on one line: the button takes the column's width so the label has
   somewhere to go, the size scales down with the viewport, and nowrap plus ellipsis is the last resort
   for a city long enough to beat both. Truncating a label is bad; wrapping the primary action's caption
   onto two lines is worse. */
/* /!\ THE LABEL IS SIZED FROM THE NUMBER, not from its own clamp. It held 11px while the number shrank
   beneath it, so the pairing came apart at exactly the widths where it matters. 0.36 of the number keeps
   the relationship it has at full size. */
/* /!\ IT IS NO LONGER ON THE ACTION COLOUR, SO IT NO LONGER TAKES --action-ink. Outside the pill the
   label sits on paper rather than on the button's own fill, where --action-ink would be near-invisible.
   ⚠️ THERE IS NO HERO CASE. The hero button carries no label at all — see seed/blocks/hero.html — so
   own ink it would have been near-invisible on both. */
.call-stack .call-note{color:var(--slate);text-align:center}
.call-note{font-family:'Geist',sans-serif;font-weight:700;
  /* /!\ TIED TO THE NUMBER, WITH A FLOOR. 0.36 is the relationship the pair has at full size, and it
     holds all the way down until the number hits its own floor — at which point 0.36 gives 8.28px of
     tracked uppercase, which is not readable. max() keeps them in step everywhere it matters and stops
     the label following the number past the point of legibility. */
  font-size:max(9.5px, calc(var(--num-size, 32px) * 0.36));letter-spacing:.14em;
  text-transform:uppercase;color:var(--action-ink);text-align:center;
  max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}

/* ── RATE CARD ──────────────────────────────────────────────────────────────────────────────────────────
   Of five aggregator competitors, exactly one publishes any pricing. It is a real differentiator, so it
   gets design weight wherever sw_price_pos puts it.
   ⚠️ Google, Feb 2019: the font must be equal to or bigger than the majority of the text on the page, it
   must not be inside a closable notification, and an icon linking to a price list does not qualify. Body
   text on this page is 16px; the rate rows are 16px. Do not shrink them to fit. */
/* ⛔ THE CARD RISES INTO THE BAND, SO IT HAS TO PAINT ON TOP OF IT.
   It was position:static, which puts it in the in-flow layer — BELOW the positioned hero band. The band
   then painted over the card's top strip, taking the gold border with it, and the "full rate card below"
   link sat jammed against that edge. It read as the link being clipped; it was the card being buried.

   ⚠️ THE OVERLAP IS A VARIABLE BECAUSE TWO RULES DEPEND ON IT. The card is pulled up by it and the band's
   bottom padding has to clear it. Written as two unrelated numbers they drift apart, and the clearance
   silently goes to zero at some viewport nobody tested — which is exactly what happened at 1280x720,
   where 64px of padding minus a 46px overlap left 18px. */
.rates{padding:0 0 8px;--rates-overlap:14px}
.rates-card{position:relative;z-index:3;margin-top:calc(var(--rates-overlap) * -1);
  background:var(--card);border:1px solid var(--rule);
  border-top:3px solid var(--brand);border-radius:var(--radius);padding:16px 18px 14px;
  display:grid;gap:6px 28px}
.rates-intro h2{font-size:24px;margin:2px 0 6px}
.rates-note{margin:0;color:var(--slate);font-size:13px;line-height:1.55}
/* ⛔ 15px, NOT 16. The rate card was the largest body type on the page — larger than the prose it sits
   above — which is backwards for a reference table. 15 at desktop and 14.5 below 900, where the rows are
   tightest. Not 13: that is the context line's size and the label has to stay clearly ahead of it. */
.rate-table{width:100%;border-collapse:collapse;font-size:15px}
@media (max-width:899.98px){ .rate-table{font-size:14.5px} }
/* ⛔ THE COLUMNS HAD NO GAP AT ALL. padding:7px 0 on both cells means zero horizontal padding, so with
   border-collapse the label's right edge and the price's left edge were touching — measured at 0px on
   every row at 375px, which is why "New lock installation" read as one run-on string with its price.
   The price column is also shrink-to-fit now (width:1% plus nowrap), so the label gets every pixel that
   is left and stops wrapping to two lines for no reason. */
.rate-table th{text-align:left;font-weight:400;color:var(--ink);padding:7px 14px 7px 0;
  border-bottom:1px solid var(--rule-2)}
.rate-table th span{color:var(--slate)}
/* == THE PRICE IS INFORMATION, NOT A WARNING ============================================================
   X IT WAS 700 AGAINST A 400 LABEL, and weight reads as darkness: a column of near-black bold numbers
   shouts at somebody who is already worried about being overcharged. Wrong emotion on the one block whose
   job is to reassure. Same colour as its label now, one step heavier and no more — enough to find the
   figure when scanning a column, not enough to make it the loudest thing on the page.

   X AND IT WENT ONE STEP TOO FAR THE FIRST TIME. Matching the service name exactly — both --ink — was
   what was asked for and it was wrong: it made the figure and the label equally loud, so the eye had
   nothing to follow. The NAME is the dark one; the price is --slate, nearer the grey context beside it
   than the name above it. It is still findable at a glance because it is the only thing in the row set at
   500 against everything else's 400, and because it is right-aligned in its own column.

   /!\ WEIGHT CARRIES THE EMPHASIS NOW, NOT DARKNESS. --slate and .rate-covers are the same colour, so if
   the price ever needs to stand out more, move the weight, not the token — going darker walks straight
   back into the column of shouting near-black figures this replaced.

   /!\ tabular-nums STAYS. It is what keeps $48–$229 and $349–$1,119 aligned on the same digit columns;
   without it a proportional 1 makes every second row look indented. */
.rate-table td{text-align:right;padding:7px 0 7px 12px;width:1%;
  border-bottom:1px solid var(--rule-2);white-space:nowrap;
  font-family:'Geist',sans-serif;font-weight:500;color:var(--slate);font-variant-numeric:tabular-nums}
/* == THE MIDDLE COLUMN ==================================================================================
   /!\ THIS OVERRIDES .rate-table td RATHER THAN REPLACING IT, and the order matters for the publish
   window: CSS ships on a deploy and the markup on a publish, so for a few minutes the OLD two-cell rows
   meet this stylesheet. A single <td> in that markup is a price, and it must still render as one — which
   it does, because the price rule is the base and this is the exception.

   /!\ IT IS ALLOWED TO WRAP, which is why it has no width:1% and no nowrap. "push bars, high-security
   locks" on two quiet lines is fine; the same phrase forced onto one line is what would push the price
   column off the card at 375. */
.rate-table td.rate-covers{text-align:left;width:auto;white-space:normal;
  padding:7px 14px 7px 0;color:var(--slate);font-weight:400;font-size:13px;line-height:1.35}
.rate-table td.rate-covers:empty{padding-right:0}
/* == THREE COLUMNS DO NOT FIT ON A PHONE, SO THE ROW BECOMES TWO LINES ===================================
   X MEASURED, NOT ASSUMED: at 375 the card had about 311px for three columns, and once the price took its
   76 the service and its context were sharing 220. "New lock installation" and "door with no existing hole"
   both wrapped, labels ran to FIVE lines, and the card stood 1,389px tall — taller than the hero above it.
   At 320 it was eight lines and 1,890px.

   /!\ THE FIX IS THE ROW, NOT THE TYPE. Shrinking the text to fit would make the one block Advanced
   Verification requires to be legible the least legible thing on the page. Instead the row stops being
   three columns and becomes what it actually is on a phone: a service and its price on one line, the
   context underneath in grey. Same three fields, same order, no abbreviation.

   /!\ display:block ON THE TABLE PARTS IS DELIBERATE AND THE BORDER MOVES WITH IT. Once the cells are grid
   items the per-cell bottom border would draw twice per row and once under the context line, so the rule
   moves to the <tr> — which means the last-row exception has to move there too, or the card gets a stray
   line above its own edge. */
@media (max-width:559px){
  .rate-table,.rate-table tbody,.rate-table tr{display:block}
  /* /!\ 3px, NOT 8. Fourteen rows two lines deep, at 8px top and bottom each, is 224px of padding on
     its own and the card was most of a phone screen. Alon's value, measured on his own screen. */
  /* /!\ THE ROWS ARE DECLARED, NOT LEFT IMPLICIT, AND grid-row:1/-1 BELOW IS WHY. A negative grid line
     counts against the EXPLICIT grid only; with no grid-template-rows the explicit grid is one line pair,
     so -1 resolved to line 2 and "span both rows" silently meant "row 1" — the price stayed level with
     the service name and the fix measured as no fix at all. Two auto tracks make -1 mean what it says,
     and a row with no context line has nothing in track 2 (see .rate-covers:empty) so it stays 0 tall. */
  .rate-table tr{display:grid;grid-template-columns:1fr auto;grid-template-rows:auto auto;column-gap:14px;
    padding:3px 0;border-bottom:1px solid var(--rule-2)}
  /* == THE MISSING RULE BETWEEN THE TWO LOCK-CHANGE ROWS ==============================================
     X :last-of-type IS THE FIX AND ITS ABSENCE WAS THE BUG. There are TWO tables, not one — rateTable()
     splits fourteen rows at ceil(14/2), so row 7 ends the first table and row 8 opens the second. Side
     by side on desktop both correctly end without a rule. Stacked into one column they do not: the first
     table's last row now has a row directly beneath it and was still dropping its border, which put the
     page's one missing separator exactly between "Lock change | basic locks" and the row after it.
     Only the LAST table's last row sits against the card's own edge, so only it may lose the rule. */
  .rate-table:last-of-type tr:last-child{border-bottom:none}
  .rate-table th,.rate-table td{border-bottom:none;padding:0}
  .rate-table th{grid-column:1;grid-row:1}
  /* /! SPAN BOTH ROWS AND CENTRE. Pinned to grid-row 1 the price sat level with the service name and
     the context line hung below it unbalanced. 1/-1 is both rows whether or not the second one exists,
     so the single-line rows are unchanged. */
  .rate-table td{grid-column:2;grid-row:1/-1;align-self:center;width:auto}
  .rate-table td.rate-covers{grid-column:1;grid-row:2;padding-top:1px}
  /* The service fee has no context to give, and an empty grid item still reserves its row. */
  .rate-table td.rate-covers:empty{display:none}
}
.rate-table tr:last-child th,.rate-table tr:last-child td{border-bottom:none}
/* ⛔ EXCEPT THE FIRST TABLE WHEN THEY STACK. The rule above closes each COLUMN cleanly, which is right
   side by side — but below 900px the two tables sit on top of each other and the first one losing its
   last border means there is no line where one table ends and the next begins. "Lock change" and
   "High-security locks" read as one row with two prices. */
/* /! AND ONLY DOWN TO 560px. Below that the table parts become grid items and the row border moves
   to the <tr> — see the 559px block above, where the first table ALREADY keeps its last border. Adding
   it to th and td as well drew a second line under the label and a third beside the price. */
@media (min-width:560px) and (max-width:899.98px){
  .rate-table:nth-of-type(1) tr:last-child th,
  .rate-table:nth-of-type(1) tr:last-child td{border-bottom:1px solid var(--rule-2)}
}

/* == THE SERVICE GRID ===================================================================================
   Directly below the price table. Three columns on desktop, two on tablet, one on mobile.

   /!\ THE BREAKPOINTS ARE min-width AND THE BASE IS ONE COLUMN, so the narrowest case needs no query and
   cannot be missed. Two columns from 620 (each gets ~280px, which is where the longest item stops
   wrapping) and three from 940.

   /!\ THE SAME GOLD TICK AS THE HERO, at 16px rather than 20. It sits beside 15px text here and beside a
   17px benefit line there; matching the type rather than the other mark is what keeps both looking
   deliberate. The colours come from .hero-tick-mark, so the two cannot drift apart.

   X NO HOVER STATE. These are not links -- see src/render/services.ts for why they are not. A hover colour
   on something that cannot be clicked is a promise the element does not keep. */
/* == THE ROUTER ========================================================================================
   Five choices between the price table and the body copy. Not five buttons: five CELLS, each of which
   dials, laid out as a set the eye can take in rather than a list it has to read down.

   X THEY WERE FIVE COPIES OF THE HERO BUTTON AND THAT WAS THE FAULT. Each row printed the number in its
   own pill, so a phone screen carried six copies of one number — five here and the sticky bar — and the
   block read as a form with five identical submit buttons rather than as a choice between five things.
   The number is gone from these entirely; it is above them, below them, and in the middle of the page.

   X AND A FULL-WIDTH ROW MADE THE EYE TRAVEL 1,100px FIVE TIMES, heading at one end and target at the
   other. Three across at ~350px is one glance per cell.

   /! 1060px IS .service-grid's OWN CAP, reused rather than invented. The two blocks sit six hundred
   pixels apart on the same page and a different measurement in each would read as a mistake.

   /! SIX TRACKS, NOT THREE, IS WHAT MAKES FIVE CELLS FILL TWO ROWS. Five items in three columns leave
   a hole; on a six-track grid the first three span 2 each and the last two span 3 each, so both rows run
   the full width and the set looks deliberate. Same trick as .service-col:nth-child(3). */
.router{padding:26px 0 0}
.router-grid{display:grid;gap:10px;grid-template-columns:1fr;max-width:1060px;margin-inline:auto}

/* /! THE ICON LEADS AND IT IS BIGGER THAN THE TYPE, ON PURPOSE. It is the thing being scanned and the
   heading confirms what the picture already said. Sized like a text glyph it reads as decoration and
   gets skipped, which is exactly what the phone glyph on the right was doing. */
.router-cell{display:flex;align-items:center;gap:14px;
  min-height:44px;padding:13px 15px;text-decoration:none;color:var(--ink);
  background:var(--card);border:1px solid var(--rule);border-radius:var(--radius)}
/* /!\ THE ICON TAKES THE HEADING'S COLOUR, NOT THE LINK BLUE. At --brand-deep it read as a separate
   thing sitting next to a label — an icon and a caption, two objects. In the same near-black it is part
   of the same object, which is what makes the cell scannable as one unit. */
.router-icon{flex:0 0 auto;display:flex;align-items:center;justify-content:center;
  width:34px;height:34px;color:var(--ink)}
.router-icon .svc-icon{width:100%;height:100%;display:block}
.router-text{min-width:0;display:flex;flex-direction:column;gap:1px}
.router-head{font-family:'Geist',sans-serif;font-weight:700;font-size:16px;line-height:1.25;
  letter-spacing:-.012em;text-wrap:balance}
.router-line{color:var(--slate);font-size:13.5px;line-height:1.35;text-wrap:pretty}

/* /! THE PRESSED AND HOVER STATES ARE BOTH INSIDE THE POINTER GATE, like every other one in this file.
   A cell this large is exactly what a scrolling thumb passes over, and iOS fires :active on touch-down
   before it knows a tap from a scroll. On touch the dialler opening is the feedback. */
/* /! THE HOVER IS --action ON ALL THREE PARTS AT ONCE. The border greyed and the heading went
   --brand-deep, which is the link colour and reads as navy — two different blues on one cell for one
   gesture. #71D2F5 measures 1.67:1 on --paper, under the 3:1 WCAG 1.4.11 asks of a UI boundary; it is a
   hover accent on a control that is fully legible at rest, and the figure is known. */
/* /!\ THE BORDER AND THE ICON MOVE, THE TEXT DOES NOT. The heading was going to the accent too, which
   put #71D2F5 — 1.67:1 on --paper — under the one line in the cell somebody actually has to read. The
   icon is a shape and the border is a boundary; neither carries meaning that a low ratio destroys, and
   between them they are enough to say the cell is live. */
@media (hover: hover) and (pointer: fine){
  /* /!\ color:var(--ink) IS NOT REDUNDANT, IT IS UNDOING a:hover. The cell is an <a>, and the global
     a:hover{color:var(--brand-darker)} is (0,1,1) against .router-cell's (0,1,0) — so the heading, which
     has no colour of its own and inherits from the anchor, went navy on hover. .router-line was never
     affected because it sets --slate explicitly, which is why only one of the two lines moved. */
  .router-cell:hover{background:var(--paper);border-color:var(--action);color:var(--ink)}
  .router-cell:hover .router-icon{color:var(--action)}
  .router-cell:active{background:var(--rule-2)}
}
.router-cell{-webkit-tap-highlight-color:transparent}

/* /! 620, NOT 560, AND THE LONGEST HEADING IS WHY. "Locked out of your house" wraps to two lines in a
   two-across cell at about 600. The cell grows, and because grid rows are uniform its NEIGHBOUR grows
   with it — one row visibly taller than the other with white space in the short cell. 560 resolved that
   forty pixels after it started looking wrong; this goes to one column BEFORE the wrap rather than
   after it. */
/* == THE ODD CELL IN THE LAST ROW IS CENTRED, NEVER STRETCHED ==========================================
   Five cells never divide evenly into two or three columns, so one row is always short. The short row
   sits CENTRED under the row above it, at the same cell width.

   X grid-column:1/-1 WAS THE WRONG ANSWER AND IT IS WHAT "Business locks" WAS DOING. Spanning the full
   row makes the odd cell wider than the four above it — a cell that is bigger because it is last, which
   reads as emphasis nobody chose.

   /! THE TRICK IS TWICE AS MANY TRACKS AS COLUMNS, EVERY CELL SPANNING TWO, AND THE LAST ROW STARTING
   ONE TRACK LATE. Two columns is four tracks; three columns is six. A cell that spans two tracks is
   exactly one column wide, and starting the short row at track 2 offsets it by half a column — which
   lands it under the gap between the cells above. The centring falls out of the grid rather than needing
   a rule, a margin or a breakpoint of its own, and it is the same three lines at both widths.

   /! ONLY THE FIRST CELL OF THE SHORT ROW IS PLACED. The one after it auto-flows into the next free
   pair of tracks, which is already the right place; naming both would be two numbers that have to agree.

   ⛔ THE span 2 RULES LIVE INSIDE THESE QUERIES, NOT ON .router-cell. Below 620 the grid is a single
   track, and a cell spanning two would generate an implicit second column and break the one-column
   state — the layout that is correct on every phone. */
@media (min-width:620px){
  .router-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
  .router-cell{grid-column:span 2}
  /* two columns: the fifth is alone on its row */
  .router-cell:nth-child(5){grid-column:2 / span 2}
}
@media (min-width:900px){
  .router-grid{grid-template-columns:repeat(6,minmax(0,1fr))}
  .router-cell{grid-column:span 2}
  /* three columns: the fourth opens the short row and the fifth follows it */
  .router-cell:nth-child(4){grid-column:2 / span 2}
  .router-cell:nth-child(5){grid-column:span 2}
}

.services{padding:22px 0 0}
/* /!\ TWO COLUMNS DOWN TO 420, NOT 620. At 526 this collapsed to one column and the block became a very
   long scroll for no reason — the columns still had room. A service name is two or three words, so a
   ~180px column carries one comfortably; 420 is where the longest of them ("House and apartment lockouts",
   "Access control and buzzers") stops fitting on two lines beside its tick.

   /!\ THE THIRD CATEGORY SPANS THE PAIR WHENEVER THERE ARE TWO, which is what makes the two-column state
   look deliberate rather than like a three-column grid that ran out of room. Left to auto-placement it
   would sit in column one with a hole beside it.

   /!\ THE GRID IS CENTRED AS A BLOCK AND THE LINES INSIDE IT ARE NOT. justify-items:start keeps every
   service name hard against its own tick; centring the individual lines would leave the ticks in a ragged
   column, which is the thing that made the hero benefit list zigzag. */
/* == THE ONE-COLUMN GRID IS CENTRED AS A BLOCK, NOT AS LINES ==========================================
   X grid-template-columns:1fr WAS THE BUG AND justify-content COULD NOT SAVE IT. A 1fr column consumes
   the whole track, so there is no free space left for justify-content to centre — the grid was already
   centred, it was simply as wide as the page. Below 420, where the page is one centred column of text,
   that left the only left-aligned block on the screen hugging the gutter.

   /!\ auto SIZES THE COLUMN TO ITS WIDEST ITEM, which is what gives justify-content something to
   distribute. .service-col drops to width:auto for the same reason: at 100% it would stretch back to
   fill the track this change just stopped creating.

   ⛔ THE LINES INSIDE ARE NOT CENTRED, AND justify-items:start IS WHAT GUARANTEES IT. Every tick stays
   on one vertical line and every service name stays hard against its own tick; the AUTOMOTIVE /
   RESIDENTIAL / COMMERCIAL headings and their underline rules run to the column's width, not the page's.
   text-align:center would centre each line inside the column and leave the ticks in a ragged fan — the
   exact fault this is fixing, moved inside the block.

   /!\ SAME CONSTRUCTION AS .hero-ticks, WHICH ALREADY DOES THIS: grid-template-columns:auto with
   justify-content:center and justify-items:start. One pattern, two blocks. */
.service-grid{display:grid;gap:20px 30px;grid-template-columns:auto;
  justify-items:start;justify-content:center;max-width:1060px;margin-inline:auto}
.service-col{width:auto}
@media (min-width:420px){
  /* Two real columns from here up, so the tracks share the width and each column fills its own. */
  .service-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
  .service-col{width:100%}
  .service-col:nth-child(3){grid-column:1/-1}
}
@media (min-width:940px){
  .service-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
  .service-col:nth-child(3){grid-column:auto}
}
.service-col h3{font-family:'Geist',sans-serif;font-size:12px;font-weight:700;letter-spacing:.13em;
  text-transform:uppercase;color:var(--slate);margin:0 0 10px;padding-bottom:8px;
  border-bottom:1px solid var(--rule-2)}
.service-col ul{list-style:none;margin:0;padding:0;display:grid;gap:7px}
.service-col li{display:flex;align-items:flex-start;gap:9px;font-size:15px;line-height:1.4;
  color:var(--body)}
.service-col .hero-tick-mark{width:16px;height:16px;margin-top:.15em}

/* ── BODY COPY ──────────────────────────────────────────────────────────────────────────────────────────*/
/* == THE PROSE COLUMN ==================================================================================
   X THE PAGE LOOKED LEFT-HUGGING AND .wrap WAS NOT THE FAULT. Measured at 1905: every .wrap sat at 363
   left and 363 right — perfectly centred. What was left-hugging was the CONTENT inside it. The wrap is
   1180 wide because the rate card needs two columns, but a paragraph capped at 68ch is 676px, so each one
   left 484px of dead space on its right, the list left 439 and the body image 400. Headings ran the full
   1140. Full-width heading, half-width paragraph, repeated down the page, reads as a column shoved left.

   /!\ FIXED AT THE CONTAINER, NOT PER SECTION. The measure is right — 68ch is readable and 1140 is not —
   so the COLUMN moves to the measure instead of the text being stretched to the column. Heading,
   paragraph, list and image now share both edges, and the column is centred on the page.

   /!\ .rates KEEPS THE WIDE WRAP. Its card is a two-column table and genuinely wants 1180. This is scoped
   to .prose so widening one cannot silently narrow the other. */
/* == EVERY UNDERLINE UNDER TEXT IS currentColor. THIS IS A RULE, NOT FIVE FIXES. ========================
   X FIVE PLACES HAD AN UNDERLINE IN A DIFFERENT COLOUR FROM THE WORDS ABOVE IT, and hover made each one
   worse rather than better: .numintext drew --brand under --brand-deep text, so a phone number in body
   copy showed two blues at rest and a THIRD on hover, when the text darkened and the rule underneath it
   did not. The sibling links, the footer's number and the hero price link all had the same shape.

   /!\ currentColor IS NOT A TIDIER LITERAL, IT IS THE THING THAT CANNOT DRIFT. A token repeats a value
   and has to be repeated again in every state -- default, hover, focus, visited. currentColor IS the
   text colour, so a state that changes the text has already changed the rule under it, and a state
   nobody thought to write cannot be wrong.

   /!\ IT APPLIES TO UNDERLINES, NOT TO EVERY BORDER. A table row separator, the header strip's bottom
   edge and the nav panel's dividers are structure between elements, not a line under words: they stay
   --rule and --rule-2. The test is whether the line belongs to the text directly above it.

   /!\ THE WEIGHT STAYS WHERE IT WAS. These went from a faint --rule to a full-strength underline in the
   text's own colour, which is heavier at rest by design -- 1px, and 2px only on .numintext, which is a
   19px phone number. Do not buy the old lightness back with an alpha; that is a fourth colour again. */
.prose .wrap{max-width:min(860px, var(--wrap))}
.prose p,.prose ul,.prose ol{max-width:none}
.prose{padding:26px 0 0}
.prose h2{font-size:clamp(22px,6vw,27px);margin-bottom:8px}
.prose h3{font-size:19px;margin:22px 0 6px}
/* ⚠️ pretty, NOT balance, ON RUNNING TEXT. balance evens the lines of a SHORT block and browsers cap it
   at a handful of lines, so on a paragraph it does nothing at best. pretty is the one that works here: it
   costs a little more layout time and its job is the last line — no single word left stranded, which is
   the thing that actually looks careless in a column of body copy. Headings take balance; see .prose h2.
   ⚠️ NEITHER MOVES ANY BOX. Both change where words break inside a block whose height is already set by
   the number of lines, so there is nothing here to shift. */
.prose p,.prose li,.rates-note,.foot-disclaimer,.hero-city,.zip-sub{text-wrap:pretty}
.prose h2,.prose h3,.hero-head,.reviews h2,.service-col h3,.rates-intro h2{text-wrap:balance}
.prose p{margin:0 0 14px;max-width:68ch}
.sub-h{color:var(--slate);font-size:15px;margin-top:-2px}
/* == THE CALL BUTTON INSIDE THE BODY ==================================================================
   ⛔ ON DESKTOP THE STICKY BAR IS display:none, so once the hero scrolled away a 4,469px page had no
   call button on it at all — the whole body, the part that does the selling, with nothing to press. Three
   of them now: closing the opening block, and above the house and commercial sections.

   /!\ CENTRED AS A BLOCK, WHICH IS WHY IT IS A FLEX WRAPPER AND NOT text-align. .call-btn is itself a
   flex column; text-align does not centre a block-level flex item, and margin-inline:auto on the button
   would need it to have a width to be auto-centred against. */
/* a column now, because the button carries a label beneath it like the header's does */
.body-call{display:flex;flex-direction:column;align-items:center;margin:20px 0 24px}
.body-call .call-note{color:var(--slate);text-align:center;margin-top:9px}

/* == THE METRO SENTENCE MOVED BELOW THE OPENING PARAGRAPH AND GREW ====================================
   X IT USED TO SIT ABOVE THE PAGE'S OWN OPENING HEADING, small and grey. Moved here and set at heading
   size it closes the opening block instead of introducing it — and the alternative, leaving it on top at
   this size, would have put a display line and a button in front of the H1. */
/* The line under a body h2. Quieter than the paragraph it precedes, so the heading still leads. */
/* ⚠️ IT IS AN h3 NOW AND MUST NOT LOOK LIKE ONE. The element changed so a crawler reads the line as
   the section's subject rather than as prose; the design did not change with it, so the heading
   defaults — bold, larger, its own margins — are undone here on purpose. */
.body-sub{margin:-2px 0 14px;max-width:68ch;font-size:17px;line-height:1.5;color:var(--ink-3);
  font-weight:400;letter-spacing:0}
@media (max-width:759.98px){ .body-sub{font-size:16px} }
/* ⚠️ IT WAS SET AT H2 SIZE AND READ AS ONE. clamp(22px,6vw,27px) at weight 700 is the heading scale, so
   a one-line lead-in to the call button competed with every real heading on the page. Alon: "its the same
   size as the h2's... its huge". It is a lead-in, so it is now a shade above body copy and no more. */
.area-metro{margin:20px 0 2px;text-align:center;font-family:'Geist',sans-serif;font-weight:600;
  font-size:clamp(16px,3.6vw,18.5px);line-height:1.35;letter-spacing:-.006em;text-wrap:balance;color:var(--ink)}

.prose picture{display:block;margin:14px 0 18px}
/* /!\ object-position READS --focus, AND WITHOUT IT THESE CROP TO THE CENTRE. Every body photograph is a
   2.36 panorama in a 3:2 frame, so a third of the width is discarded — and in all five the subject stands
   in the right third, exactly as in the hero. A centre crop removes the person from the picture and
   leaves an empty road. The percentages are measured; see BODY_FOCUS in src/render/area-body.ts.
   50% is the fallback, so a picture with no measured focus behaves as it always did. */
/* ⚠️ 16px, ONE STEP ABOVE THE BUTTONS RATHER THAN DOUBLE THEM. --radius is 10px and everything
   interactive uses it; at 10 the photographs read as square-ish and at 20 they read as a different
   design language from the button sitting above them. 16 is visibly rounded and still a relative of the
   10. One number to change if it wants to be 20. */
.prose picture img{width:100%;height:auto;border-radius:16px;aspect-ratio:3/2;
  object-fit:cover;object-position:var(--focus,50%) center}

/* ⛔ THE SAME RULE FOR EVERY OTHER PHOTOGRAPH ON THE SITE. picture() now stamps --focus on every
   <picture> it renders — sibling body shots, generic page bodies, the lot — and without this they
   went on cropping to the centre because only .prose pictures were being told to read it. */
.prose picture > img,.faq picture > img,section picture > img{
  object-fit:cover;object-position:var(--focus,50%) center}
.tick{list-style:none;margin:0 0 16px;padding:0;display:flex;flex-direction:column;gap:9px;max-width:68ch}
.tick li{position:relative;padding-left:26px;line-height:1.55}
/* /!\ 15x8, NOT 9x9, AND THE BOX IS WHAT WAS WRONG. Two borders on a SQUARE rotated -45° give a
   chevron with two equal arms — a corner, not a checkmark — and the arm that should be the long stroke
   ran past the box and was clipped by it. A checkmark is one short arm and one long one, which is a
   15x8 box: the border-left becomes the short upstroke and the border-bottom the long one, and both
   fit inside their own element instead of being cut off at its edge.
   /!\ top IS 0.35em BECAUSE THE BOX GOT SHORTER. .62em centred a 9px square against a 1.55 line; an
   8px-tall mark hung low at the same offset. Alon checked .25 and .35 side by side and .35 is the one
   that sits on the text baseline rather than slightly above it. The value is in em against the line
   box, so it holds at every type size these lists are used at. */
.tick li::before{content:"";position:absolute;left:4px;top:.35em;width:15px;height:8px;
  border-left:2px solid var(--brand);border-bottom:2px solid var(--brand);transform:rotate(-45deg)}
.call-line{font-size:16px}
.numintext{font-family:'Geist',sans-serif;font-size:19px;white-space:nowrap;
  font-variant-numeric:tabular-nums;text-decoration:none;border-bottom:2px solid currentColor}
/* == A REFERENCE DOCUMENT IS SET SMALLER THAN A LANDING PAGE =========================================
   ⛔ THESE THREE ARE READ WHEN SOMEBODY IS LOOKING SOMETHING UP, not scanned by a visitor deciding
   whether to call. At the site's 16px/1.6 they had the type of a sales page, which makes a terms
   document look longer than it is and reads as though every clause is being pressed on the reader.

   Two steps down — 16 to 14.5 — with the leading tightened from 1.6 to 1.55 and the headings brought
   down with the body so the hierarchy is unchanged. The one thing NOT reduced is the h1: a document
   still needs a title that says which document it is.

   ⚠️ 14.5px IS NOT SMALL PRINT. It is the size a contract or a policy is normally set at, and it is
   comfortably above the 12px the footer's own fine print uses. Legibility is what makes a legal document
   defensible; the point here is proportion, not shrinking it out of the way. */
.legal{font-size:14.5px;line-height:1.55}
.legal h1{font-size:clamp(26px,7vw,34px);margin-bottom:6px}
.legal h2{font-size:17px;margin-top:24px}
.legal h3{font-size:15.5px;margin:18px 0 5px}
.legal p{margin:0 0 12px}
.legal-date{color:var(--slate);font-size:12.5px;margin-top:0}
/* The address at the foot of each document: present, findable, and not competing with the clauses. */
.legal-contact{font-size:13px;color:var(--slate)}
/* == NO CHECKMARKS IN A REFERENCE DOCUMENT ============================================================
   ⛔ A CHECKMARK IS A CLAIM. On nearly every line of a privacy policy it turns a list of what happens to
   somebody's data into a list of features — "we share it with Google ✓" reads as a benefit. The same
   mark is right on the hero benefit lines and the service grid, which ARE claims; it is wrong here.

   ⛔ AND IT IS DONE IN CSS BECAUSE THE MARKUP MAY NOT MOVE. One of these lists is the A2P 10DLC
   text-messaging block, which is byte-identical in the terms and the privacy policy on purpose and is a
   carrier compliance requirement. Editing class="tick" out of it would break that; a rule here changes
   how it draws and not one character of what it says.

   /!\ display:block IS LOAD-BEARING. .tick is a flex column, and a list marker does not render on a
   flex item at all — list-style:disc alone would have removed the checkmark and drawn nothing in its
   place. position:static undoes the anchor the ::before was absolutely positioned against. */
.legal .tick{display:block;list-style:disc;padding-left:20px;margin:0 0 14px}
.legal .tick li{position:static;padding-left:0;margin:0 0 6px}
.legal .tick li::before{content:none}
address.plain,.legal address{font-style:normal}

/* ── MID-PAGE CALL BAND ─────────────────────────────────────────────────────────────────────────────────*/
/* == THE MID-PAGE CALL BAND ============================================================================
   X IT WAS --ink WITH A PHOTOGRAPH AND A SCRIM, AND ALL THREE ARE GONE. The band was built against the
   dark hero; on a light page it read as a black strip dropped into the middle of the document. The
   photograph sat at 30% opacity beneath a .55-to-.82 wash, which could not have been visible at any point
   — and on the home page [band_picture] rendered nothing, so the wash was covering bare colour.

   X DO NOT REINTRODUCE AN IMAGE OR A SCRIM. A scrim exists to protect text from a photograph. With no
   photograph there is nothing to protect against, and the two rules only ever made sense as a pair.

   The band is now the page's own surface with a rule top and bottom to separate it, and the call button
   inside it is the hero's button unchanged — same fill, same white number, same dark label. */
.cta-band{position:relative;background:var(--card);margin-top:26px;padding:30px 0;
  border-top:1px solid var(--rule-2);border-bottom:1px solid var(--rule-2)}
.cta-band .wrap{position:relative;z-index:2;display:flex;flex-direction:column;align-items:center;gap:12px}
.cta-lede{margin:0;font-family:'Geist',sans-serif;font-weight:700;font-size:13px;letter-spacing:.16em;
  text-transform:uppercase;color:var(--slate)}

/* ── SAME-AREA LINKS ────────────────────────────────────────────────────────────────────────────────────
   Never another city, never a generic page, never a menu. */
/* The accent tint, borrowed from how the CRM lights an active row: the band reads as LIT rather than
   filled, which is what a "more in this area" block wants — present, not competing with the call button.
   ⚠️ On a light ground the label takes --acc-text-light. The near-white --acc-text is the DARK-theme
   value and would be invisible here; that exact swap is documented in the CRM's own stylesheet. */
/* ⛔ A SMALL GROUP OF INLINE LINKS, NOT A NAVIGATION STRIP. This was a tinted panel of bordered pill
   buttons sitting above the town list, which is navigation with different styling — the exact thing an
   area page was decided not to have, arrived at by way of decoration. A landing page's only action is to
   call; these exist so somebody already reading can reach the sibling page, and they should look like
   what they are: a line of links at the bottom.
   The tint, the borders, the boxes and the 44px targets are all gone. Inline text does not need a tap
   target, because it is not a button. */
.samearea{max-width:var(--wrap);margin:22px auto 0;padding:14px 20px 0;border-top:1px solid var(--rule-2);
  display:block;font-size:14px;line-height:1.7;color:var(--slate)}
.samearea-label{font-family:'Geist',sans-serif;font-weight:700;font-size:12px;letter-spacing:.14em;
  text-transform:uppercase;color:var(--faint);margin-right:8px}
.samearea-links{display:inline}
.samearea-links i{font-style:normal;margin:0 .12em;color:var(--rule)}
.samearea-links a{color:var(--brand-deep);text-decoration:none;border-bottom:1px solid currentColor}

/* == THE SUGGESTION PANEL PAINTS ABOVE THE FOOTER BECAUSE NOTHING TRAPS IT ANY MORE ==================
   X .zip-results ALREADY CARRIED z-index:5 AND IT COULD NOT WORK. .zipsearch was in the scroll-reveal
   list, lc-reveal animated transform and opacity, and a running animation on either CREATES A STACKING
   CONTEXT - so the panel 5 was scoped inside a section whose own effective level is 0, and the footer,
   later in the DOM at the same level, painted straight through it. The wordmark showing through the
   panel was the footer own transformed <b> doing exactly what the spec says.

   /! NO z-index WAS ADDED HERE TO COMPENSATE, AND ONE BRIEFLY WAS. Taking .zipsearch out of the reveal
   removed the stacking context, so the panel own z-index reaches the root and works. Neither .site-foot
   nor the wordmark carries a z-index at all - checked - so there was never anything to out-rank. A number
   added on this side would have been a second wrong thing cancelling the first.

   X .zipsearch IS OUT OF THE REVEAL PERMANENTLY, AND THIS IS THE REASON. The reveal has since come back
   in a form that cannot hide anything, but it still animates transform and a transform animation still
   makes a stacking context while it runs. Putting this section back in the list would re-create the bug
   for the 420ms the animation lasts. The exclusion lives in the script in src/render/page.ts. */
.zipsearch{padding:30px 0 4px}
/* == THE ZIP SEARCH IS CENTRED, BLOCK AND CONTENTS ====================================================
   It is the only interactive thing on the home page and it sat left-aligned inside left-aligned prose,
   which made it read as a paragraph that happened to contain a box. Centring the eyebrow, the heading,
   the subtitle and the input is what makes it read as a control.

   /!\ THE RESULTS LIST IS NOT CENTRED. It is a listbox of zip codes and place names; centred rows would
   put the numbers in a ragged column and cost the scan they exist for. The BOX is centred; what drops
   out of it is left-aligned, like every other menu on the page.

   /!\ margin-inline:auto ON .zip-box, NOT text-align ALONE. The box is max-width:340px, so text-align
   centres the text inside it and leaves the box itself against the left edge. */
.zipsearch .wrap{text-align:center}
.zip-sub{margin:4px auto 14px;color:var(--slate);font-size:15px;max-width:56ch}
.zipsearch .zip-box{margin-inline:auto}
.zip-results{text-align:left}
.zip-none{margin-inline:auto}
/* position:relative so the results hang off the input rather than pushing the page around as you type. */
.zip-box{position:relative;max-width:340px}
.zip-box input{width:100%;font-family:'Geist',sans-serif;font-size:19px;font-weight:600;
  font-variant-numeric:tabular-nums;letter-spacing:.06em;padding:13px 15px;min-height:52px;
  color:var(--ink);background:var(--card);border:1px solid var(--rule);border-radius:var(--radius)}
/* /!\ THE PLACEHOLDER WAS WEARING THE INPUT'S OWN TYPE TREATMENT. It inherited 600 weight, .06em
   tracking and --ink at about 18:1, so "e.g. 60601" looked exactly like a zip somebody had already
   typed -- the one thing a placeholder must never look like. Regular weight, normal tracking and --faint
   at 5.34:1 on --card: quieter than the value it stands in for, and still well clear of 4.5:1.
   /!\ opacity:1 IS LOAD-BEARING. Firefox applies its own opacity to ::placeholder on top of whatever
   colour is set, which would have taken this below AA on one browser only.
   X NOT LIGHTER THAN --faint. A hint nobody can read is not a hint. */
.zip-box input::placeholder{color:var(--faint);font-weight:400;letter-spacing:normal;opacity:1}
/* == ONE RING, AND THE BORDER DOES NOT MOVE ===========================================================
   X THE border-color CHANGE IS GONE. This rule drew an outline AND recoloured the 1px border, so a
   focused input showed two concentric strokes 1px apart. The border is part of the control at rest and
   stays exactly what it is at rest; the outline is the only thing focus adds.
   /!\ IT IS THE ONLY PLACE IN THE SHEET THAT DID THIS. Every other focus rule -- :focus-visible on
   line 237 and .sticky:focus-visible -- sets outline alone, and they were checked rather than assumed.

   ⚠️ #71D2F5 MEASURES 1.67:1 ON THE INPUT'S --card GROUND, BELOW THE 3:1 IN WCAG 1.4.11. Alon has the
   figure and has chosen the colour. It is recorded here so nobody re-derives it, and it is not an
   oversight to be corrected on sight. */
.zip-box input:focus{outline:2px solid #71d2f5;outline-offset:-1px}
.zip-results{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:5;margin:0;padding:4px;
  list-style:none;background:var(--card);border:1px solid var(--rule);border-radius:var(--radius);
  box-shadow:0 10px 30px rgba(var(--ink-rgb),.16)}
.zip-results li{display:flex;align-items:baseline;gap:10px;padding:11px 12px;min-height:44px;
  border-radius:calc(var(--radius) - 4px);cursor:pointer;font-size:15px}
.zip-results li b{font-variant-numeric:tabular-nums;color:var(--ink)}
.zip-results li span{color:var(--slate);font-size:14px}
.zip-results li.on{background:var(--paper)}
@media (hover: hover) and (pointer: fine){.zip-results li:hover{background:var(--paper)}}
.zip-none{margin:12px 0 0;font-size:14px;color:var(--slate);max-width:46ch}
.zip-none b{color:var(--ink)}
.zip-tel{color:var(--brand-deep);font-variant-numeric:tabular-nums;white-space:nowrap}

/* ── REVIEWS ────────────────────────────────────────────────────────────────────────────────────────────
   ⛔ The FTC disclaimer stays on the same page as the testimonials. */
.reviews{padding:28px 0 0}
.reviews-since{margin:2px 0 0;font-family:'Geist',sans-serif;font-weight:600;font-size:12px;
  letter-spacing:.1em;text-transform:uppercase;color:var(--brand-deep)}
.reviews h2{font-size:clamp(22px,6vw,27px);margin-bottom:14px}
/* == THE REVIEW CAROUSEL ===============================================================================
   /!\ A SCROLLER, NOT A TRANSFORM SLIDER. The track is an ordinary overflow-x element with scroll-snap and
   the controls call scrollTo. Nothing is transformed and no JavaScript is needed to lay it out — with the
   script blocked it is still a horizontally scrollable list of reviews, which is a working component
   rather than a broken one, and touch swipe is the browser's own rather than a reimplementation of it.

   X THE SCROLLBAR IS WHAT MADE IT LOOK OLD, AND IT IS GONE. A grey system bar under a row of cards dates a
   component more than anything else on it. Both properties are needed: scrollbar-width for Firefox and
   modern Chrome, ::-webkit-scrollbar for Safari and older WebKit. Hiding the BAR does not disable the
   SCROLLING — swipe, trackpad and shift-wheel all still work.

   /!\ THE CARD WIDTH IS SOLVED, NOT MINIMUM-ED. grid-auto-columns was minmax(300px, 1fr), which fits
   however many 300px columns happen to go in and leaves the last one cut in half at the edge — four and a
   bit cards at 1280. calc((100% - N*gap) / N) makes exactly N whole cards fill the viewport at every
   width, so there is never a sliver of a fifth quote suggesting the component is broken. */
.review-carousel{position:relative;--per:1;--gap:12px}
@media (min-width:620px){.review-carousel{--per:2}}
@media (min-width:1000px){.review-carousel{--per:3}}
.review-track{display:grid;grid-auto-flow:column;
  grid-auto-columns:calc((100% - (var(--per) - 1) * var(--gap)) / var(--per));
  /* /!\ mandatory, AND THE 2px BUG WAS NOT SNAP. An arrow press appeared to move the track 2px instead of
     312, and this line was briefly changed to proximity to "fix" it. It was never snap: the check ran in a
     browser pane whose requestAnimationFrame does not fire, so behavior:'smooth' could not animate at all
     — the same 2px came back with snapping switched off entirely, and a document-level smooth scroll moved
     zero. Verified with behavior:'auto', one press moves exactly one card plus one gap. */
  gap:var(--gap);overflow-x:auto;scroll-snap-type:x mandatory;
  padding:2px;margin:0 -2px;
  scrollbar-width:none;-ms-overflow-style:none}
.review-track::-webkit-scrollbar{display:none;width:0;height:0}
.review-track > *{scroll-snap-align:start}

/* == THE ARROWS ========================================================================================
   X NOT PARKED IN A ROW UNDERNEATH. Two squares below the component is a control that reads as furniture;
   overlaid at the edges of the card row, circular and quiet, they read as part of the row they move.

   /!\ VISIBLE ON HOVER, AND ALWAYS VISIBLE ON FOCUS. opacity:0 alone would make them unreachable for a
   keyboard, which is why :focus-visible brings them back independently of the hover state. On a touch
   screen there is no hover at all and no arrow ever appears — that is correct, because swiping is what a
   thumb does here, and the dots below still give both position and a tap target.

   /!\ THEY DO NOT SIT ON THE CARDS. translate pulls them half outside the track on desktop where the wrap
   has room, and tucks them just inside it below 760 where it does not. */
.review-arrow{position:absolute;top:calc(50% - 22px);z-index:3;
  width:44px;height:44px;display:flex;align-items:center;justify-content:center;
  /* X NO BORDER HERE EITHER, for the same reason as the hamburger. The circle is still a circle: the
     card ground and the shadow under it are what separate the arrow from the review behind it, and a
     hairline on top of both was a third statement of the same thing. */
  background:var(--card);color:var(--ink);border:0;border-radius:50%;
  box-shadow:0 2px 10px rgba(var(--ink-rgb),.14);
  font-size:24px;line-height:1;padding:0 0 3px;cursor:pointer;
  /* opacity is the hover REVEAL of the arrow itself and has to stay; it moves to the shared .14s so it
     does not fade in at one speed while its own background eases at another. */
  opacity:0;transition:opacity var(--hover),background-color var(--hover),color var(--hover),
             box-shadow var(--hover)}
.review-arrow[data-dir="-1"]{left:0;transform:translateX(-46%)}
.review-arrow[data-dir="1"]{right:0;transform:translateX(46%)}
@media (max-width:759.98px){
  .review-arrow[data-dir="-1"]{transform:translateX(-4px)}
  .review-arrow[data-dir="1"]{transform:translateX(4px)}
}
.review-arrow:focus-visible{opacity:1}
@media (hover: hover) and (pointer: fine){.review-carousel:hover .review-arrow{opacity:1}}
@media (hover: hover) and (pointer: fine){.review-arrow:hover{background:var(--paper)}}
/* A disabled arrow stays hidden rather than showing a dead control at the end of the row. */
.review-arrow[disabled]{opacity:0 !important;pointer-events:none}

/* == THE DOTS ==========================================================================================
   /!\ POSITION, AND ALSO A CONTROL. They are real buttons, so a thumb has something to aim at on a touch
   screen where the arrows never appear. 22px of tap target around an 8px dot, bought with padding rather
   than by drawing a bigger circle. */
.review-dots{display:flex;justify-content:center;gap:2px;margin-top:12px}
.review-dot{width:22px;height:22px;padding:7px;border:0;background:none;cursor:pointer;
  display:flex;align-items:center;justify-content:center}
/* /!\ transform IS GONE FROM THIS LIST AND THE DOT NOW SNAPS TO scale(1.25). That is the rule against
   transitioning transform applied literally. Worth knowing this one is not a hover state - the scale
   marks the current card and changes on its own every 6 seconds - so if the snap reads badly,
   ", transform var(--hover)" restores it and breaks no rule about hover. */
.review-dot::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--rule);
  transition:background-color var(--hover)}
.review-dot[aria-current="true"]::before{background:var(--brand-deep);transform:scale(1.25)}
@media (hover: hover) and (pointer: fine){.review-dot:hover::before{background:var(--brand-deep)}}
.review-dot[hidden]{display:none}

.review-grid{display:grid;gap:12px}
.review{margin:0;background:var(--card);border:1px solid var(--rule);padding:14px 16px;
  display:flex;flex-direction:column;gap:8px}
.review blockquote{margin:0;font-size:15px;line-height:1.55;color:var(--body)}
/* /!\ GOLD, THE SAME GOLD AS THE HERO STARS. They were the accent, which made the one element on the
   page that appears twice appear in two colours. Stars are the site's only decorative glyph and gold is
   what it uses for them; --gold is 1.83:1 and unusable as text, which is exactly why the accessible name
   on each card carries the rating in words instead of relying on the glyphs. */
.review .stars{color:var(--gold);font-size:13px;letter-spacing:.18em}
.review figcaption{display:flex;flex-direction:column;font-size:13px;color:var(--slate)}
.review figcaption b{font-family:'Geist',sans-serif;font-weight:700;font-size:15px;color:var(--ink)}
.ftc{color:var(--slate);font-size:12px;line-height:1.5;margin:14px 0 0;max-width:80ch}

/* ── AREAS SERVED ───────────────────────────────────────────────────────────────────────────────────────
   Real suburb names, from the old national menu. Self-maintaining, true, and uncopyable. */
.areas{padding:24px 0 0}
.areas .wrap{border-top:1px solid var(--rule);padding-top:16px;display:flex;flex-direction:column;gap:8px}
.area-list{margin:0;color:var(--slate);font-size:13px;line-height:1.9}
.area-list i{font-style:normal;margin:0 .12em;color:var(--rule)}

/* ── FOOTER ─────────────────────────────────────────────────────────────────────────────────────────────
   ⛔ The business address is on every page, and the dispatch disclaimer is linked from every footer.
   ⛔ Privacy Policy and Terms are two separate pages and the links say which is which. */
/* ⛔ THE FOOTER IS LIGHT. It was a second dark band, which made the page dark-light-dark and gave the
   eye two heavy horizontals competing for the same job. Light footer means the hero is the only dark
   region on the page, and the hero is the thing that should be heavy.

   It is --card, not --paper: the body ground is --paper, so an identical footer would merge into the
   last section. A near-white a step off the body plus a 1px top rule keeps it a distinct region.

   ⚠️ EVERY COLOUR IN HERE HAD TO FLIP, NOT JUST THE BACKGROUND. --dim and --hi are the on-dark pair;
   left in place they would have been near-invisible on a near-white ground — which is exactly the bug
   the old --gold-deep links had, and the reason that bug now cannot exist rather than being corrected. */
.site-foot{background:var(--card);color:var(--body);margin-top:30px;padding:26px 0 28px;
  border-top:1px solid var(--rule)}
/* == THE RESERVE THE FIXED BAR NEEDS, AND ONLY WHERE THE BAR EXISTS ==================================
   ⛔ IT CAME OUT WITH position:sticky AND HAS TO GO BACK WITH position:fixed. A sticky bar is IN the flow
   and reserves its own box at the end of the document; a fixed one is out of flow and simply covers
   whatever is under it, which is the footer's last line. This is the paired half of that revert - remove
   one without the other and the copyright line ends up beneath the bar.

   /!\ ONLY BELOW 760, BECAUSE .sticky IS display:none FROM 760 UP. Above that width the reserve would be
   dead space at the foot of every desktop page, held for a bar that is not rendered.

   /!\ 92px IS DELIBERATELY GENEROUS. The bar is 67-70px depending on width - its number sits on a vw
   clamp - so no fixed number can match it exactly, and the slack is the footer's own colour and therefore
   invisible. The env() term is inert now that viewport-fit=cover is gone - every inset resolves to 0,
   so this is a flat 92px - and it stays only so the reserve would still be right if it ever returned. */
@media (max-width:759.98px){
  .site-foot{padding-bottom:calc(92px + env(safe-area-inset-bottom))}
}
/* ══ THE FOOTER ═══════════════════════════════════════════════════════════════════════════════════════
   FOUR BANDS, TOP TO BOTTOM, AT EVERY WIDTH:

       1  the lockup, and the dispatch paragraph
       2  the five nav links     ONE horizontal row, generic pages only
       3  the three legal links  ONE horizontal row
       4  a full-width rule, then the copyright and this page's identity at opposite edges

   ⛔ THE LINK ROWS ARE NEVER VERTICAL. Not at 1280, not at 654, not at 375. They are horizontal rows
   that WRAP. Two previous attempts stacked them — first as a column of eight, then as two columns of a
   grid — and both read as a list of destinations rather than a footer. flex-direction is never touched
   below; the only thing that changes with width is where the row is justified.

   ⛔ THE DISPATCH PARAGRAPH IS CAPPED AT 60ch AND SETS THE WIDTH OF NOTHING. Uncapped it ran the full
   content width and forced every other band to lay out around it.

   ⛔ THE NAV ROW IS ABSENT ON A LANDING PAGE, NOT HIDDEN. [foot_nav] renders an empty string there, so
   there is no element and therefore no gap — a flex gap only exists between items that exist. That is
   why it can vanish without moving anything else a pixel. */
.foot-inner{display:flex;flex-direction:column;gap:18px}

/* == BAND 1 IS THREE COLUMNS, NOT A LOGO WITH THINGS UNDER IT ==========================================

       [ lockup ]   [ Automotive Residential Commercial Emergency Contact ]   [ dispatch paragraph ]

   /!\ THE NAV MOVED UP INTO THIS ROW AND THAT IS THE WHOLE HEIGHT SAVING. It used to be its own band
   below the lockup, which left the logo column short, the paragraph column short, and a full row of
   links under both -- three bands of vertical space to say what fits on one line.

   X A LANDING PAGE HAS NO MIDDLE COLUMN AND MUST NOT SHOW A HOLE. [foot_nav] renders an EMPTY STRING on
   anything that is not a generic page, so there is no element at all -- and a flex gap only exists
   between items that exist. justify-content:space-between then puts the lockup and the paragraph at the
   two ends, which is the correct two-column footer rather than a three-column one with a gap in it. */
.foot-lead{display:flex;justify-content:space-between;align-items:flex-start;gap:18px 40px}
.foot-lead .brand{flex:0 0 auto}
/* X THE PARAGRAPH IS CAPPED AT 60ch AND SETS THE WIDTH OF NOTHING. Uncapped it ran the full content
   width and forced the other two columns to lay out around whatever was left. */
.foot-disclaimer{margin:0;font-size:13px;line-height:1.6;max-width:60ch;color:var(--slate);flex:0 1 auto}

/* == THE SERVICE LINKS ================================================================================
   Horizontal and wrapping at every width. X NEVER A VERTICAL LIST -- flex-direction is not touched
   anywhere below; the only thing that changes with width is where the row is justified. */
/* /!\ flex:0 0 auto, NOT 0 1 auto, AND THAT IS THE WHOLE REASON IT IS ONE LINE. The five links need
   455px to sit in a row. Allowed to shrink, flex handed the column 408px and the row silently became
   two — the links still horizontal, still wrapping, and still wrong, because the band exists to be one
   row of five. It cannot overflow instead: below 1152 the whole band is a COLUMN, so there is no width
   at which this item is asked for more room than the row has. */
.foot-nav{display:flex;flex-wrap:wrap;align-items:center;gap:0 14px;flex:0 0 auto}
.foot-nav a{display:inline-flex;align-items:center;min-height:44px;font-size:15px;
  text-decoration:none;color:var(--brand-deep)}
@media (hover: hover) and (pointer: fine){.foot-nav a:hover{color:var(--brand-darker);text-decoration:underline}}

/* == WHEN THE ROW RUNS OUT OF SPACE, BOTH COLUMNS GIVE WAY TOGETHER ===================================

       [ lockup ]   [ Automotive Residential Commercial ]   [ dispatch paragraph, narrower ]
                    [ Emergency Contact                 ]

   The links WRAP onto a second row -- three and two, the same shape they already take at 375 -- and the
   paragraph narrows and runs to more lines. Neither one holds its width at the other one's expense.

   X THEY DO NOT STACK ONE PER LINE. A vertical ladder of five is 220px tall, which made the footer
   TALLER than the layout it replaced -- the opposite of what moving the links up was for.

   X AND THE FOOTER DOES NOT COLLAPSE HERE EITHER. At 1097 it used to stack into one column with the
   right half of the viewport completely empty: a layout that had given up while it still had 400px of
   unused width beside it. Three columns survive to 620, where everything centres anyway.

   /!\ flex:0 1 auto BELOW THIS WIDTH, flex:0 0 auto ABOVE IT, AND THAT IS THE WHOLE MECHANISM. The five
   links need 455px to sit on one row. Allowed to shrink at every width, flex takes 26px off them at 1280
   -- where the row has room for all five -- and wraps them for nothing. Refused the shrink at every
   width, they can never wrap at all and the paragraph pays for it alone. 1152 is where the one-row
   version genuinely stops fitting: lockup 135 + links 455 + two 40px gaps + wrap padding 124 leaves the
   paragraph under 40ch.

   /!\ THE LINK WIDTHS, MEASURED, so the wrap points are checkable rather than guessed:
   Automotive 78.4  Residential 75.8  Commercial 81.7  Emergency 76.8  Contact 54.5, gap 22.
   Three on a row needs 280px, four needs 379, five needs 455. */
@media (max-width:1151.98px){
  .foot-nav{flex:0 1 auto;min-width:281px}
}

/* == 281px IS THE FLOOR, AND WITHOUT IT FLEX KEEPS GOING ==============================================
   min-width:0 let the links shrink as far as the row wanted, and they did not stop at two rows -- 2+2+1
   at 1000 and a one-per-line ladder again by 700, which is the shape this change exists to remove.
   Three on a row measures 279.9px, so 281 is that plus a pixel of cushion, and it is comfortably under
   the 379px at which a FOURTH link would join the first row and make it 4+1. Between those two numbers
   there is exactly one shape available, which is why it can be stated as a floor rather than a guess. */

/* == THE BOTTOM BAR: COPYRIGHT, THE LEGAL THREE, AND THIS PAGE'S NUMBER ================================

     (c) 2013-2026 Locksmith Connections . Privacy Policy . Terms of Service . Dispatch Disclaimer   (877) 940-4442

   /!\ THE LEGAL LINKS ARE FINE PRINT, NOT NAVIGATION, AND THE COLOUR IS THE ARGUMENT. They are set in
   --faint at 12px -- the copyright's own colour and the copyright's own size -- so the line reads as one
   continuous piece of required small print rather than as three destinations competing with the service
   links above. They were --brand-deep at 12px in a band of their own, which made three legal notices
   look like a second menu. 5.34:1 on --card, the same as the copyright beside them.

   /!\ margin-left:auto ON .foot-fine, NOT justify-content:space-between. space-between with three items
   would have put the legal links in the MIDDLE of the bar with air on both sides. Pushing only the last
   item right leaves the copyright and the legal links adjacent on the left, which is what "immediately
   to the right of the copyright line" means. */
/* /!\ THE 7px COLUMN GAP IS WHAT SEPARATES THE COPYRIGHT FROM THE FIRST MIDDOT. The dots inside
   .foot-links are spaced by that row's own gap; this one has to come from the row above it. */
.foot-base{display:flex;flex-wrap:wrap;align-items:center;gap:6px 7px;
  padding-top:14px;border-top:1px solid var(--rule-2)}
/* == THE COPYRIGHT IS A BAND, NOT AN ITEM IN THE BAR ==================================================
   ⛔ ALWAYS LAST, AT EVERY WIDTH AND ON EVERY PAGE TYPE. As the first child of .foot-base it opened a
   one-line run on desktop and, once that bar stacked, sat ABOVE the legal links at some widths and
   BELOW the city and number at others — three positions for one line. Its own band has no such
   dependency.
   /! AND IT HAS REAL SPACE ABOVE IT: .foot-inner's 18px gap plus 16px here. Jammed against the block
   above it read as the last sentence of the footer's content rather than as the site's chrome. A rule
   was the alternative and would have been a second hairline in a short footer. */
.foot-copy{margin:0;width:100%;padding-top:16px;text-align:center;
  font-size:12px;line-height:1.6;color:var(--faint)}
.foot-links{display:flex;flex-wrap:wrap;align-items:center;gap:0 7px}
.foot-links a{display:inline-flex;align-items:center;min-height:44px;font-size:12px;
  text-decoration:none;color:var(--faint)}
/* == THE MIDDOT IS A SIBLING OF THE LINKS, NOT A ::before INSIDE THEM ==================================
   X IT USED TO LIVE INSIDE THE ANCHOR, which meant hovering Privacy Policy underlined the dash in front
   of it. Punctuation between two links belongs to neither of them: a text-decoration set on the anchor
   reaches everything the anchor contains, so the only fix that actually works is to take the character
   out of the anchor. It is a real <i> in the markup now, the same construction .foot-fine already uses
   between the city and the number.
   /!\ THE FIRST ONE IS THE DOT AFTER THE COPYRIGHT, so the run reads "...Connections . Privacy Policy
   . Terms...". Below 620 the row moves onto its own line and that leading dot becomes wrong, which is
   the one thing the mobile block removes. */
.foot-links i{font-style:normal;color:var(--faint);user-select:none}
@media (hover: hover) and (pointer: fine){.foot-links a:hover{color:var(--body);text-decoration:underline}}

.foot-fine{margin-left:auto;font-style:normal;display:flex;flex-direction:column;align-items:flex-end;
  text-align:right;gap:2px;font-size:13px;color:var(--slate);line-height:1.6}
.foot-fine-row{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:flex-end;gap:0 7px}
.foot-fine-metro{display:block}
.foot-fine i{font-style:normal;color:var(--rule)}

/* == BELOW 620 EVERYTHING CENTRES AND THE BASE BECOMES THREE STACKED LINES =============================

       (c) 2013-2026 Locksmith Connections
       Privacy Policy . Terms of Service . Dispatch Disclaimer
       (877) 940-4442

   X NOT "MOSTLY CENTRED". A centred logo over left-aligned links is worse than either done consistently,
   which is what an earlier version was.
   /!\ justify-content ON THE ROWS AND text-align ON THE BLOCKS. A wrapped link row needs its LINES
   centred, which is justify-content on the flex container; text-align alone centres the text inside each
   link and leaves the row itself starting at the left edge.
   /!\ THE BASE STACKS RATHER THAN SITTING AS A CENTRED GROUP. At 375 each of the three fits on its own
   line, and centring them as a group would push them to opposite ends with a few pixels of air --
   technically centred, and reading as things shoved apart. */
/* == THREE COLUMNS, THEN TWO, THEN ONE ================================================================

   X IT WENT STRAIGHT FROM THREE TO ONE AND THAT IS WHY THE NUMBER KEPT MOVING. 1152, then 882, then 881
   — three attempts to find a single width at which a footer that fits in three columns stops fitting and
   has to become one. There is no such width. Between them is a whole range where three do not fit and
   one wastes half the page, and the answer to that range is two:

       three   lockup | service links | paragraph
       two     lockup over paragraph on the left, service links on the right
       one     everything centred

   /! 890 IS MEASURED, AND IT IS ONE NUMBER FOR THE WHOLE FOOTER. The lead stops fitting in three at
   881 and the bottom bar wraps at 849 on Chicago — but the bar is a property of the CITY: the longest
   metro line on the site is 50 characters against Chicago 44, so the worst case wraps about 39px
   earlier, at roughly 888. 890 covers all 123 pages and changes the whole footer at one width instead
   of at two of them nine pixels apart.

   ⛔ A LANDING PAGE HAS NO MIDDLE COLUMN AND MUST NOT GET A GRID WITH AN EMPTY TRACK. [foot_nav] renders
   on generic pages only, so the two-column grid is scoped to body.kind-generic; everything else keeps
   the plain stacked column, where the lockup and the paragraph follow each other with no hole beside
   them. A grid track with no items is zero wide but its GAP is not, which is where the hole would be. */
@media (max-width:889.98px){
  .foot-lead{flex-direction:column}
  .foot-lead .foot-nav{order:1}
  /* Stacked, the links have the full content width, so the 281px floor would only hold them narrower
     than the space they already have. */
  .foot-nav{min-width:0}
}
/* == THE BOTTOM BAR IS TWO ENDS OR IT IS CENTRED, NEVER ONE OF EACH ====================================
   X ONCE IT WRAPPED, THE COPYRIGHT AND THE LEGAL LINKS STAYED LEFT AND THE CITY AND NUMBER WENT RIGHT ON
   A ROW OF THEIR OWN — left-aligned text above right-aligned text, which reads as two things that failed
   to line up rather than as one band. margin-left:auto on .foot-fine is what does it: it right-aligns
   the last item whether or not it is still on the first line, and CSS cannot ask an element whether it
   wrapped. So the shape is chosen by width, at the same 890 as everything above. */
/* ⛔ ONE FOOTER, ONE ALIGNMENT. The bar centred here while the lockup and the paragraph above it stayed
   left — two alignments in one footer, which is the fault this whole section exists to remove, one band
   higher up than where it was last found. The moment the footer stops being columns and becomes a single
   stack, EVERY element in it centres: lockup, paragraph, links, legal links, copyright, city, number and
   metro line. No exceptions.
   X THERE WAS A TWO-COLUMN STATE HERE AND IT IS GONE. It was added to stop the footer collapsing with
   half the width unused, and it was worse than the thing it avoided: the paragraph dropped to a
   full-width row of its own, stayed LEFT-aligned, and went on narrowing as the row got wider, while the
   nav links right-aligned on the row above it. Two alignments and a paragraph shrinking into space it
   had just been given. Three columns, then one. Nothing between them, on any page type. */
@media (max-width:889.98px){
  .foot-inner{align-items:center;text-align:center}
  .foot-lead{align-items:center}
  .foot-nav{justify-content:center;width:100%}
  .foot-base{flex-direction:column;align-items:center;gap:10px;text-align:center;width:100%}
  .foot-fine{margin-left:0;align-items:center;text-align:center}
  .foot-fine-row{justify-content:center}
  .foot-links{justify-content:center}
}

/* == BELOW 620 THE FINE PRINT GIVES UP ITS TAP BOXES ==================================================
   ⛔ EVERYTHING IS ALREADY CENTRED BY THE BLOCK ABOVE, and none of that is repeated here. Centring
   written out twice is centring that can disagree with itself later. What is left is the one thing
   genuinely about this width: the legal links stop carrying a 44px tap box.

   Measured at 400, from the TEXT rather than from the boxes:

       legal -> number      12 gap + 12.4 dead space inside the 44px box + 3.9 half-leading = 28.3
       number -> copyright  12 gap + 3.9 half-leading + 5.4 half-leading                    = 21.3

   X THE gap PROPERTY WAS NEVER WRONG. It measures box to box and all three boxes were 12px apart. The
   legal row box is 44px tall around 19.2px of text, so 12.4px of it is empty — and empty space inside a
   box reads as part of the gap beside it. A tap target is a region a thumb can hit; it has no business
   deciding what a reader sees as spacing.

   /!\ 44 -> 25.2px HERE ONLY, AND STILL ABOVE THE BAR. WCAG 2.5.8 Target Size (Minimum), the AA
   criterion, asks 24x24 CSS px; 44 is 2.5.5, which is AAA. Stacked fine print at the foot of the page is
   where that trade belongs, and the wider layout keeps its 44px because there the height costs nothing.

   /!\ AND THE COPYRIGHT LEADING COMES DOWN TO MATCH. 1.9 on 12px is 5.4px of half-leading against the
   number 3.9; equalising the boxes without the leading leaves 1.5px on the table. */
/* == BELOW 520 THE FIVE LINKS BREAK 3 + 2, AND A max-width IS WHAT DECIDES IT ==========================
   Left to the full content width they broke wherever the gutter happened to fall — four and one at some
   widths, two and three at others, and the row read as an accident. Capping the row forces the break
   after "Commercial", and justify-content:center then centres each line independently.

   /!\ MEASURED, NOT GUESSED. At the 14px gap above: Automotive 78.4 + Residential 75.8 + Commercial
   81.7 with two gaps is 263.9px, and adding Emergency would need 354.7. Any cap between those two takes
   exactly three, so 330 sits in the middle of a 91px window rather than on the edge of it.

   ⚠️ AT 320 THE CAP DOES NOT BIND AND DOES NOT NEED TO. The gutter is 11px there, so the row already has
   only 298px — which still clears 263.9 and still takes three. The cap only does work on the wider
   phones, which is exactly where the row had room to take four. */
@media (max-width:519.98px){
  .foot-nav{max-width:330px;margin-inline:auto}
}
@media (max-width:619.98px){
  .foot-base{gap:12px}
  .foot-links a{min-height:0;padding:3px 0}
}
/* ⚠️ NOT .numintext. That is the 19px bold treatment the header chip uses, and it made a legal footnote
   shout as loudly as the call button. Same size as everything else on this line, coloured as a link. */
/* == THE FOOTER NUMBER: NO UNDERLINE ANYWHERE, TWO COLOURS BY PAGE TYPE ================================
   /!\ ON A LANDING PAGE IT KEEPS THE ACCENT, DELIBERATELY. That is the local tracked number for the metro
   the visitor is reading about, and it is worth finding. On a generic page it is the toll-free line, sat
   beside the copyright, and there it takes the copyright's own colour: it is a fact in the small print,
   not an offer. It is a tel: link in both cases — it just stops advertising itself on one of them.

   /!\ THE UNDERLINE IS GONE RATHER THAN RECOLOURED. Every underline on this site is currentColor or
   absent; a hairline in a different colour from its own text is what produced three visible blues on one
   phone number. Removing it is the version of that rule that also answers "stop looking like a link". */
.foot-tel{color:var(--brand-deep);text-decoration:none;
  font-variant-numeric:tabular-nums}
@media (hover: hover) and (pointer: fine){.foot-tel:hover{color:var(--brand-darker)}}
/* The six generic pages and the three legal pages carry the toll-free line, not a metro line. */
body.kind-generic .foot-tel,body.kind-legal .foot-tel{color:inherit}
@media (hover: hover) and (pointer: fine){body.kind-generic .foot-tel:hover,body.kind-legal .foot-tel:hover{color:var(--brand-darker)}}
.copyright{margin:0}

/* ── STICKY BAR ─────────────────────────────────────────────────────────────────────────────────────────
   ⛔ ONE BAR. Bottom, on scroll. There were two before and the top one covered the H1.
   ⛔ THE BAR IS THE ANCHOR. It was a <div> with only the small button linked — on the primary mobile CTA
   of a page whose single goal is a phone call, a thumb landing on the number got nothing. */
/* ⛔ THE BAR IS DARK AND THE BUTTON IS THE ACTION COLOUR, NOT THE OTHER WAY ROUND.
   It shipped inverted: the whole bar was --action and the CALL button inside it was --action-ink. That
   made the container shout and the button recede, and it left the phone number as dark type on light
   blue, which reads washed out at 21px.

   THE RULE: --action is a FILL FOR THE THING YOU PRESS, not a background for the region around it. One
   small saturated element against a dark ground — which is exactly why the header phone chip works, and
   this bar is now built the same way.

   The whole bar is still one tap target. It has simply stopped advertising itself as one. */
/* == THE STICKY BAR IS ONE BUTTON =======================================================================
   Same fill as the hero button and the header chip, a white number, and the dispatch label beneath it in
   --action-ink. One action, one colour, three surfaces.

   X NO PILL INSIDE IT. There was a small pale "Call" button on the right of a dark bar, which made the
   pill look like the target and the number — the biggest thing on the bar — look like text. The <a> is
   the bar and the whole bar is the hit area; there is nothing inside it to intercept a tap, and nothing
   inside it carries pointer-events of its own.

   /!\ THE NUMBER IS WHITE ON #71D2F5 HERE TOO, and for the same reason as the other two surfaces: it is
   large and heavy enough to carry, and the LABEL — which is small and uppercase and did not carry — is
   --action-ink at 5.2:1. Measured, rendered, chosen. Do not add a border, outline, shadow or darker fill.

   /!\ min-height 64px, not 44. It is the primary action of the page on the device where it matters most,
   and it now holds two lines instead of one. */
/* == THE BAR SWEEPS ON AN UPWARD SCROLL, AND PROMOTING IT MADE IT WORSE ================================
   ⛔ WHAT THE RECORDING ACTUALLY SHOWS, WHICH IS NOT WHAT WAS INFERRED TWICE. One solid full-width band
   in the bar's own #71D2F5 travelling top to bottom over about five frames, while the page underneath
   stays still. It carries NO CONTENT — the footer links show through it dimmed in one frame, and in the
   next the real bar at the bottom is solid blue with its number missing. Background paints, contents do
   not, and it moves: that is a compositor layer being RE-RASTERED, not a second copy of the element.

   ⛔ will-change:transform AND backface-visibility WERE ADDED HERE AND HAVE BEEN TAKEN BACK OUT. The
   reasoning was that Safari was repainting the bar instead of compositing it, so a layer would fix it.
   The opposite happened: giving the bar its own backing store made the re-raster VISIBLE. Before the
   promotion Alon described the sweep as "very dim and faded"; after it, solid. A fix that strengthens
   the symptom is a wrong model, and the frames settled it where two rounds of reasoning had not.

   ⛔ THE TRIGGER IS THE TRANSITION, NOT THE PAINT PATH. This element transitions transform over .22s.
   When Safari's toolbar slides in, the visual viewport changes and the bar is moved — and the transition
   animates it through that move, giving the compositor .22s of intermediate states to raster. The script
   in src/render/page.ts listens for visualViewport's resize and kills the transition for 250ms, so the
   bar SNAPS to its new position with nothing to animate through.

   X THE SAFE-AREA PADDING IS GONE FROM THE BOTTOM EDGE, AND SO IS viewport-fit=cover. Both were added
   together for the band flash, the flash was never fixed by them, and between them they caused the two
   things Alon actually reported: the bar resizing mid-scroll, and a strip of page showing beneath it.
   See the note on the padding below, and head.ts. */
/* == THE BAR IS PINNED TO THE VIEWPORT. IT WAS position:sticky FOR A WHILE AND THAT WAS A MISTAKE =====
   ⛔ STICKY WAS TRIED AS A FLASH FIX, IT DID NOT FIX THE FLASH, AND IT BROKE TWO OTHER THINGS. It is on
   the disproved list at the foot of this file with everything else. What it cost while it was in:

     · THE BAR STOPPED SITTING ON THE BOTTOM EDGE. A strip of page showed underneath it on iPhone.
     · THE BAR APPEARED TO CHANGE HEIGHT while scrolling, and flashed MORE on the way up, not less.

   Both follow from the same property. A sticky element pins to the SCROLLPORT; a fixed one pins to the
   visible viewport. On iOS those are not the same box - while the toolbar is showing, the scrollport's
   bottom sits below the visible area, and that offset MOVES as the toolbar slides. So the bar was being
   held against an edge the user cannot see, which is both the strip underneath and the apparent height
   change. Fixed has neither problem, because it is anchored to the box the user is actually looking at.

   /!\ left:0 AND right:0 ARE BACK AND ARE NOT DECORATION. A fixed element with both offsets auto is
   shrink-to-fit at its static position - the bar would be as wide as its own text rather than edge to
   edge. They were correctly removed for sticky, where they mean horizontal sticky constraints instead,
   and they have to come back with it. See also the footer's bottom reserve, restored for the same reason:
   a fixed bar is out of flow and covers the footer's last line without it.

   ⛔ AND NOTHING ELSE GOES BACK ON. No will-change, no transform, no transition on transform. The layer
   promotion was tried and made the band STRONGER. The band is closed - see the list further down. */
/* == THE CSS ON ITS OWN HAS TO GIVE A WORKING BAR. THAT IS THE FLOOR, NOT THE TARGET =================
   ⛔ position:fixed; bottom:0 AND NOTHING CLEVER. Everything below this line is what a visitor gets when
   no JavaScript runs at all: a full-width bar on the bottom edge with the number on it. On iOS it will
   sit a toolbar's height high while the toolbar is hidden - the gap - and that is the WORST acceptable
   outcome. A missing bar, or a bar in the wrong place, is not.

   ⛔ THE dvh HOST WAS REMOVED FROM HERE. 100dvh resolved to the shorter viewport while the toolbar was
   hidden, so the host's bottom edge was in the same wrong place the bar had been, and the gap survived.
   No CSS unit reports the actually-visible area reliably on iOS; window.visualViewport does, so the gap
   is closed by the script in src/render/page.ts and by nothing here.

   /!\ THE SCRIPT ADJUSTS bottom, NOT transform, WHICH IS WHY IT IS SAFE TO ADD. A transform would
   promote this to its own compositor layer - that was tried during the band hunt and made things worse.
   An inline bottom offset moves the element identically and creates no layer. Do not "optimise" it into
   a translate.

   /!\ AND THE INLINE OFFSET CANNOT STRAND THE BAR. The script only ever writes style.bottom; the rule
   here is what applies if it never runs, and the script clamps anything implausible back to 0. */
.sticky{position:fixed;left:0;right:0;bottom:0;z-index:40;background:var(--action);
  text-decoration:none;
  /* ⛔ THE BAR FADED OUT AND APPEARED INSTANTLY, because only .sticky.off carried a transition.
     Leaving is animated by the rule being ADDED; arriving is the rule being REMOVED, which drops back to
     this one — and this one declared none. Alon: "it was fading now it feels like it is not fading, it is
     appearing". The same opacity, mirrored.
     /! OPACITY AND VISIBILITY ONLY. NO transform AND NO will-change. Both are on the disproved list
     below: a compositor layer made the band flash STRONGER, and the .22s transform transition was removed
     while chasing it. Opacity was never implicated — .sticky.off has been transitioning it all along. */
  transition:opacity .22s ease, visibility 0s;
  /* X IT WAS rgba(--action-rgb, .35) AND THAT IS THE SAME FAULT ONE LAYER DOWN. iOS paints the tap
     highlight on touch-DOWN, before the gesture is known, so a finger beginning a scroll anywhere on
     this bar washed the whole thing in 35% of the action colour. It is not a hover, it is not :active,
     and gating those two would have left it doing the identical thing. See the block at the foot of
     this file for why a phone needs no press confirmation from a dial button at all. */
  -webkit-tap-highlight-color:transparent;
  /* X NO UPWARD SHADOW. A 24px blur at 22% fell across whatever sat above the bar, and with the
     copyright now flush against it that was a grey wash over the last line of the footer. A hairline
     separates the two without reaching into the page, and unlike a shadow it costs no height because a
     border on this element is inside its own box. */
  border-top:1px solid rgba(var(--ink-rgb),.16);min-height:64px;
  /* == THE BOTTOM PADDING IS A CONSTANT, AND THAT IS THE FIX FOR THE BAR CHANGING HEIGHT ============
     ⛔ IT WAS calc(9px + env(safe-area-inset-bottom)) AND THAT MADE THE BAR RESIZE AS YOU SCROLL. On iOS
     that inset is 0 while Safari's toolbar is expanded and about 34px once it retracts - so the value
     changed mid-scroll and took the bar's height with it. Measured: content 50px + 9 + 9 = 68px with the
     inset at 0, against 50 + 9 + 43 = 102px with it at 34. Half as tall again, purely from scrolling,
     and min-height:64px never binds so nothing absorbed it. That is the "the bar changes height" report,
     and no amount of changing how it is POSITIONED could ever have touched it.

     /!\ NOTHING NOW NEEDS TO CLEAR THE HOME INDICATOR HERE, because viewport-fit=cover is gone from the
     viewport meta - see head.ts. Without it iOS lays the page out INSIDE the safe area and reserves the
     indicator itself, which is what it did before any of this. The left/right max() below are inert for
     the same reason - every inset resolves to 0 - and are left in place only so that reinstating
     viewport-fit would not silently drop landscape notch handling. Do not read them as live. */
  /* /! PLAIN 16px, NOT max(16px, env(...)). The env() form was the last trace of viewport-fit=cover in
     this rule and it resolved to exactly this, because without that meta tag every inset is 0. Keeping a
     dead safe-area reference here only invited someone to think insets were live. If viewport-fit ever
     comes back, landscape notch handling has to be reinstated deliberately - see head.ts. */
  padding:9px 16px;
  /* == ONE GRID FOR THE BAR, SO THE SUBTITLE SITS UNDER THE NUMBER ================================
     ⛔ THE ROW AND THE SUBTITLE WERE CENTRED SEPARATELY, WHICH IS WHY THEY DID NOT LINE UP. Both were
     centred on the BAR: the row as a whole cluster, the subtitle as a line of text. But the cluster
     carries a dot and a handset on its left, so the NUMBER inside it sits right of the bar's centre
     while the subtitle sat on it - the subtitle drifting left of the digits above it.

     Three columns - lead, and the number - with the subtitle placed in the number's own column on a
     second row. justify-content:center centres the whole track group, so the cluster keeps equal space
     either side; justify-self:center then centres the subtitle within the number's column alone. Both
     requirements at once, and no magic offset to go stale when the type scale changes.

     /!\ .sticky-row IS display:contents. It has to stop generating a box for its children to become
     items of THIS grid; it still exists for inheritance, so font-size:var(--num-size) on it continues
     to size the number and the icon exactly as before.

     /!\ THE COLUMN ASSIGNMENTS COVER BOTH MARKUP SHAPES. Before the publish the dot and the handset are
     loose children and take columns 1 and 2; after it they are wrapped in .sticky-lead, which spans the
     same two. Whichever is live, the number is column 3 and the subtitle is beneath it. This file has
     now been broken twice by assuming a shape only a publish delivers - it is not going to be a third. */
  /* /!\ AN OPTICAL NUDGE, NOT A CORRECTION - THE MATHS WAS ALREADY RIGHT. Centred by measurement the
     cluster has identical space either side, and it still reads as sitting slightly right, because the
     dot and the handset are small and pale while the number is large and solid. The eye weights the
     digits and puts the true centre somewhere between "cluster centred" and "number centred". This
     splits that difference: a fourth, empty track on the right pushes the whole assembly left by half
     its width, carrying the subtitle with it because the subtitle is inside the grid too. Nothing moves
     relative to anything else.

     /!\ A TRACK, NOT A MARGIN. A margin on the row would move the row and leave the subtitle behind -
     the exact fault this replaced. Everything lives in one grid now precisely so a single change moves
     all of it, and an empty track is a change to the grid.

     /!\ minmax(0, ...) SO IT CAN NEVER CLIP OR SCROLL. The track collapses before the content does, so
     on a narrow phone the nudge gives way rather than pushing the number into the padding. It is worth
     26px of slack when there is room and nothing when there is not. Measured at 320 - the tightest
     width the site supports - the cluster and the nudge together still fit inside the bar. */
  /* /! THE HANDSET SAT TOO CLOSE TO THE DIGITS. One value drives both gaps - dot to handset and
     handset to number - so they cannot drift apart again the way they had. */
  --num-max:30px;--num-size:clamp(21px, 7.4vw, 30px);
  --optical-nudge:13px;
  display:grid;
  grid-template-columns:auto auto auto minmax(0, calc(var(--optical-nudge) * 2));
  grid-template-rows:auto auto;
  justify-content:center;align-items:center;row-gap:2px;column-gap:calc(.45 * var(--num-size));
  opacity:1;visibility:visible;
  transition:opacity .2s ease, visibility 0s,
             background-color var(--hover),color var(--hover),box-shadow var(--hover)}

/* X .off HIDES IT; THE ABSENCE OF .off DOES NOT REVEAL IT. That inversion is the whole point. The bar
   used to rest at translateY(110%) with .on sliding it up, which made JAVASCRIPT the thing that made it
   exist - and a script that does not run left a lead-generation page with no phone number on it at all.
   The CSS ships it visible now and the script only takes it away near the top of the page, so every
   failure mode of that script ends with the bar on screen.

   /!\ AND THE TRANSFORM AND ITS .22s TRANSITION WENT WITH IT. There is nothing left for Safari to
   animate the bar through on a viewport change, and the bar is no longer viewport-anchored either - see
   the fixed -> sticky note above.
   X DO NOT ADD will-change OR A TRANSFORM BACK. Both were tried; the layer promotion made the band
   stronger, not weaker. */
/* ⛔ display:none, AND THE FADE THAT REPLACED IT HAS BEEN BACKED OUT. Hiding with opacity + visibility
   is the right way to make this ease rather than pop, and it went out in the SAME push as the
   visualViewport script - so when a real iPhone showed the bar flashing and rebuilding, there was no way
   to tell which of the two caused it. Both are reverted. If the fade is wanted again it goes out ALONE,
   on top of a bar that is known to be stable, so the next report attributes to one change.
   /!\ THIS IS THE COST OF BUNDLING. Two changes, one push, one symptom, no attribution. */
/* == HIDDEN BY opacity AND visibility, NOT BY display ================================================
   X display CANNOT ANIMATE, WHICH IS WHY THE BAR POPPED. It used to slide in on a transform; that came
   out during the band hunt and .off became display:none, leaving nothing to ease. A transform is still
   banned here - it was tried twice and once made the band worse - so opacity is the property that can
   carry this without promoting the bar to its own layer.

   /! visibility IS NOT REDUNDANT NEXT TO opacity. An element at opacity:0 is still focusable and still
   clickable, so without it a keyboard user tabs into an invisible phone link above the fold.
   visibility:hidden takes it out of the tab order and out of hit-testing; pointer-events:none covers the
   moment it is mid-fade.

   /! THE visibility DELAY IS WHAT MAKES THE FADE-OUT VISIBLE. At 0s with no delay it blanks instantly
   and the opacity transition runs on something already gone. Hiding waits .2s; showing has no delay, so
   it becomes visible first and then fades up. The two rules differ ONLY in that delay - change the
   duration and it has to change in three places.

   /! THIS SHIPPED ONCE BEFORE, BUNDLED WITH A visualViewport SCRIPT, AND BOTH WERE REVERTED WHEN A
   DEVICE REPORT COULD NOT TELL THEM APART. It is going out alone this time, on a bar that is otherwise
   unchanged, so the next report attributes to it and nothing else. */
.sticky.off{opacity:0;visibility:hidden;pointer-events:none;
  transition:opacity .2s ease, visibility 0s .2s,
             background-color var(--hover),color var(--hover),box-shadow var(--hover)}

/* == THE BAND FLASH IS A DECISION, NOT A BUG, AND THE BAR IS NO LONGER MODIFIED FOR IT ==============
   ⛔ DO NOT OPEN ANOTHER THEORY ABOUT THE BAND. Everything below was tried and each was disproved by a
   recording rather than argued away:

     position:fixed -> the original suspect                       band remained
     a compositor layer (will-change + backface-visibility)       band got STRONGER
     removing the .22s transform transition                       band remained
     Safari's toolbar sliding on scroll up                        killed by the bounce happening downward
     overscroll-behavior:none, to stop the rubber-band            band remained
     position:sticky, so it is never viewport-anchored            band remained - AND BROKE TWO THINGS
     viewport-fit=cover removed                                   band remained
     every animation inside the bar                               band remained, slightly weaker

   The red test proved the element IS this bar, so what is left is the bar existing as a composited layer
   at all. Alon's standing decision: the flash stays. It is not worth losing anything else over.

   ⛔ AND NOTHING IN THE BAR IS KEPT CRIPPLED FOR IT ANY MORE. The pulse halo was switched off here as the
   last experiment and simply never put back; with the handset ringing again, "no forever-animation in
   this layer" had stopped being true anyway, so the rule was buying nothing and costing the bar its
   presence. The bar now looks like every other call button on the page - green dot, halo, ringing
   handset - which is what it should have looked like all along.

   /!\ IF THE BAND IS EVER REPORTED STRONGER, THE ORDER TO REMOVE THINGS IS: the ring first, then the
   halo. Both are decoration; the number is not. */

/* --num-size drives the number AND the icon off one value, so they cannot drift apart at a width nobody
   measured — the same single-source rule the header chip and the hero button use. */
/* == THE NUMBER IS CENTRED IN THE BAR. THE DOT AND THE HANDSET HANG OFF ITS LEFT EDGE ================
   ⛔ CENTRING THE WHOLE ROW PUT THE NUMBER RIGHT OF TRUE CENTRE. The dot and the handset are small and
   light and the eye does not measure against them - it measures against the digits. With the row centred
   as one object, their combined width pushed the number off-centre by half of it, and on a bar whose
   only job is to present a phone number that is the one thing that must sit true.

   /!\ minmax(0,1fr) auto minmax(0,1fr), AND EVERY PART OF THAT MATTERS. The number sits in the middle
   track, and the two 1fr tracks are equal by definition, so it is centred in the bar whatever the lead
   measures. The minmax(0,...) lets those tracks shrink BELOW their content, which is what makes the
   narrow case behave: the lead overflows leftwards out of its track instead of widening it and shoving
   the number right. That is the explicit requirement - at 320 the dot and icon crowd the edge, they do
   not push the number.

   /!\ NO gap ON THE GRID. A column-gap would sit between the lead's track and the number's and reappear
   as asymmetry the moment the lead overflows. The 9px between lead and number is the lead's own
   margin-right, so it travels with the lead. */
/* --num-size now lives on .sticky itself, so the grid's column-gap can be derived from it; this still
   sizes the number and the handset by inheritance exactly as before. */
.sticky-row{font-size:var(--num-size);display:contents}

/* Pre-publish shape: the dot and the handset are loose children of the row. */
.sticky-row > .online-indicator{grid-area:1 / 1 / 2 / 2}
.sticky-row > .phone-icon{grid-area:1 / 2 / 2 / 3}
/* Post-publish shape: they are wrapped, and the wrapper spans the same two columns. */
.sticky-lead{grid-area:1 / 1 / 2 / 3;display:flex;align-items:center;gap:calc(.45 * var(--num-size));min-width:0}
.sticky-num{grid-area:1 / 3 / 2 / 4}
/* /!\ justify-self:center IS THE WHOLE POINT - it centres the subtitle inside the NUMBER's column
   rather than inside the bar, which is what puts it under the digits instead of under the cluster. */
.sticky-note{grid-area:2 / 3 / 3 / 4;justify-self:center}

/* == THE WHOLE ROW IS CENTRED, NOT THE NUMBER ========================================================
   ⛔ THIS WAS TRIED THE OTHER WAY AND IT LOOKED WRONG ON A REAL SCREEN. Centring the NUMBER alone is
   defensible on paper - the eye reads digits, not a dot - and it measured perfectly: number dead centre,
   dot and handset hanging off its left edge. Seen at size it just reads as a group shoved left, because
   the dot and the handset are still ink and the eye takes the whole cluster as the object. Equal space
   either side of everything is what looks centred.

   /!\ SO THE GRID IS GONE AND THIS IS A PLAIN CENTRED FLEX ROW. It also removes the pre/post-publish
   split that the grid needed :has() to paper over: a flex row is correct with the dot and handset as
   loose children AND with them wrapped in .sticky-lead, so the stylesheet no longer cares which shape
   the published markup happens to be.

   /!\ ONE gap, ON THE ROW. .sticky-lead carries no margin of its own - if it did, the space between the
   handset and the number would be the row's 9px PLUS that margin, and only on the pages where the
   wrapper exists. The row's gap is the single source for both. */
.sticky-num{font-family:'Geist',sans-serif;font-size:1em;line-height:1.05;white-space:nowrap;
  font-variant-numeric:tabular-nums}
.sticky-note{font-family:'Geist',sans-serif;font-weight:700;font-size:11px;letter-spacing:.13em;
  text-transform:uppercase;color:var(--action-ink);text-align:center;
  max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* /!\ GATED LIKE EVERY OTHER :active IN THIS FILE. The bar is the single biggest touch target on a
   phone and therefore the one a scrolling finger passes over most often — the exact case that made a
   press state on touch wrong. With a mouse the whole bar still reacts, which is what tells you the
   whole bar is the target. */
@media (hover: hover) and (pointer: fine){
  .sticky:active{background:var(--action-hover)}
}
/* ⛔ THE ONE ELEMENT THAT KEEPS ITS OWN RING, AND IT IS INSET. The bar's ground IS --action, where
   --brand-deep measures 3.27:1 and --action-ink 5.22:1 — the designed pairing wins. It is also edge to
   edge at the bottom of the viewport, so an OUTWARD halo would be clipped on three sides; inset is the
   only direction with room. */
/* /!\ THE OFFSET IS NEGATIVE, WHICH IS THIS ELEMENT'S WHOLE PROBLEM. The bar is edge to edge at the
   bottom of the viewport, so an outward ring is clipped on three sides; drawing it inside is the only
   direction with room. --action-ink rather than the accent because the bar's ground IS --action, where
   the pairing measures 5.22:1 against the accent's 3.27. */
.sticky:focus-visible{outline:2px solid var(--action-ink);outline-offset:-4px}


/* ── WIDER SCREENS ──────────────────────────────────────────────────────────────────────────────────────
   THE DESKTOP FOLD IS THE REAL WIN. Today desktop shows a logo, a small phone button, a grey H1 and a
   full-viewport stock photo — no headline, no promise, no reason to call. Everything that works exists only
   on mobile. Below, and only below, the band becomes a two-column fold that carries the headline, the
   promise, the urgency line, the number and the lockout range. The ORDER of the mobile page is untouched. */
@media (min-width:760px){
  body{padding-bottom:0}
  .sticky{display:none}
  .site-nav{order:0;width:auto}
  .page-h1{font-size:15px;font-weight:600;letter-spacing:.19em;text-transform:uppercase;color:var(--dim);
    margin-bottom:22px}
    /* On desktop the photograph takes the right 58% and is fully visible there. The scrim runs the other
     way: solid ink under the headline, gone by the time it reaches the van.
     ⚠️ The dense end stops at 44%, and that is measured, not guessed — the text column is 640px inside an
     1180px wrap, so at 1440 the last character sits at roughly 40% of the viewport. Every word is on solid
     ink; nothing relies on a shadow or a lucky patch of dark sky. */
  .hero-photo{inset:0 0 0 auto;width:58%;opacity:1;
    -webkit-mask-image:linear-gradient(90deg,transparent 0,#000 26%);
    mask-image:linear-gradient(90deg,transparent 0,#000 26%)}
  .hero-bg{background:radial-gradient(80% 130% at 92% 26%,rgba(var(--action-rgb),.16) 0%,rgba(var(--action-rgb),0) 58%),
    linear-gradient(90deg,
      var(--ink) 0%,
      var(--ink) 42%,
      rgba(var(--ink-rgb),.80) 58%,
      rgba(var(--ink-rgb),.52) 68%,
      rgba(var(--ink-rgb),.26) 74%,
      rgba(var(--ink-rgb),.08) 80%,
      rgba(var(--ink-rgb),0) 92%)}
  /* The same horizontal treatment in light: solid under the 640px text column, clear over the photograph.
     The dense end stops at 44% for the identical reason the dark one does — that is where the last
     character sits at 1440px — so this is the measured stop, not a matching-looking number. */
  /* /!\ THE OPAQUE ZONE REACHES 62% BECAUSE THE STACK IS CENTRED ON THE PAGE NOW, not held in a left
     column. The old stops were solved for a 54% column; centring the text without moving them would put
     the right-hand half of every line on the photograph. The subject — the handover — sits in the right
     third of the frame and is still fully visible from where the fade ends. */
  /* /!\ THE FADE STARTS AT 34% AND RUNS THE REMAINING 66%, WHERE IT USED TO START AT 62% AND RUN 38%.
     Solid white for the first 62% of the band and then a short ramp reads as a white panel with a
     photograph pasted beside it -- there was a visible LINE where the picture began. A fade that starts
     before the photograph does and never stops falling has no line in it anywhere.

     /!\ STARTING BEFORE 42% IS NOT WASTED AND IT IS NOT VISIBLE EITHER. .hero-photo begins at 42% of
     the viewport, so everything left of that is white over white and the early stops cost nothing --
     they exist so the curve is already descending by the time it reaches the picture, instead of
     breaking away from flat at the same moment the photograph appears.

     /!\ THE 71/80/88 STOPS ARE .66/.50/.30 AND NOT .60/.42/.24, AND THAT IS A MEASUREMENT, NOT TASTE.
     The first curve put the widest benefit tick -- "Keys made and programmed on site", which reaches
     77.9% of the band at 950px -- on a dark patch of the driveway at 3.66:1 against a 4.5:1 bar. Only
     that one line, only in the 900-1000 range, where the text column is still wide but the photograph
     has started. Lifting three mid stops by six to eight points clears it at 4.9:1 and costs the fade
     nothing at either end: it still leaves flat at 34% and still reaches zero at 100%. */
  body.band-light .hero-bg{background:linear-gradient(90deg,
        var(--card) 0%,
        var(--card) 14%,
        rgba(255,255,255,.90) 24%,
        rgba(255,255,255,.76) 34%,
        rgba(255,255,255,.60) 44%,
        rgba(255,255,255,.45) 54%,
        rgba(255,255,255,.31) 64%,
        rgba(255,255,255,.19) 74%,
        rgba(255,255,255,.10) 84%,
        rgba(255,255,255,.04) 92%,
        rgba(255,255,255,0) 100%)}

    /* ⛔ THE WORDS CARRY THEIR OWN GROUND, AND THIS HALO IS LOAD-BEARING. One gradient could not both
       carry centred text spanning 29%..71% of the band and leave the middle of the photograph visible —
       those two requirements fight over the same pixels. The scrim is a long gentle ramp now and the
       type is held up by this instead. Delete it and the headline sits on bare photograph.
       /!\ tools/contrast-check.mjs SOLVES FOR A SCRIM ALONE and cannot see a text-shadow, so it reports
       this band as failing. That verdict is stale by construction — judge it on the rendered page. */
    body.band-light .hero-head,
    body.band-light .hero-sub,
    body.band-light .hero-city,
    body.band-light .hero-tick{
      text-shadow:0 1px 1px rgba(255,255,255,.95), 0 0 14px rgba(255,255,255,.9), 0 0 30px rgba(255,255,255,.7);
    }
  /* padding-top stays on the same scale so the ramp is continuous across this breakpoint rather than
     stepping; padding-bottom does not, because 78px is the 46px overlap's own tuned number. */
  .hero-inner{padding-top:var(--hero-air);padding-bottom:78px;min-height:62vh;
    display:flex;flex-direction:column;justify-content:center}
  /* /!\ THE CENTRED HERO IS CONTENT-DRIVEN, AND min-height WAS THE PHOTOGRAPH'S HEIGHT.
     62vh here and 58vh above 1080, plus a 78px foot, were reserved so the picture BEHIND the words had
     room to be a picture. With the photograph moved to its own band the reservation is just empty page:
     the band kept the height and lost the thing that filled it. That is the white space between the
     ticks and the photo band -- it was never a gap or a spacer between the two sections, and there is
     nothing between them to delete. The band ends below the ticks on ordinary section padding now. */
    /* /!\ THE STACK IS CENTRED INSIDE A LEFT COLUMN, not across the full width. Centring across the whole
       band would put the end of every line over the photograph -- which is the 1.49:1 the separate photo
       strip existed to avoid. 54% is where the fade is still .94 opaque. */
  .hero-body{max-width:640px;gap:18px}
  .hero-head{font-size:clamp(34px,4.8vw,58px);line-height:1.0;letter-spacing:-.022em}
  /* Back to the quieter grey here: on desktop this line sits on solid ink at 16.5:1, so the
     hierarchy costs nothing. On mobile it is over a photograph, where --dim measured 1.5:1. */
  .hero-city{font-size:19px;max-width:34ch;color:var(--dim)}
  .hero-urgency{font-size:15px}
  .hero-actions{flex-direction:row;align-items:stretch;gap:18px}
  /* /!\ THREE PER COLUMN MEANS grid-auto-flow:column. Six items in two columns fill ROW-major by default,
     so the reading order becomes 1,2 / 3,4 / 5,6 across the pair -- a list read in pairs, not two columns
     of three. Naming the rows and flowing down fills 1,2,3 then 4,5,6. */
  /* Two columns, three rows, filled DOWN each column. Row-major would read 1,2 / 3,4 / 5,6 across the
     pair, which is a list in pairs rather than two columns of three. */
  .hero-centred .hero-ticks{grid-template-rows:repeat(3,auto);grid-auto-flow:column;
    grid-template-columns:auto auto;justify-content:center;justify-items:start}
  .hero-centred .hero-actions{flex-direction:column}
  .hero-centred .hero-inner{align-items:center;padding-bottom:34px}
  /* /!\ THE COLUMN CAP AND THE GRADIENT ARE ONE DECISION. body.band-light's desktop fade is solid to 44%
     and still .94 opaque at 54%, so the words are held inside that band. Widen this without moving those
     stops and the end of every line lands on the photograph. */
  .hero-centred .hero-inner > *{max-width:640px;margin-inline:auto;width:100%}
  /* Two columns once there is room; one column on a phone, where a 2x2 grid would wrap every
     label to two lines and read as eight fragments rather than four statements. */
  .call-btn{align-items:center;padding:15px 26px}
  .call-note{text-align:left}
  .hero-anchor{flex-direction:column;align-items:flex-start;gap:3px;justify-content:center;
    border-left:1px solid rgba(var(--dim-rgb),.32);padding-left:18px}
  .hero-anchor-range{font-size:26px}

  /* The rate card rises into the band, so pricing is unmissable at the fold without moving anything. */
  .rates{--rates-overlap:46px}
  /* ⛔ THE THREE-UP CARD DOES NOT FIT AT 760px AND THAT IS WHERE THE COLLIDING COLUMNS CAME FROM.
     At 800px this grid gives each table about 220px, and "High-security locks" against "$149–$429" left a
     FIVE PIXEL gap — measured, not estimated. Widening the cell padding alone just moved the damage: it
     bought separation and pushed seven of eleven labels onto two lines instead.
     The honest fix is to stop going three-up before there is room for three. Between 760 and 1000 the
     intro takes the full width and the two tables sit side by side, which gives each of them roughly 350px
     and no label wraps at all. Three columns start at 1060px, where they actually fit. */
  /* ⛔ PADDING ON A <table> DOES NOTHING WHEN border-collapse IS collapse. That is a CSS rule, not a
     quirk, and it is why three separate attempts to space these columns with .rate-table{padding:…}
     had no effect whatsoever — including the one shipped as a fix. At 1060+ the grid gap was 0 and the
     inert padding was the only other thing meant to separate the two tables, so the published page
     rendered "$29Car keys / fobs" with a ONE PIXEL gap: the collapsed border, and nothing else.
     Separation now comes from the CELLS, where padding is honoured, plus the grid gap. Nothing in this
     card relies on padding applied to a table element. */
  /* ⛔ TWO COLUMNS ONLY ABOVE 900px, NOT 760. Between those two widths each column is about 350px and
     has to hold a label, a context line and a price on one row — so "Key extraction" broke to two lines,
     "New lock installation" to three, and the card grew to most of an iPad screen. The single-column
     layout below it was already correct; it just started 140px too late.
     /!\ THE MEDIA QUERY THIS SITS IN IS min-width:760px AND THAT IS RIGHT FOR EVERYTHING ELSE IN IT.
     Only the card's own column count moves, which is why this is a nested query and not a new breakpoint
     on the block above. */
  .rates-card{grid-template-columns:1fr;align-items:start;padding:18px 24px 16px;gap:0}
    /* ⛔ THE GUTTER RULES BELONG TO THE TWO-COLUMN LAYOUT AND NOTHING ELSE. They used to sit outside
       this query, so once the card stacked at 760-900px the SECOND table kept its 24px left padding and
       the FIRST kept a border-right with nothing beside it — every row in the lower half indented by
       24px against the upper half, for no reason a reader could see. Alon: "there is some sort of
       indent when the two sides stack between each other". Inside the query they cannot outlive the
       columns they were drawn for. */
    @media (min-width:900px){
      .rates-card{grid-template-columns:1fr 1fr}
      .rate-table:nth-of-type(1){border-right:1px solid var(--rule-2)}
      .rate-table th{padding-left:24px}
      .rate-table td{padding-right:24px}
      .rate-table:nth-of-type(1) th{padding-left:0}
      .rate-table:nth-of-type(2) td{padding-right:0}
    }
  /* /!\ THE INTRO KEEPS THE FULL WIDTH AT EVERY SIZE NOW, and the three-up card is gone. Each row carries
     three columns of its own since the split by context -- service, what it covers, range -- so a table
     squeezed into a third of the card had about 330px for all three and "push bars, high-security locks"
     wrapped to three lines. Full-width intro, two tables under it, ~560px each: nothing wraps. */
  .rates-intro{grid-column:1/-1;padding-bottom:14px;margin-bottom:14px;
    border-bottom:1px solid var(--rule-2)}





  .prose{padding-top:34px}
  .prose picture img{max-width:none;width:100%}
  .review-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
  /* X THE FOOTER'S COLUMNS ARE NOT SET HERE ANY MORE. This rule made .foot-inner a two-column grid and
     it sat AFTER the three-column rules, so it won on specificity-of-order and the footer stopped at
     two columns however wide the screen was. The layout lives in one place now, beside .foot-inner
     itself — one column, then two at 620, then three at 980. The .copyright half of this never matched
     anything: the class is .foot-copy. */
}

/* Three columns only once they fit — see the note above. Measured across 760/800/900/999/1000/1180/1280:
   at exactly 1000px one label still wrapped, so the breakpoint is 1060, where none do. */
/* X THE THREE-UP CARD IS GONE ON PURPOSE. It put the intro and both tables in one row, which was right
   while a row was "label, price". A row is "service, what it covers, range" now, so three-up gave each
   table a third of the card for three columns of its own. Two-up with a full-width intro is the layout
   that fits. If this is ever restored, measure "Lock change / push bars, high-security locks / $149-$429"
   at 1060 first — that is the row that breaks. */

@media (min-width:1080px){
  .hero-inner{min-height:58vh}
  /* Fills the prose column rather than sitting at half width against the left edge. */
  .body-img{max-width:none;width:100%}
}

/* ══════════════════════════════════════════════════════════════════════════════════════════════════════
   MOTION — all of it, in one place. Roughly 0.4 KB gzipped and 0 ms of LCP.

   ⛔ NOTHING ABOVE THE FOLD PARTICIPATES. Not the hero, not the H1, not the rate card, not a call button,
   not the sticky bar. The rate card must be visible immediately — that is an Advanced Verification
   requirement, not a preference — and it is also the LCP element. It cannot fade or slide in, ever.

   ⛔ DO NOT ADD content-visibility: auto TO THESE SECTIONS. It is a real rendering win on a 6,700px page
   and it CONFLICTS with what is below: a skipped section has no size until it is near, so scroll position
   jumps, and an element whose animation is driven by a view() timeline may have been skipped at the
   moment that animation should start. It is one or the other. The reveal was chosen. If you ever want to
   swap them, measure both — do not just add the second one on top.
   ══════════════════════════════════════════════════════════════════════════════════════════════════════ */

/* == THE REVEAL IS SCROLL-DRIVEN AGAIN, WHICH IS THE WHOLE POINT OF IT =================================
   X A TIMER CANNOT FEEL LIKE THIS AND NO AMOUNT OF TUNING WILL FIX THAT. The version this replaces was
   an IntersectionObserver firing a 560ms animation: it started when the section crossed a line and then
   ran on its own clock, indifferent to the scroll. What Alon has been asking for is the section tracking
   the wheel - stop scrolling and it stops, scroll back and it goes back. That is a timeline, not a timer.

   X BOTH ORIGINAL FAULTS ARE FIXED HERE, AND NOTHING ELSE ABOUT IT HAS CHANGED.

   1 - IT NOW COMPLETES, BECAUSE THE RANGE IS EXPLICIT. The original had no animation-range, so it ran
   over the default and a section taller than the viewport sat permanently part-faded: view() percentages
   scale with the ELEMENT, so a 4,469px block stretched the same animation over thousands of pixels of
   scrolling. The range below ends at cover 35%, and the arithmetic that makes that safe is worth writing
   down because it holds for EVERY element at EVERY viewport:

       cover 0%    the element's top edge is at the bottom of the scrollport
       cover 100%  the element's bottom edge is at the top of the scrollport
       total travel = V + E        (V = viewport height, E = element height)

   Centred on screen means top = (V - E) / 2, which is (V + E) / 2 of travel - exactly cover 50%,
   independently of V and E. So any range ending below 50% is finished before the element reaches the
   middle of the screen, whatever its height. 35% leaves a third of that margin in hand.

   2 - THE RESTING STATE IS PLAIN CSS AND IS ALWAYS VISIBLE. Everything scroll-driven lives inside the
   @supports below, so a browser without view() never sees a rule that could hide anything, and there is
   NO FILL MODE - outside the range the element is simply at rest. That also means it does not pop: at
   cover 0% the element is exactly at the scrollport edge with zero pixels showing, so the animation is
   already at its first frame by the time any of it is visible.

   /!\ animation-duration IS auto AND MUST NOT BECOME A TIME. Written as the `animation` shorthand the
   duration would reset to 0s and the timeline would have nothing to drive. That is why these are all
   longhands. Do not collapse them.

   ⚠️ SCROLLING BACK UP REPLAYS IT. A view() timeline is a scrubber bound to scroll position, not a
   one-shot, so re-entering a section from below runs it again. That is inherent to the mechanism and it
   is the behaviour that was asked for - it is not the bug it looks like. If it ever has to fire once
   only, the answer is an IntersectionObserver and a class, and the feel goes with it.

   X .rates, .zipsearch AND .reviews STAY OUT. Each holds a positioned child that must escape its section,
   and an animation on transform or opacity CREATES A STACKING CONTEXT that traps it - that is what put
   the zip suggestion panel under the footer. The :not() chain enforces it in the selector now that there
   is no script holding a blocklist. */
/* /! TWO NUMBERS DECIDE HOW MUCH THIS IS FELT, AND THE ONE THAT MATTERS IS THE RATIO BETWEEN THEM.
   A scroll-driven animation is perceived by how far it travels per pixel SCROLLED, not by its distance
   alone. At 32px spread over a range of about 400px of scrolling that was 0.08px of movement per pixel
   of scroll - arithmetically present, invisible in use, which is exactly how it was described: the
   travel is there but it takes so long to cover it that nothing reads as moving.
   The scale, measured on a 844px screen against a 100px paragraph:
     32px over cover 50%  = 0.068  present arithmetically, invisible in use
     46px over cover 38%  = 0.128  where it sits now
     56px over cover 32%  = 0.185  too eager, and over too short a scroll
   Raise the distance or shorten the range to go further; they pull the same lever from opposite ends,
   and the range is also what decides whether it feels hurried - a short range is a fast one. Do not take the range past cover 50%, which is where the element is centred
   on screen - past that it is still fading in while the reader is already reading it. */
@keyframes lc-rise{ from{ opacity:0; transform:translateY(46px) } }

@supports (animation-timeline: view()){
  @media (prefers-reduced-motion: no-preference){

    /* == WHOLE SECTIONS — bounded by cover 35% ======================================================
       These are single elements with nothing to come apart, and several are taller than a phone
       viewport, so they keep the bounded range: centred is always cover 50% (travel is V+E and travel
       to centred is (V+E)/2, so the ratio is exactly a half whatever the sizes), and 35% therefore
       always finishes first. */
    /* /!\ .cta-band IS SAFE FOR THE SAME REASON THE BODY BUTTONS ARE. It holds a pulse dot, and a
       running animation here makes a stacking context - but that dot is absolutely placed INSIDE its own
       position:relative .online-indicator and never needs to escape the section. That containment is the
       whole difference between it and .zipsearch, whose panel does. */
    .reveal:not(.rates):not(.zipsearch):not(.reviews),
    .cta-band,
    .areas,
    .samearea{
      animation-name:lc-rise;
      animation-duration:auto;
      animation-timing-function:linear;
      animation-fill-mode:none;
      animation-timeline:view();
      animation-range:cover 0% cover 35%;
    }

    /* == THE BODY COPY — EVERY ELEMENT ON ITS OWN TIMELINE ==========================================
       ⛔ DO NOT GROUP THESE BACK TOGETHER. A previous attempt wrapped each section in one box so it
       would move as a unit, on the reasoning that a section coming apart looked like fidgeting. That
       reasoning was wrong and the grouping is the exact thing that killed the effect: an 800px section
       has one timeline driven by its OWN top edge, so it finished while its lower half was still below
       the fold, and every paragraph after the first arrived already opaque. The coming apart IS the
       effect. Each element has to stay faded until it personally rises off the bottom edge.

       ⛔ IT ENDS AT THE MIDDLE OF THE SCREEN, NOT AT THE BOTTOM EDGE. This was entry 0% -> entry 100%,
       and entry 100% means the element's BOTTOM edge reaching the bottom of the scrollport - so it
       completed the instant the element had cleared the fold, which for a 43px heading is almost
       immediately. Everything arrived at full opacity while still sitting in the bottom inch of the
       screen, which is the opposite of the effect: the element should stay faded through the whole
       lower half and come up as it rises.

       /!\ cover 50% IS "CENTRED", EXACTLY, FOR EVERY ELEMENT AT EVERY VIEWPORT. cover travel is V + E
       and travel to centred is V - (V-E)/2 = (V+E)/2, so the ratio is one half whatever the sizes are.
       That is what makes a single number correct here where no single number could express "cleared the
       bottom edge" - that one is E/(V+E), 6% for a 43px heading and 43% for a 231px picture.

       /!\ AND IT IS NOW HEIGHT-SAFE, WHICH entry 100% WAS NOT. entry 100% ran past the middle of the
       page for anything taller than the viewport - the tall-element fault one level down, held off only
       by the 3x margin between the tallest child (231px at 375x700) and the screen. Ending at centred
       has no such dependency: it is the same fraction of travel at every size.

       ⚠️ entry 0% AND cover 0% ARE THE SAME INSTANT - both are the element's top edge at the scrollport's
       end edge - so this is equivalent to cover 0% -> cover 50%. It is written with entry because that
       is what the start means here, and because reading it as "from the moment it appears, to centred"
       is how anyone tuning it will think about it.

       /!\ .wrap > *, NOT .prose > *. .prose has exactly one child, the width wrapper, and animating
       that would put the entire 4,700px body on one timeline — which is the original bug verbatim. */
    /* /!\ BOTH SHAPES, BECAUSE THE OLD SHAPE IS ALREADY PUBLISHED. A previous publish shipped a
       script that wrapped each section in a .lc-group div. That script is deleted, but the HTML carrying
       it is live on 870 pages until the next publish — and against that markup '.wrap > *' matches the
       six WRAPPERS instead of the 29 children, which is the grouped behaviour this change exists to
       remove. Matching both shapes makes the stylesheet correct before and after that publish, and the
       second selector simply stops matching once the wrappers are gone. */
      /* ⛔ .rubrics REVEALS PER CARD, NOT AS ONE BLOCK. It is a direct child of .wrap, so the grid of
         four matched as a SINGLE element — about 2000px tall on a phone — and "entry 0% cover 38%" is a
         range solved for a paragraph. An element that tall finishes rising long after its first card is
         already being read, so the top card sat faded while it was being looked at and the ones below
         were finished before they arrived. Alon: "I can barely see this first image".
         /! THE SAME TRAP AS .lc-group, AND ANY FUTURE GRID IS THE SAME ONE. If a child of .wrap is
         taller than roughly one screen, exclude it here and animate ITS children instead. */
      .prose .wrap > *:not(.lc-group):not(.rubrics),
      .prose .rubrics > *,
      .prose .lc-group > *{
      animation-name:lc-rise;
      animation-duration:auto;
      animation-timing-function:linear;
      animation-fill-mode:none;
      animation-timeline:view();
      animation-range:entry 0% cover 38%;
    }
  }
}

/* X THIS WAS DELETED BY ACCIDENT AND NOBODY NOTICED FOR TWO DAYS. It went out in 678f267 with the
   scroll-reveal block - it sat inside the region that was spliced away, and the RULE that uses it,
   .phone-ring{animation:...ring}, survived. A rule naming a keyframe that does not exist is silent: no
   error, no warning, and getComputedStyle still reports animationName:ring, which is why a check for
   running animations looked fine. The handset simply stopped ringing everywhere on the site.
   /! IF YOU EVER SPLICE A COMMENT BLOCK OUT OF THIS FILE, CHECK WHAT WAS INSIDE IT. */
@keyframes ring{
  0%,43%,100% { transform:rotate(0) }
  1%,9%       { transform:rotate(19.5deg) }
  3%,11%      { transform:rotate(-18.2deg) }
  5%          { transform:rotate(22.1deg) }
  7%          { transform:rotate(-20.8deg) }
  13%         { transform:rotate(16.9deg) }
  15%         { transform:rotate(-15.6deg) }
  17%         { transform:rotate(14.3deg) }
  19%         { transform:rotate(-13deg) }
  21%         { transform:rotate(11.7deg) }
  23%         { transform:rotate(-10.4deg) }
  25%         { transform:rotate(9.1deg) }
  27%         { transform:rotate(-7.8deg) }
  29%         { transform:rotate(6.5deg) }
  31%         { transform:rotate(-5.2deg) }
  33%         { transform:rotate(3.9deg) }
  35%         { transform:rotate(-2.6deg) }
  37%         { transform:rotate(1.3deg) }
  39%         { transform:rotate(-0.7deg) }
  41%         { transform:rotate(0.7deg) }
}

@keyframes pulsate{
  0%   { transform:scale(1);   opacity:.7 }
  50%  { transform:scale(2.5); opacity:0 }
  100% { transform:scale(1);   opacity:0 }
}
@media (prefers-reduced-motion: no-preference){
  .phone-ring{ animation:5s ease-in-out 2s infinite ring }
  .online-indicator .pulse{ animation:pulsate 2s infinite }

  /* == THE BUTTONS DO NOT ALL RING AND PULSE ON THE SAME BEAT ========================================
     ⛔ THEY WERE PERFECTLY IN STEP, WHICH IS WHY IT LOOKED WRONG. Every copy of these two animations
     shares one duration and one delay and they all start at page load, so the header chip and the first
     button in the body wiggled and pulsed on the identical frame. Two identical things moving together
     read as one mechanism twitching, not as two live buttons.

     /!\ THE STICKY BAR WAS ALREADY OFF-BEAT BY ACCIDENT, AND THAT IS WHY IT LOOKED FINE. It is
     display:none until you scroll past 420, and an animation on a display:none element does not start -
     so its clock begins whenever the bar first appears. That accident is the effect being made
     deliberate here.

     /!\ POSITIVE DELAYS FOR THE RING, NEGATIVE FOR THE PULSE, AND THE DIFFERENCE MATTERS. The ring is a
     once-every-five-seconds event, so shifting when it starts is all that is needed. The pulse is
     continuous, and a positive delay would leave every dot dead for the first second or two after load -
     a negative one starts it already part-way round, so it is out of phase immediately with nothing
     missing. The values are deliberately not multiples of each other, or they would drift back into
     step.

     /!\ THE HEADER KEEPS THE DEFAULTS. It is the reference everything else is offset FROM, so it needs
     no rule and gains none. */
  [data-cta="hero"]   .phone-ring{ animation-delay:3.6s }
  [data-cta="mid"]    .phone-ring{ animation-delay:5.1s }
  [data-cta="body"]   .phone-ring{ animation-delay:4.3s }
  [data-cta="sticky"] .phone-ring{ animation-delay:2.9s }

  [data-cta="hero"]   .online-indicator .pulse{ animation-delay:-.7s }
  [data-cta="mid"]    .online-indicator .pulse{ animation-delay:-1.3s }
  [data-cta="body"]   .online-indicator .pulse{ animation-delay:-.4s }
  [data-cta="sticky"] .online-indicator .pulse{ animation-delay:-1.6s }

  /* The three body buttons and the three mid buttons each share a data-cta, so without this they would
     be in step with each other even while offset from the header. A per-instance nudge keeps siblings
     apart too; :nth-of-type is on the ANCHOR, so it counts buttons rather than icons. */
  /* ⛔ BY INDEX, NOT BY :nth-of-type. Each copy of the call band is alone inside its own .call-stack, so
     :nth-of-type(1) matched every one of them and they all kept the same delay. data-cta-n is stamped in
     renderPage() and counts down the page.

     /!\ THE SECOND BAND TAKES THE HEADER'S OWN 2s, WHICH IS DELIBERATE. It is the "On Call 24/7" block
     low on the page and the header is at the very top, so the two are never on screen together — while
     the first band, which IS near it, is four seconds away. Alon: "the footer one... should have this
     same timing as the header one." */
  [data-cta-n="2"] .phone-ring{ animation-delay:2s }
  [data-cta-n="3"] .phone-ring{ animation-delay:6.4s }
  [data-cta-n="2"] .online-indicator .pulse{ animation-delay:-2.1s }
  [data-cta-n="3"] .online-indicator .pulse{ animation-delay:-.9s }
}


/* ── Cross-document view transitions ────────────────────────────────────────────────────────────────────
   Two lines, no JavaScript, and already scoped to within-an-area by the link graph rather than by any
   rule here: an area page's only links are its own children and the footer's legal pages. There is no
   link to another city to transition across.

   ⛔ DO NOT GIVE THE HERO A SHARED view-transition-name. Morphing a full-bleed image between pages is the
   version that demos well and costs real milliseconds on a phone. The default cross-fade cannot hurt. */
/* ⛔ THIS IS THE CROSS-DOCUMENT FORM, AND THAT IS WHAT MAKES IT SAFE FOR THE CALL TRACKING.
   navigation:auto does not intercept a navigation. The browser still unloads, fetches, parses and RUNS
   the new document, then cross-fades two snapshots — so the Google phone snippet re-runs every time with
   that page's own number and label.

   ⛔ NEVER WRAP A LINK IN document.startViewTransition(). That makes the navigation SAME-document: the
   old document stays, its head is never re-parsed, and the gtag config from the page the visitor arrived
   on keeps running. A visitor who lands on Chicago and clicks through to a sibling would then see
   Chicago's number on a page whose conversion label is something else — the number goes stale and the
   label stops matching, which is exactly the silent misattribution verify-head.mjs exists to catch and
   could not catch, because the served HTML would still be correct.

   There is no router, no pushState and no startViewTransition in this project, and there must not be. */
@view-transition{ navigation:auto }
@media (prefers-reduced-motion: reduce){ @view-transition{ navigation:none } }

/* ⛔ NOTHING IS NAMED, AND ON A PAGE THAT SCROLLS NOTHING CAN BE. THIS WAS TRIED THREE TIMES.

   A view transition captures the old element at its position IN THE VIEWPORT at the moment of the
   click, and animates from there to wherever the new page puts it. The header is position:static, so
   where it was captured is a function of how far the reader had scrolled — not of the markup, and not
   of anything CSS can equalise.

   Measured on the real journey. A sibling link is at the BOTTOM of an area page:
       old snapshot, area page scrolled to its foot   −6205px
       new page, which always begins at scroll 0           0px
       the group therefore animates 6205px, downward — "sliding in from the top"

   ⛔ THE EARLIER MEASUREMENT WAS WRONG IN METHOD, NOT MERELY INCOMPLETE. It compared both headers at
   scroll 0 and found them identical to two decimal places at 24 width/page combinations, which is a
   true statement about a condition the transition never captures. Nobody clicks a sibling link from
   the top of the page.

   ⛔ DO NOT ADD A FOURTH ATTEMPT. There is no arrangement of names, positions or ranges that makes a
   statically-positioned element hold still across a navigation from an arbitrary scroll offset. The
   only version that never misbehaves is the plain cross-fade below, and a cross-fade cannot move
   anything. If a named element is ever genuinely wanted, the precondition is that it is position:fixed
   or sticky so its viewport position is the same on both sides — and that has to be measured AT A
   SCROLLED STATE, not at the top. */

/* ⛔ REDUCED MOTION STOPS THE PSEUDO-ELEMENT ANIMATIONS TOO. navigation:none above prevents the
   transition being started at all in browsers that honour it; this is the belt for the ones that have
   already begun one. Without it a named element still slides between positions. */
@media (prefers-reduced-motion: reduce){
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*){ animation:none !important }
}


/* ── TOWN AND NEIGHBOURHOOD NAMES ───────────────────────────────────────────────────────────────────────
   The names are what make 870 location pages read as real rather than templated. They are set quietly —
   this is information the reader scans for their own town, not a keyword block asking to be noticed. */
/* ⛔ --dim WAS TOO WEAK OVER A PHOTOGRAPH AND HAD NO SHADOW. It is the on-dark secondary colour, chosen
   against a flat band; over a bright sky at 14px it disappeared, which is what "you can barely read what's
   going on here" was pointing at. Every piece of hero type carries the shadow now — the first pass gave it
   only to the headline, subtitle, H1 and urgency line, and left this one and the ticks bare. */
.hero-serving{margin:0;color:var(--hi);font-size:14px;line-height:1.5;max-width:46ch;opacity:.92}
/* /!\ CENTRED WITH THE REST OF THE STACK, WHICH IS WHY max-width NEEDS margin-inline:auto BESIDE IT.
   46ch is narrower than the hero column, so without this the line sits hard against the left edge of a
   band where everything else is centred — the same correction .hero-head and .hero-city already carry.
   /!\ :empty IS A GUARD, NOT A TIDY-UP. [opt] drops the paragraph where there are no towns, but a page
   published BEFORE this change still carries the old markup, and an empty <p> with a 46ch box would
   push the call button down on every one of them until a publish catches up. */
.hero-centred .hero-serving{margin-inline:auto;text-align:center}
.hero-serving:empty{display:none}
/* ⛔ --body, NOT --slate, AND THAT IS A MEASUREMENT RATHER THAN A RESTYLE. --slate was right where this
   line used to sit — in a left column, over the flat end of the scrim. Restored under the subtitle it
   lands at 40% down and 30-70% across, which is the middle of the fade over the photograph, and it is
   the smallest type in the band at 14px. Composited over the real frame it measured 3.25:1 at 950px,
   3.79:1 at 1280 and 4.44:1 at 1920, all against a 4.5:1 bar. --body clears it at 6.93 / 8.09 / 9.47
   and is the colour the subtitle directly above it already uses, so the two read as one block.
   Darkening the type is the fix; thickening the scrim would have bought the number by hiding the
   photograph, which is the mistake this band already made once. */
body.band-light .hero-serving{color:var(--body);opacity:1}
.metro-line{color:var(--slate);font-size:15px;margin:0 0 14px}
.metro-line:empty{display:none}
.area-note{color:var(--slate);font-size:14px;margin:0}
.area-more{margin:12px 0 0;font-size:13px}
.area-more a{color:var(--brand-deep)}

/* One sub-group per county. The county name is a real subheading because it is itself a searched term —
   "locksmith essex county nj" — not decoration. */
/* ── THE HOME PAGE'S FOUR ROUTES ────────────────────────────────────────────────────────────────────
   Four tiles, and the whole tile is the anchor. The home page exists to hand somebody to one of these
   four pages, so the route has to be the most visible thing on it.

   ⛔ THE PICTURE IS NOT CROPPED TO A STRIP HERE. Everywhere else on the site these panoramas are cut to
   a wide band and pushed right, because they sit behind or beside text. In a tile the picture IS the
   content, so it gets a 16:9 box and its own focus point, and nothing has to survive a scrim.

   /!\ ONE FOCUS RING ON THE WHOLE TILE, not one per element. The <a> wraps the picture, the heading, the
   list and the go-line, so a keyboard lands on the card once and moves on. */
.rubrics{display:grid;gap:16px;margin:18px 0 28px}
/**
 * ⛔ THREE STEPS, BECAUSE THERE ARE NINE CARDS. Two-up leaves a lone card on a row of its own at every
 * width above 700px — nine is not even, so the last one always sits by itself looking like a mistake.
 * Three-up makes 3x3, which is the only arrangement of nine that comes out square.
 *   >= 1300   3 x 3
 *   700-1299  2 x 4 + 1
 *   < 700     1 x 9
 * Alon set the thresholds from the rendered page rather than from theory: "somewhere around 1300-1500,
 * and the 9x1 is already right at 699".
 */
/* ⛔ FIVE CARDS, NOT NINE, SO NEITHER ROW COUNT DIVIDES EVENLY — AND A LONE CARD ON ITS OWN ROW READS
   AS A MISTAKE. The four search-term pages left the grid (see generic-home.html), so the arithmetic
   changed: two-up leaves 2+2+1 and three-up leaves 3+2. Both strand something.
   /!\ SO THE TRACK COUNT IS A MULTIPLE AND THE CARDS SPAN IT. At two-up the grid has four tracks and a
   card spans two; the fifth spans all four and runs full width, which looks deliberate rather than left
   over. At three-up the grid has six tracks, the first three span two each, and the last two span three
   each — a wider pair closing the block instead of a gap on the right. */
@media (min-width:700px){
  .rubrics{grid-template-columns:repeat(4,minmax(0,1fr));gap:20px}
  .rubrics>.rubric{grid-column:span 2}
  .rubrics>.rubric:last-child{grid-column:span 4}
}
/* ⛔ NO THREE-UP INSIDE THIS CONTAINER, AND THE REASON IS ARITHMETIC RATHER THAN TASTE.
   The wrap is 1180px. Two-up gives each card (1180-20)/2 = 580px; three-up gives (1180-44)/3 = 379px.
   Three across is ALWAYS narrower per card here — to match 580px at three the container would have to be
   about 1784px. Alon asked for the cards to look WIDER on a big screen and the three-up step made every
   one of them a third smaller. Widening the container is the only thing that makes cards wider; the
   column count cannot. */

/* ⛔ SO THE CONTAINER IS WIDENED, AND ONLY FOR THIS ONE GRID.
   Nine cards is 3x3 or nothing — two-up always strands the ninth on a row by itself. The step is at
   1500px, and the container is FLUID above it — it grows with the viewport to a 1784px ceiling, where a
   card reaches the same 580px it has at two-up.
   /!\ 1824 WAS THE FIRST THRESHOLD AND IT WAS TOO HIGH. That is the exact width where a three-up card
   stops being SMALLER than a two-up one, so it was the mathematically safe answer — and it made the
   layout snap from a full three-across straight to two the moment the window lost a single pixel. Alon:
   "im even bummed that it swaps to 2 columns too quick... why dont they shrink a little first."
   /!\ SO THE FLOOR IS A CARD WIDTH, NOT A CLEVER NUMBER. At 1500px viewport a card is 472px; the version
   that was rejected for looking shrunken was 379px, which is what three-up gives inside the 1180 wrap.
   472 is comfortably clear of it and 1500 covers most real laptops and monitors. Below that it falls back
   to two-up rather than getting thin, and 1180-wide readers see nothing different either way.
   /!\ .rubrics BREAKS OUT OF .wrap RATHER THAN .wrap GETTING WIDER. Everything else on the page — prose
   at 860px, the rate table, the coverage lists — is set against 1180 and would have to be re-checked if
   the wrap itself moved. One section escaping its container is a smaller claim than a new page width. */
@media (min-width:1500px){
  .rubrics{
    grid-template-columns:repeat(6,minmax(0,1fr));
    width:min(1784px, calc(100vw - 40px));
    margin-left:calc(50% - min(1784px, calc(100vw - 40px)) / 2);
  }
  .rubrics>.rubric{grid-column:span 2}
  .rubrics>.rubric:nth-child(4),
  .rubrics>.rubric:nth-child(5){grid-column:span 3}
}

/* ⛔ THE SEARCH-TERM LINKS. Deliberately not cards, not photographed, and not a heading — they are four
   ways of naming the same five services, and the whole point of moving them here was that they should
   not look like a sixth and seventh thing you can book.
   /!\ IT STILL HAS TO LOOK CLICKED-ON-ABLE. Demoted is not the same as hidden: these pages carry a large
   share of the organic impressions and this row is their internal link from the most linked page on the
   site. Link colour, a real hover, and a 44px tap target — just no picture and no border. */
/* /!\ NO BOX, AND IT MUST NOT WRAP. Boxed, it read as a fourth card sitting under the five — the exact
   "these are also services" impression the whole change was undoing. And it wrapped because it sits in
   the 860px prose column while the grid above breaks out wider, so "Mobile locksmith" dropped to a line
   of its own and looked like an afterthought. Alon: "don't make them wrap and remove that border."
   /!\ SO IT TAKES THE GRID'S WIDTH, not the prose column's. Same breakout as .rubrics, which is also why
   it lines up under the cards instead of being narrower than them. */
.rubric-terms{
  display:flex;flex-wrap:nowrap;align-items:center;justify-content:center;
  gap:0 16px;margin:-2px 0 32px;
  width:min(1180px, calc(100vw - 40px));
  margin-left:calc(50% - min(1180px, calc(100vw - 40px)) / 2);
  margin-bottom:32px;
}
.rubric-terms a{white-space:nowrap}
.rubric-terms-label{
  font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3);
  font-weight:600;margin-right:2px;
}
.rubric-terms a{
  display:inline-flex;align-items:center;min-height:44px;padding:0 2px;
  font-size:15px;color:var(--brand-deep);text-decoration:none;
}
@media (hover: hover) and (pointer: fine){
  .rubric-terms a:hover{text-decoration:underline}
}
/* /!\ A SEPARATOR THAT IS NOT A CHARACTER IN THE LINK. Putting "·" inside the <a> makes it part of the
   link text for a screen reader and part of the tap target for a thumb. */
.rubric-terms a + a::before{
  content:"";width:3px;height:3px;border-radius:50%;background:var(--rule);
  margin-right:16px;flex:none;
}
/* /!\ THE PHONE IS THE ONE PLACE IT STACKS, and that is not the wrap being fixed above — four links on
   one line at 375px would either overflow or be unreadably small. A column of four taps is right there. */
@media (max-width:699px){
  .rubric-terms{
    flex-direction:column;align-items:flex-start;flex-wrap:wrap;gap:0;
    width:auto;margin-left:0;
  }
  .rubric-terms a + a::before{display:none}
}

.rubric{
  display:flex;flex-direction:column;overflow:hidden;
  background:var(--card);border:1px solid var(--rule);border-radius:var(--radius);
  text-decoration:none;color:inherit;
  transition:border-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
/* ⛔ THE RUBRIC IMAGES ARE NOT BODY PHOTOGRAPHS. They sit flush against the top inside edge of a card
   that has its own border and its own radius, so they take neither the .prose margin nor the rounding —
   a rounded image inside a rounded card leaves four visible slivers of card behind its corners, and a
   top margin leaves a strip of card above it. Alon: "these are in a container there that wont look good
   if they will have border radius there." */
.rubric picture{margin:0;display:block;background:var(--hi)}

.rubric picture img{
  /* ⛔ border-radius:0 IS AN OVERRIDE AND MUST BE WRITTEN, NOT OMITTED. These images are inside .prose,
     so `.prose picture img{border-radius:16px}` matches them too and at equal specificity the later rule
     wins — deleting the radius from THIS rule simply let the .prose one through, which is exactly what
     Alon saw after being told it was fixed. It has to say zero out loud. */
  display:block;width:100%;height:auto;aspect-ratio:16/9;border-radius:0;border:0;
  object-fit:cover;object-position:var(--focus,50%) center;
}
.rubric-body{padding:16px 18px 18px;display:flex;flex-direction:column;gap:9px;flex:1}
.rubric-body h3{margin:0;font-size:19px;line-height:1.25;color:var(--ink)}
.rubric-body p{margin:0;font-size:15px;line-height:1.55;color:var(--ink-3);max-width:none}
.rubric-body .tick{margin:0;font-size:14.5px;gap:5px}
.rubric-body .tick li{color:var(--ink-2)}

/* The go-line is the only part that looks like a link, because the whole card is one. */
.rubric-go{
  margin-top:auto;padding-top:4px;
  font-family:'Geist',sans-serif;font-weight:650;font-size:14.5px;color:var(--action);
}
.rubric-go::after{content:" \2192";font-weight:400}

@media (hover:hover) and (pointer:fine){
  .rubric:hover{border-color:var(--action);box-shadow:0 6px 22px rgba(var(--ink-rgb),.09);transform:translateY(-2px)}
  .rubric:hover .rubric-body h3{color:var(--action)}
}
.rubric:focus-visible{outline:2px solid var(--action);outline-offset:2px}
@media (prefers-reduced-motion:reduce){ .rubric{transition:none} .rubric:hover{transform:none} }

/* ── THE FAQ ────────────────────────────────────────────────────────────────────────────────────────
   <details> rather than a scripted accordion: it opens with JavaScript off, it is keyboard operable and
   screen-reader announced with no aria at all, and the closed answers are still in the markup, so they
   are indexed normally.

   ⛔ NO BOXES, NO CARDS, NO ALTERNATING FILLS. That is what makes an FAQ look like 2012. What reads as
   current is hairline rules, a lot of vertical air, question type big enough to be the thing you scan,
   and one quiet chevron. The restraint IS the design here.

   /!\ THE OPEN/CLOSE ANIMATION NEEDS interpolate-size, WHICH IS THE ONLY WAY TO TRANSITION TO auto.
   height:auto has never been animatable; every accordion before this either used JS to measure the
   content or faked it with max-height and a guessed ceiling that clips long answers. ::details-content
   plus interpolate-size does it in CSS with no measuring and nothing to clip. Where it is unsupported
   the panel simply appears — the @supports block below is additive, never required. */
.faq{padding:52px 0}
.faq .eyebrow{display:block}
.faq h2{margin:6px 0 22px}   /* no max-width: 22ch clamped the longer heading to 366px and wrapped it four ways */

/* ⛔ 820px IS THE BODY PHOTOGRAPH'S COLUMN, NOT A ROUND NUMBER SOMEBODY LIKED. Measured at 1440: the
   body paragraph is 766, the body images are 820, and these rows were running to 1140 — 320px wider than
   anything else on the page, which is what threw the chevron out to arm's length on a wide screen.
   Matching an existing column means the FAQ lines up with the pictures above it instead of inventing a
   third measure. */
/* ⛔ THE FAQ SHARES .prose's WRAP, IT DOES NOT BUILD ITS OWN. Capping .faq-list at 820 gave the right
   WIDTH and the wrong POSITION: .prose .wrap is min(860px, var(--wrap)) and centred, .faq .wrap was the
   full 1180, so an 820 list left-aligned inside it started 160px left of the body copy it follows.
   Measured at 1440 — prose image 303..1123, faq list 143..963. One line fixes both. */
.faq .wrap{max-width:min(860px, var(--wrap))}
.faq-list{border-top:1px solid var(--rule,rgba(10,18,24,.13))}
.faq-item{border-bottom:1px solid var(--rule,rgba(10,18,24,.13))}

.faq-item>summary{
  list-style:none;cursor:pointer;position:relative;
  padding:19px 44px 19px 0;
  font-family:'Geist',sans-serif;font-weight:640;font-size:18.5px;line-height:1.38;
  color:var(--ink);
  transition:color .16s ease;
}
.faq-item>summary::-webkit-details-marker{display:none}
.faq-item>summary:hover{color:var(--action)}
.faq-item[open]>summary{color:var(--action)}
.faq-item>summary:focus-visible{outline:2px solid var(--action);outline-offset:4px;border-radius:4px}

/* the chevron — two borders rotated, so there is no icon file and no glyph that can go missing */
.faq-item>summary::after{
  content:"";position:absolute;right:8px;top:26px;
  width:9px;height:9px;
  border-right:2.5px solid currentColor;border-bottom:2.5px solid currentColor;
  border-radius:1px;opacity:.5;
  transform:translateY(-2px) rotate(45deg);
  transition:transform .22s cubic-bezier(.32,.72,0,1),opacity .16s ease;
}
.faq-item>summary:hover::after{opacity:1}
.faq-item[open]>summary::after{transform:translateY(2px) rotate(225deg);opacity:1}

.faq-a{padding:0 44px 22px 0}
.faq-a p{margin:0;max-width:66ch;font-size:17px;line-height:1.62;color:var(--ink-3)}

/* Smooth height. Additive: without support the answer just appears, which is the old behaviour. */
@supports (interpolate-size: allow-keywords){
  :root{interpolate-size:allow-keywords}
  .faq-item::details-content{
    height:0;overflow:hidden;opacity:0;
    transition:height .28s cubic-bezier(.32,.72,0,1),opacity .22s ease,content-visibility .28s allow-discrete;
    content-visibility:hidden;
  }
  .faq-item[open]::details-content{height:auto;opacity:1;content-visibility:visible}
}

@media (max-width:759.98px){
  .faq{padding:38px 0}
  .faq-item>summary{font-size:16.5px;padding:16px 34px 16px 0}
  .faq-item>summary::after{top:22px;right:4px;width:8px;height:8px}
  .faq-a{padding:0 34px 18px 0}
  .faq-a p{font-size:16px}
}

@media (prefers-reduced-motion:reduce){
  .faq-item>summary,.faq-item>summary::after{transition:none}
  @supports (interpolate-size: allow-keywords){
    .faq-item::details-content{transition:none}
  }
}

.county-groups{display:flex;flex-direction:column;gap:20px;margin-top:14px}
/* h4 since the list title dropped to h3 — all three kept so an older published page keeps its styling */
.county-group h2,.county-group h3,.county-group h4{font-size:16px;margin:0 0 2px;color:var(--ink)}
.county-group .area-list{margin-top:4px}
.area-count{font-size:12px;color:var(--slate);margin:0 0 4px;font-variant-numeric:tabular-nums}
@media (min-width:760px){
  .county-groups{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 32px}
  .county-groups.full{grid-template-columns:1fr}
}

/* The Service Areas sibling: a reference page, not a landing page. */
.service-areas{padding-top:26px}
.sa-h1{font-size:clamp(24px,6vw,32px);margin:2px 0 12px;letter-spacing:-.015em}
.service-areas .area-list{font-size:13px;line-height:1.9}

/* ══ PRESS FEEDBACK IS A POINTER STATE TOO, AND I HAD THIS WRONG ═════════════════════════════════════

   ⛔ :active IS GATED EXACTLY LIKE :hover, AND THE COMMENT THAT USED TO SIT HERE ARGUED THE OPPOSITE.
   It said ":active is real with a mouse too, so these apply everywhere" — true about a mouse, and it
   quietly assumed the finger case was harmless. It is not. iOS applies :active on touch-DOWN, before it
   can know whether the gesture is a tap or the first millimetre of a scroll, so a finger travelling over
   the call button on its way down the page lights it up. That is the same symptom the hover gate was
   added to remove, arriving through the other door.

   ⛔ AND A PHONE NEEDS NO CONFIRMATION FROM THIS BUTTON. It dials. The device leaving the page and
   opening the dialler is the feedback, and it is unambiguous in a way a 140ms colour change is not.
   There is nothing for a press state to tell somebody here that the phone is not about to tell them
   louder.

   ⚠️ A MOUSE STILL GETS IT. Inside the gate, :active does what it has always done on a pointer device —
   a button that does not react to mousedown reads as broken. The gate is the same one the hover rules
   use, evaluated per input rather than per device, so a phone with a mouse attached gets press feedback
   and the same phone touched gets none.

   /!\ -webkit-tap-highlight-color IS PART OF THIS, NOT A SEPARATE TIDY-UP. Left unset, iOS draws its
   own grey flash over the whole element on touch-down — same trigger, same timing, same wrong answer,
   and no amount of gating :hover or :active touches it. It is transparent on the four call surfaces and
   ONLY on those. Ordinary links keep it: a footer link navigates rather than dials, that takes a moment,
   and the flash is the only thing telling a thumb it landed. */
@media (hover: hover) and (pointer: fine){
  .head-call:active,.call-btn:active{background:var(--action-hover)}
  .hero-tick:active .hero-tick-mark .disc{fill:var(--brand)}
}
.head-call,.call-btn{-webkit-tap-highlight-color:transparent}


/* ── THE CONTACT PAGE ────────────────────────────────────────────────────────────────────────────────
   Two lines and nothing else on the page. Left-aligned they read as the start of an article that never
   arrives; centred they read as what they are — a heading and a number. */
.prose.contact-lead{text-align:center}
.prose.contact-lead h2{margin-bottom:10px}
.prose.contact-lead .call-line{margin:0}

/* ══ THE GEOGRAPHY HUBS ══════════════════════════════════════════════════════════════════════════════

   ⛔ A HUB IS A LIST AND IT IS STYLED AS ONE. No cards, no icons, no accent rails — 41 state pages and
   69 county pages exist to be scanned for one name and left. Decoration on a page whose whole job is to
   be passed through is decoration that slows the pass-through down.

   ⚠️ THE COLUMNS COME FROM THE VIEWPORT, NOT FROM A COUNT. Illinois lists 5 links and California lists
   47; a fixed column count leaves one of them ragged. auto-fill with a min track means both fill the
   width they have and neither needs a special case. */
.hub{padding:22px 0 8px}
.hub h1{font-size:clamp(26px,6.4vw,38px);margin:6px 0 10px;letter-spacing:-.015em}
.hub-intro{color:var(--slate);margin:0 0 14px}
.hub-group{margin:26px 0 0}
.hub-group h2{font-size:17px;margin:0 0 10px;color:var(--ink)}
.hub-list{list-style:none;margin:0;padding:0;
  display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr));gap:2px 22px}
.hub-list li{padding:5px 0;border-bottom:1px solid var(--rule-2)}
.hub-list a{color:var(--brand-deep);text-decoration:none;font-size:15px}
.hub-list a:hover{text-decoration:underline}
/* ⚠️ The count is secondary information and must never be mistaken for part of the place name. */
.hub-n{color:var(--slate);font-size:12px;margin-left:.5em;font-variant-numeric:tabular-nums}
.hub-list-areas li{padding:7px 0}
.hub-list-areas a{font-weight:600}

/* ══ BREADCRUMBS ═════════════════════════════════════════════════════════════════════════════════════
   ⛔ THEY SIT ABOVE THE H1 AND BELOW THE HEADER, and they only ever name pages that exist — see
   src/render/hub.ts. Small, quiet, and never the first thing the eye lands on. */
.crumbs{padding:12px 0 0}
.crumbs ol{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:0 6px;
  font-size:12.5px;color:var(--slate)}
.crumbs li+li::before{content:"›";margin-right:6px;color:var(--rule)}
.crumbs a{color:var(--slate);text-decoration:none}
.crumbs a:hover{color:var(--brand-deep);text-decoration:underline}
.crumbs [aria-current]{color:var(--ink-3)}

/* ══ THE 404 ═════════════════════════════════════════════════════════════════════════════════════════
   It is a real page with the real nav, because the visitor who reaches it still has a locked door. */
.notfound{padding-top:26px}
.notfound h1{font-size:clamp(24px,6vw,34px);margin:4px 0 14px;letter-spacing:-.015em}
.notfound-links{list-style:none;margin:20px 0 0;padding:0;display:flex;flex-wrap:wrap;gap:8px 10px}
.notfound-links a{display:inline-block;padding:8px 14px;border:1px solid var(--rule);border-radius:999px;
  color:var(--brand-deep);text-decoration:none;font-size:14px;background:var(--card)}
.notfound-links a:hover{border-color:var(--brand);text-decoration:none}

/* A county inside a state hub: its own heading, then its towns. See hubList in src/render/hub.ts —
   a bare link and a count hid every town in the state one click deeper than it needed to be. */
.hub-county{margin:0 0 18px}
.hub-county h3{font-size:15px;margin:0 0 6px;color:var(--ink)}
.hub-county h3 a{color:var(--ink);text-decoration:none}
.hub-county h3 a:hover{color:var(--brand-deep);text-decoration:underline}
.hub-more{margin:6px 0 0;font-size:13px}
.hub-more a{color:var(--brand-deep);text-decoration:none}

/* The footer service row. ⛔ It is NAVIGATION, unlike .foot-links below it, which is required text —
   so it takes link colour and link weight rather than the copyright's grey. See seed/blocks/footer.html
   for why these three pages live here and the other six live in the header. */
.foot-nav{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:4px 8px;
  margin:0 0 14px;font-size:13.5px}
.foot-nav a{color:var(--brand-deep);text-decoration:none}
.foot-nav a:hover{text-decoration:underline}
.foot-nav i{font-style:normal;color:var(--rule)}
/* ⚠️ Six long keyword labels do not fit one desktop row at the old size. The nav gives way, never the
   phone chip — see seed/blocks/header-nav.html. */
.site-nav>a{font-size:14px}
@media (min-width:1100px){.site-nav>a{font-size:14.5px}}

/* ══ THE TOWNS LIST IS NOW LINKS, AND THEY HAVE TO LOOK LIKE LINKS ═══════════════════════════════════
   ⛔ THE QUIET VERSION FAILED. They were body-coloured with a hairline underline, on the reasoning that
   138 names with 13 links among them would otherwise read as a wall of blue. That reasoning optimised
   the wrong thing: Alon looked straight at a block containing thirteen links and said "town links??
   where? theres just one". A link nobody can see is not a link — it earns no click, and the only thing
   the restraint bought was a tidier-looking page nobody could use.
   Brand colour and semibold. The unlinked names stay plain, which is correct: they are places we cover,
   not places with a page. See linkTown in src/render/areas.ts. */
/* ⚠️ EXACTLY .area-more a — colour only, and the default underline left alone. Two shades of "link" in
   one section is how the first two attempts failed: a reader learns what a link looks like from the most
   obvious one on the page, and anything quieter than that stops reading as clickable. Alon: "Don't we put
   links like the link in the More in Elgin link? Make it the same way." */
.area-list a{color:var(--brand-deep)}
.area-list a:focus-visible{outline:2px solid var(--brand);outline-offset:2px;border-radius:2px}
/* The county heading, when the county has a hub to link to. */
.county-group h4 a{color:inherit;text-decoration:none}
.county-group h4 a:hover{color:var(--brand-deep);text-decoration:underline}

/* ══ THE HEADER IS TWO ROWS ON DESKTOP, AND THE PHONE IS PINNED TOP RIGHT ═════════════════════════════

   ⛔ IT WAS ONE ROW AND THE CHIP FELL OFF THE END OF IT. .head-inner is flex-wrap:wrap, so eight keyword
   links plus a phone chip simply wrapped — silently, with no overflow to notice — and the chip, the only
   element on the page that earns money, ended up on the third line under the nav. Alon: "why did it wrap
   down? The call-to-action button should be at the top right corner."

   ⛔ THE GRID IS WHAT MAKES EIGHT LINKS POSSIBLE. Row one is the lockup and the chip; row two is the nav
   with the whole width to itself. Fitting the nav beside the logo is what forced the labels down to
   "Car" and "Home" in the first place, and those labels are the site's most repeated anchor text.

   /!\ SCOPED TO THE WIDTH WHERE THE HAMBURGER DISAPPEARS (885px). Below it the nav is an absolutely
   positioned panel and the header is logo / chip / toggle in one row — untouched by any of this. */
@media (min-width:885px){
  .site-head[data-nav] .head-inner{
    display:grid;
    grid-template-columns:auto 1fr auto;
    grid-template-areas:"brand gap call" "nav nav nav";
    align-items:center;
    row-gap:4px;
  }
  .site-head[data-nav] .brand{grid-area:brand}
  .site-head[data-nav] .head-call-stack{grid-area:call;justify-self:end;max-width:none;flex:none}
  /* ⚠️ order and width:100% are flex-only properties and do nothing in a grid — the area does the work.
     They are overridden rather than deleted because the same element is still a flex child below 885px. */
  /**
   * ⛔ EIGHT KEYWORD LABELS ON ONE ROW, AND THE GAP IS WHAT GIVES WAY.
   *
   * "Commercial Locksmith" wrapped to a second line on any screen, because the nav lives inside .wrap
   * (max 1180px) rather than the viewport — so a 1969px monitor gave it no more room than a 1200px one.
   * The labels cannot shrink further without becoming the generic one-word version that was the whole
   * problem, so the space between them shrinks instead.
   *
   * Measured, not guessed: at 13px the eight fit down to 1147px on a 20px gap. Below that the gap
   * interpolates down to 4px by 885px, where the hamburger takes over and the row stops existing.
   *
   * ⚠️ BETWEEN ROUGHLY 1000px AND 885px THEY STILL WRAP TO TWO LINES, and that is the honest outcome:
   * eight labels of real search terms need about 943px of text, and there is not that much room. Two
   * tidy rows beat eight generic words.
   */
  /**
   * ⛔ THE GAP HAS A FLOOR, AND THE VERSION WITHOUT ONE SHIPPED BROKEN.
   *
   * An interpolated gap that reached 4px meant "Locksmith Near Me 24 Hour Locksmith" ran together with
   * almost no space between them at 899px — eight labels touching, which is worse than any wrap. And the
   * 6px row-gap left the second row sitting on top of the first when it did wrap.
   *
   * A readable gap is not negotiable, so it is fixed at 18px and the row is allowed to wrap when eight
   * labels genuinely do not fit. Two clean rows with 12px between them read as a nav; eight labels
   * jammed edge to edge read as a bug.
   */
  .site-head[data-nav] .site-nav{grid-area:nav;order:0;width:auto;justify-content:flex-start;
    row-gap:12px;column-gap:18px;padding-bottom:2px}
  .site-head[data-nav] .site-nav a{font-size:13px;white-space:nowrap}
}
/* One step down, the eight labels are tight but still fit on their own row. */
/* ⚠️ The old 885-1080 step is gone: the clamp above covers the whole range continuously, and two
   rules fighting over the same gap is how one of them gets forgotten. */

/* ══ THE SOCIAL ICONS ═════════════════════════════════════════════════════════════════════════════════
   ⛔ currentColor, SO ONE RULESET SERVES BOTH THE LIGHT AND DARK HEADER. The header swaps its text colour
   between treatments; an icon with a baked fill would have needed a second palette and would have been
   the thing that got missed on one of them.
   ⚠️ 44x44 MINIMUM TAP TARGET in the mobile panel, where these sit under the links and a thumb has to
   hit one without hitting its neighbour. */
/* ⚠️ THE SVGs ALSO CARRY width/height ATTRIBUTES IN THE MARKUP. This rule is correct and was being
   served, and the icons still came out a couple of pixels wide inside the mobile panel — so the sizing
   no longer depends on the cascade reaching them at all. Belt and braces on the one element whose only
   job is to be recognisable. */
/**
 * ⚠️ THE SIZE IS ALSO AN INLINE style ON EACH <svg>, WHICH IS WHAT ACTUALLY MADE THEM APPEAR.
 * This rule was correct, was served, and DevTools showed it winning — and the glyphs still came out two
 * pixels wide across three separate attempts. An inline style cannot be overridden by anything in the
 * cascade, and on an element whose entire job is to be recognisable that certainty is worth the
 * duplication. Keep both: this rule is what a future edit will look for.
 *
 * ⛔ flex:none ON BOTH, AND THAT IS PART OF THE BUG. An <svg> has a min-content width of ZERO, so as a
 * flex item it is allowed to shrink to nothing — which is exactly what it did inside the mobile panel:
 * five 44px boxes each containing a two-pixel smudge. width:18px was set, was served, and was overridden
 * by flex-shrink every time. Alon saw it three times before it was found.
 *
 * ⚠️ THE MARKUP ALSO CARRIES width="18" height="18". Neither alone was enough; together nothing in the
 * cascade or the layout can collapse them again.
 */
.nav-social svg,.foot-social svg{width:18px;height:18px;flex:none;display:block;fill:currentColor}
.nav-social a,.foot-social a{display:inline-flex;align-items:center;justify-content:center;
  width:32px;height:32px;flex:none;border-radius:8px;color:inherit;text-decoration:none}
/* ⛔ NO BACKGROUND ON HOVER. A tinted pill behind a 18px glyph reads as a button someone forgot to
   finish, and five of them in a row look like a toolbar. Alon: "no background hover" — the colour
   change alone is the whole affordance. */
/**
 * ⛔ MONOCHROME AT REST, THE PLATFORM'S OWN COLOUR ON HOVER. Alon asked whether they could be "colorful
 * to their platform" — five saturated brand colours sitting permanently in a header would pull the eye
 * away from the phone chip, which is the only element on the page that earns money. On hover they are
 * unmistakably the platform, which is the moment the colour is actually useful.
 *
 * ⚠️ STILL NO BACKGROUND. Alon, twice: "no background hover". The fill is the whole affordance.
 */
.nav-social a:hover,.foot-social a:hover{background:none}
.nav-social a[aria-label="Facebook"]:hover,.foot-social a[aria-label="Facebook"]:hover{color:#1877F2}
.nav-social a[aria-label="Instagram"]:hover,.foot-social a[aria-label="Instagram"]:hover{color:#E1306C}
.nav-social a[aria-label="Pinterest"]:hover,.foot-social a[aria-label="Pinterest"]:hover{color:#E60023}
.nav-social a[aria-label="X"]:hover,.foot-social a[aria-label="X"]:hover{color:var(--ink)}
.nav-social a[aria-label="YouTube"]:hover,.foot-social a[aria-label="YouTube"]:hover{color:#FF0000}
.nav-social a:focus-visible,.foot-social a:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
.foot-social{display:flex;justify-content:center;gap:6px;margin:0 0 14px;color:var(--slate)}
@media (max-width:884px){
  /* ⛔ CENTRED, AND WITHOUT THE RULE THE NAV LINKS CARRY. Inside the panel these are <a>s in .site-nav,
     so they inherited `border-bottom:1px solid var(--rule-2)` from the link rows and each icon sat over a
     short line that read as a stray underline. Alon: "its a border bottom they took from site-nav a. u
     need to gate it." A social row is not a menu row and must not look like one. */
  .site-nav .nav-social{display:flex;flex-wrap:nowrap;justify-content:center;gap:10px;padding:14px 16px 16px}
  /** ⚠️ flex:none again — the panel is narrow and these must not be squeezed. 44px is the tap target. */
  .site-nav .nav-social a{width:44px;height:44px;flex:none;border-bottom:0;padding:0;min-height:0}
}

/* ══ THE HEADER STICKS ON DESKTOP ONLY ═══════════════════════════════════════════════════════════════

   ⛔ NOT ON MOBILE, AND THAT IS THE WHOLE DECISION. The sticky call button already occupies the bottom of
   a phone screen and it is the one element on this site that earns money. A second fixed bar would take a
   further ~60px off a 700px viewport, from the top, which is where the hero has to do its work — and it
   would buy a persistent menu for a visitor whose actual job is to dial a number. Alon: "I vote not, but
   want a menu while scrolling... dont mind being pushed back." This is the push-back and the compromise
   in one: he gets the menu where it costs nothing.

   ⚠️ THE FOOTER CALL BUTTON STAYS. The offer was to ditch it in exchange for a sticky header; scoping the
   stickiness to desktop means neither has to give way, because the two never share a screen.

   /!\ position:sticky, NOT fixed. Sticky keeps the header in normal flow, so nothing below it needs a
   compensating margin — the classic fixed-header bug where the first section hides underneath. */
@media (min-width:885px){
  .site-head[data-nav]{position:sticky;top:0;z-index:40;
    background:var(--paper);
    /* Only once it has left the top, so the shadow does not sit under a header at rest. */
    transition:box-shadow .18s ease}
  body.head-light .site-head[data-nav]{background:var(--card)}
  .site-head[data-nav].is-stuck{box-shadow:0 6px 18px rgba(var(--ink-rgb),.10)}
}
@media (prefers-reduced-motion:reduce){ .site-head[data-nav]{transition:none} }
