/* ============================================================================
   Joseph Services - site styles
   Plain CSS, no build step. Loaded after tokens.css on every page.
   ========================================================================= */

/* ---- reset ------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: var(--lh-body);
  color: var(--ink);
  background: var(--paper);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

img, svg, video { display: block; max-width: 100%; height: auto; }
input, button, textarea, select { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; }
a { color: inherit; }

/* Visible focus everywhere. The old site had no :focus rules at all, so the
   whole thing was unusable by keyboard. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; }

::selection { background: var(--accent); color: var(--on-accent); }

.skip-link {
  position: absolute; left: var(--s4); top: -100px;
  background: var(--accent); color: var(--on-accent);
  padding: var(--s3) var(--s5); border-radius: var(--r);
  z-index: 999; font-weight: 600; transition: top var(--fast) var(--ease);
}
.skip-link:focus { top: var(--s4); }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---- layout ------------------------------------------------------------ */
.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: var(--gutter); }
.wrap-wide { width: 100%; max-width: var(--wrap-wide); margin-inline: auto; padding-inline: var(--gutter); }
.section { padding-block: var(--section-y); }
.section-sunk { background: var(--surface-sunk); }
/* ---- emphasis band -------------------------------------------------------
   On the sister site this inverts: a near-black band on light paper. Inherited
   unchanged here it resolved to background:#eef2f6 - a BRIGHT WHITE band
   dropped into a dark page, ten times across five pages. That is not a strong
   accent, it is a page that looks broken, and it breaks the one-theme rule the
   rest of the site keeps.

   The band is crimson instead. It holds the same place in the hierarchy, it is
   unmistakably an interruption, and the page never leaves its own theme.
   White on it measures 5.99. */
.section-ink { background: var(--accent); color: var(--on-accent); }
/* .section-ink is NOT an always-dark band. Its background is --ink and its
   text is --paper, and BOTH of those swap in dark mode, so the band inverts
   to a light panel with dark text. Anything tinted inside it must therefore
   be derived from currentColor, never from the fixed-white --on-dark-* scale.
   Measured with the fixed white in place: this lead read 1.09:1 against the
   inverted band. carriers.html and services.html already tint their band copy
   from currentColor; this is the shared rule catching up. */
/* 72% was tuned against a white band. Composited on crimson it measures
   3.64:1 and FAILS AA. 88% gives 4.89. The mix must be re-measured whenever
   the band colour changes - that is the whole reason this line exists. */
.section-ink .lead, .section-ink .muted { color: color-mix(in srgb, currentColor 88%, transparent); }

main { min-height: 60vh; }

/* ---- type --------------------------------------------------------------
   The hierarchy is built from size, tracking and leading, because Barlow Condensed is
   loaded at 400-700 only and there is no heavier display cut to reach for.
   Every heading level therefore sets tighter and leads tighter than the one
   below it, and the tokens carry the relationship so it stays deliberate. */
/* EVERY HEADING IS CONDENSED AND UPPERCASE. This is the single change that
   separates the two sites fastest: the sister company sets mixed-case Archivo
   with negative tracking, an editorial voice. This is stencil-adjacent - the
   lettering on the side of a trailer, which is where the brand actually comes
   from. Condensed caps also buy back the width that positive tracking costs,
   so the headlines run larger without wrapping sooner. */
h1, h2, h3, h4 {
  font-family: var(--font-display);
  text-transform: uppercase;
  line-height: var(--lh-heading);
  font-weight: 700;
  letter-spacing: var(--track-display);
  text-wrap: balance;
}
h1 { font-size: var(--t-h1); line-height: var(--lh-display); }
h2 { font-size: var(--t-h2); }
h3 { font-size: var(--t-h3); line-height: 1.16; letter-spacing: .02em; }
h4 { font-size: 1.05rem; letter-spacing: .06em; }
p  { text-wrap: pretty; }

.hero-title {
  font-family: var(--font-display);
  text-transform: uppercase;
  font-size: var(--t-hero);
  line-height: var(--lh-display);
  letter-spacing: var(--track-display);
  font-weight: 700;
}
/* One tier above the hero, for a headline over a full-bleed photograph. */
.display {
  font-size: var(--t-display); line-height: var(--lh-display);
  letter-spacing: var(--track-display); font-weight: 700;
  text-wrap: balance;
}

/* Tight display tracking is a desktop luxury. At 375px, on mobile data, in a
   cab, in daylight, letters that touch are letters that get misread, so the
   display sizes give back most of the negative tracking below 560px. The type
   is smaller there anyway, and small type wants air. */
@media (max-width: 560px) {
  h1, .hero-title, .display { letter-spacing: -.024em; }
}

.lead { font-size: var(--t-lead); color: var(--ink-soft); max-width: var(--measure); line-height: var(--lh-lead); }
.muted { color: var(--ink-soft); }
.faint { color: var(--ink-faint); font-size: var(--t-small); }
.measure { max-width: var(--measure); }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

/* Used sparingly. Counted against the eyebrow budget: at most one per three
   sections on any page. */
.eyebrow {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--track-wide);
  text-transform: uppercase;
  color: var(--accent-ink);
  font-weight: 500;
}

.stack > * + * { margin-top: var(--s4); }
.stack-lg > * + * { margin-top: var(--s6); }

/* ---- buttons ----------------------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--s2);
  padding: 0.8rem 1.4rem;
  border-radius: var(--r);
  font-weight: 600;
  font-size: var(--t-body);
  line-height: 1.2;
  white-space: nowrap;              /* CTA labels never wrap */
  text-decoration: none;
  border: 1.5px solid transparent;
  transition: background var(--fast) var(--ease),
              border-color var(--fast) var(--ease),
              color var(--fast) var(--ease),
              transform var(--fast) var(--ease);
}
.btn:active { transform: translateY(1px); }

.btn-primary { background: var(--accent); color: var(--on-accent); }
.btn-primary:hover { background: var(--accent-press); }

.btn-secondary { background: transparent; color: var(--ink); border-color: var(--rule-strong); }
.btn-secondary:hover { border-color: var(--ink); background: var(--surface); }

/* Same reason as the .lead rule above: the band inverts, so the outline and
   the hover wash are mixed from currentColor and not from fixed white. */
.section-ink .btn-secondary { color: var(--on-accent); border-color: color-mix(in srgb, currentColor 55%, transparent); }
.section-ink .btn-secondary:hover { border-color: var(--on-accent); background: color-mix(in srgb, currentColor 8%, transparent); }

.btn-lg { padding: 1rem 1.75rem; font-size: var(--t-lead); }
.btn[disabled], .btn[aria-disabled="true"] { opacity: .5; pointer-events: none; }
.btn-block { width: 100%; }

/* ---- link with a moving rule ------------------------------------------- */
.link {
  color: var(--accent-ink); text-decoration: none; font-weight: 600;
  background-image: linear-gradient(var(--accent-ink), var(--accent-ink));
  background-size: 100% 1.5px; background-repeat: no-repeat;
  background-position: 0 100%;
  transition: background-size var(--med) var(--ease);
}
.link:hover { background-size: 0% 1.5px; background-position: 100% 100%; }

/* ---- nav --------------------------------------------------------------- */
.nav {
  position: sticky; top: 0; z-index: var(--z-nav);
  height: var(--nav-h);
  background: color-mix(in srgb, var(--surface) 86%, transparent);
  backdrop-filter: blur(12px) saturate(140%);
  -webkit-backdrop-filter: blur(12px) saturate(140%);
  border-bottom: 1px solid var(--rule);
}
@media (prefers-reduced-transparency: reduce) {
  .nav { background: var(--surface); backdrop-filter: none; -webkit-backdrop-filter: none; }
}
.nav-in {
  height: 100%; display: flex; align-items: center; gap: var(--s5);
  max-width: var(--wrap-wide); margin-inline: auto; padding-inline: var(--gutter);
}
.brand { display: flex; align-items: center; gap: var(--s3); text-decoration: none; margin-right: auto; }
.brand-mark {
  width: 34px; height: 34px; flex: none;
  display: grid; place-items: center;
  background: var(--accent); color: var(--on-accent);
  border-radius: var(--r);
  font-weight: 700; font-size: 0.9rem; letter-spacing: -.02em;
}
.brand-name { font-weight: 700; letter-spacing: -.02em; font-size: 1.05rem; line-height: 1; }
.brand-sub { font-size: 0.66rem; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-faint); line-height: 1; margin-top: 3px; }

.nav-links { display: flex; align-items: center; gap: var(--s5); list-style: none; padding: 0; }
.nav-links a {
  text-decoration: none; font-size: var(--t-small); font-weight: 500;
  color: var(--ink-soft); transition: color var(--fast) var(--ease);
}
.nav-links a:hover, .nav-links a[aria-current="page"] { color: var(--ink); }
.nav-links a[aria-current="page"] { font-weight: 600; }

.nav-actions { display: flex; align-items: center; gap: var(--s3); }

.theme-btn {
  width: 40px; height: 40px; display: grid; place-items: center;
  border-radius: var(--r); border: 1px solid var(--rule);
  color: var(--ink-soft);
  transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.theme-btn:hover { color: var(--ink); border-color: var(--rule-strong); }
.theme-btn svg { width: 17px; height: 17px; }
.theme-btn .icon-dark { display: none; }
:root[data-theme="dark"] .theme-btn .icon-dark { display: block; }
:root[data-theme="dark"] .theme-btn .icon-light { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-btn .icon-dark { display: block; }
  :root:not([data-theme="light"]) .theme-btn .icon-light { display: none; }
}

.nav-toggle { display: none; width: 40px; height: 40px; place-items: center; border-radius: var(--r); border: 1px solid var(--rule); }
.nav-toggle svg { width: 20px; height: 20px; }

@media (max-width: 900px) {
  .nav-toggle { display: grid; }
  .nav-links {
    position: fixed; inset: var(--nav-h) 0 auto 0;
    flex-direction: column; align-items: stretch; gap: 0;
    background: var(--surface);
    border-bottom: 1px solid var(--rule);
    padding: var(--s3) var(--gutter) var(--s5);
    transform: translateY(-8px); opacity: 0; pointer-events: none;
    transition: opacity var(--fast) var(--ease), transform var(--fast) var(--ease);
    box-shadow: var(--lift-2);
    max-height: calc(100dvh - var(--nav-h)); overflow-y: auto;
  }
  .nav-links[data-open="true"] { transform: none; opacity: 1; pointer-events: auto; }
  .nav-links a { padding: var(--s4) 0; font-size: 1.05rem; border-bottom: 1px solid var(--rule); }
  .nav-links li:last-child a { border-bottom: 0; }
  .nav .btn-primary { display: none; }
}

/* ---- footer ------------------------------------------------------------ */
/* --ink is near-WHITE on this site, so inheriting this rule put light grey
   text on a near-white footer: 1.15:1, completely invisible. The footer is
   the darkest surface here, which is also where it belongs on a dark page. */
.footer { background: var(--sunk); color: var(--ink-soft); padding-block: var(--s8) var(--s6); }
/* Removed: :root[data-theme="dark"] .footer { background: var(--surface-sunk); }
   It was inherited from the sister site, where dark was the exception. Here
   dark is the DEFAULT, the base rule already paints --sunk, and --sunk follows
   the theme on its own. The attribute selector only ever fired for the theme
   toggle, so it did nothing a phone would see - which is exactly how the same
   line hid the sister site's whole footer on system dark. */
.footer a { color: var(--ink-soft); text-decoration: none; transition: color var(--fast) var(--ease); }
.footer a:hover { color: var(--ink); }
.footer h4 { color: var(--ink); font-size: var(--t-micro); letter-spacing: .14em; text-transform: uppercase; font-weight: 600; margin-bottom: var(--s4); }
/* This comment used to say the footer was dark in BOTH themes and therefore
   safe to paint from the fixed on-dark scale. That was true of the sister
   site. Here the footer is --sunk, which FOLLOWS the theme: #0a0d11 in dark,
   #e7ebef in light. Fixed light text on it measured 1.14:1 in light mode.
   Everything in the footer now uses the theme-aware ink scale, which resolves
   to 9.16 in dark and 7.18 in light. */
.footer .brand-sub { color: var(--ink-faint); }
.footer-grid { display: grid; gap: var(--s7); grid-template-columns: 1.6fr 1fr 1fr 1.2fr; }
.footer-list { list-style: none; padding: 0; display: grid; gap: var(--s3); font-size: var(--t-small); }
/* Measured at 375px: footer links rendered 15px tall, well under the 40px
   minimum tap target. Inline links get a real hit area on touch rather than
   relying on the visitor's aim. */
@media (max-width: 900px) {
  .footer-list a, .footer-bottom a { display: inline-block; padding-block: 0.55rem; }
  .footer-list { gap: var(--s1); }
  .footer-bottom nav ul { gap: var(--s2) var(--s5) !important; }
}
.footer-bottom {
  margin-top: var(--s8); padding-top: var(--s5);
  /* --rule follows the theme; --on-dark-rule is fixed white and
     disappears on a light footer. */
  border-top: 1px solid var(--rule);
  display: flex; flex-wrap: wrap; gap: var(--s4) var(--s5);
  align-items: center; justify-content: space-between;
  font-size: var(--t-small);
}
@media (max-width: 860px) { .footer-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 520px) { .footer-grid { grid-template-columns: 1fr; gap: var(--s6); } }

/* ---- cards + panels ---------------------------------------------------- */
.panel {
  background: var(--surface); border: 1px solid var(--rule);
  border-radius: var(--r-lg); padding: var(--s6);
}
.panel-tint { background: var(--accent-wash); border-color: color-mix(in srgb, var(--accent) 22%, transparent); }

/* ---- forms ------------------------------------------------------------- */
/* Label ABOVE input, always a real <label for>. Placeholder never carries
   meaning. The old site had 24 of 27 fields with no programmatic label. */
/* align-content:start is load-bearing, not tidiness. A .field is a grid box
   sitting in a .form-grid cell, and grid cells stretch by default. Its own
   rows are auto-sized, and CSS Grid resolves the default `align-content:
   normal` to STRETCH - so an auto row grows to fill any leftover height.
   The result: put "Email" (which has a .hint under it) beside "Phone" (which
   does not) and the taller cell forces the shorter field to absorb the
   difference INTO ITS INPUT. Measured live on /carrier-onboarding at 1900px:
   every field with a hint rendered its control at 50.6px, every hintless
   field beside one at 64.6px. Two boxes on the same row, visibly different
   heights, on the page that executes the agreement.
   Starting the content instead keeps every control at its natural height and
   leaves the slack at the bottom of the cell, where nobody can see it. */
.field { display: grid; gap: var(--s2); align-content: start; }
.field > label { font-size: var(--t-small); font-weight: 600; color: var(--ink); }
.field .hint { font-size: var(--t-micro); color: var(--ink-faint); }
.field .err { font-size: var(--t-micro); color: var(--bad); font-weight: 500; }
/* The required marker is aria-hidden and never the only signal, but it is
   still 14px text: --accent measured 4.28:1 on --paper, --accent-ink 6.12:1. */
.req { color: var(--accent-ink); }

.input, .select, .textarea {
  width: 100%;
  padding: 0.72rem 0.85rem;
  background: var(--surface);
  border: 1.5px solid var(--rule-strong);
  border-radius: var(--r);
  color: var(--ink);
  transition: border-color var(--fast) var(--ease), box-shadow var(--fast) var(--ease);
}
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
.input:hover, .select:hover, .textarea:hover { border-color: var(--ink-faint); }
.input:focus, .select:focus, .textarea:focus {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
.input[aria-invalid="true"], .select[aria-invalid="true"], .textarea[aria-invalid="true"] { border-color: var(--bad); }
.textarea { min-height: 130px; resize: vertical; }
.select {
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%237a7a85' stroke-width='1.6'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 0.8rem center; background-size: 16px;
  padding-right: 2.4rem;
}
/* Dark is the default here, so the DEFAULT arrow must be the light-stroked
   one. The sister site had this behind :root[data-theme="dark"] alone, which
   only matches after the theme toggle is pressed - a phone on system dark kept
   the wrong arrow at 2.35:1. Inverted and given both halves. */
:root[data-theme="light"] .select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2383838d' stroke-width='1.6'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2383838d' stroke-width='1.6'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); }
}

.form-grid { display: grid; gap: var(--s5); grid-template-columns: 1fr 1fr; }
.form-grid .span-2 { grid-column: 1 / -1; }
@media (max-width: 680px) { .form-grid { grid-template-columns: 1fr; } }

.check {
  display: grid; grid-template-columns: auto 1fr; gap: var(--s3);
  align-items: start; cursor: pointer;
  font-size: var(--t-small); line-height: 1.5;
}
.check input {
  width: 20px; height: 20px; margin-top: 1px; flex: none;
  accent-color: var(--accent); cursor: pointer;
}

/* Form status. Announced via aria-live in the markup. */
.notice { padding: var(--s4); border-radius: var(--r); font-size: var(--t-small); font-weight: 500; }
.notice-ok  { background: var(--ok-wash);  color: var(--ok);  border: 1px solid color-mix(in srgb, var(--ok) 26%, transparent); }
.notice-bad { background: var(--bad-wash); color: var(--bad); border: 1px solid color-mix(in srgb, var(--bad) 26%, transparent); }
.notice-warn{ background: var(--warn-wash);color: var(--warn);border: 1px solid color-mix(in srgb, var(--warn) 26%, transparent); }

.spinner {
  width: 15px; height: 15px; flex: none;
  border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
  border-top-color: currentColor; border-radius: 50%;
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2s; } }

/* ---- theme switch: freeze every transition for one frame ----------------
   THIS IS A BUG FIX, NOT A FLOURISH. Do not delete it because it looks like
   an optimisation.

   Nearly every themed component transitions a colour (.btn transitions
   background and color, .footer a transitions color, cards transition
   border-color). Flipping data-theme changes the custom properties those
   colours are built from, and in Chromium the transitioned properties are
   then left holding their OLD resolved value and never recompute. Measured
   on this site before the fix, one click of the header toggle gave:

     .btn-primary   background rgb(210,68,19), color rgb(255,255,255)
                    instead of rgb(255,106,53) / rgb(20,16,14)
     .btn-secondary color rgb(22,22,26) on a rgb(18,18,21) page,
                    which is a contrast ratio of 1.0:1, i.e. invisible

   getAnimations() was empty at that point, so nothing was mid-flight: the
   value was simply stale, permanently, until something else dirtied it.

   So the toggle in site.js adds this class, changes the attribute, forces a
   reflow and drops the class again. With transitions off there is no
   transition to go stale, and the theme lands in one clean repaint. It also
   removes an unasked-for 160ms colour crossfade of the whole page.

   THIS LIVES IN site.css, NOT motion.css, ON PURPOSE. Only five of the eleven
   pages load motion.css; every page loads this file, and every page has the
   toggle in its header. Putting it in motion.css left about, carriers,
   services, privacy, terms and sms-terms still broken, which is how the gap
   was found. */
:root.theme-switching,
:root.theme-switching *,
:root.theme-switching *::before,
:root.theme-switching *::after {
  transition: none !important;
}

/* ---- scroll reveal ----------------------------------------------------- */
/* MOTION_INTENSITY 4: content enters once, nothing loops, nothing parallaxes.
   Driven by IntersectionObserver in site.js, never a scroll listener. */
/* CONTENT IS VISIBLE BY DEFAULT. Nothing on this page waits for JavaScript
   to become readable.

   It used to: .reveal started clipped to zero width and only opened when an
   IntersectionObserver in a DEFERRED script added .in. Deferred means after
   the HTML is parsed AND the file has crossed the network. Measured on the
   live site from Karachi: DOMContentLoaded at 953ms, a 4KB script taking
   462ms of that purely in latency. So every revealed section - which is most
   of the page - was blank for about a second, and the safety net that catches
   a failed observer sat at FOUR seconds. That is the delay that was reported.

   The wipe is now driven by the browser's own scroll timeline. Where that is
   supported the animation is identical and costs no JavaScript at all; where
   it is not, the content is simply there. Either way nothing is ever hidden
   waiting for a network request to finish. */
/* Defined HERE, not in motion.css. .reveal is a site.css rule and every page
   loads site.css, but about, privacy, terms and sms-terms do NOT load
   motion.css - so a keyframe referenced from here and defined there is
   undefined on four of eleven pages. That is the same split that has bitten
   this codebase before, written into its own comments: anything that must
   work on every page belongs in site.css. */
@keyframes wipe-in {
  /* The inset is expanded 3rem on the three edges the wipe does not travel
     along, and that is not cosmetic.

     clip-path: inset() clips to the element's BORDER BOX. Several components
     put decoration deliberately OUTSIDE that box: the numbered steps on the
     home page sit their ::before counter at top:-.85rem so it straddles the
     rule above them. With a plain inset(0 0 0 0) end state - which is exactly
     the border box - the top half of "01", "02" and "03" was cut off
     permanently, not just while animating. The old fade-up used opacity and
     transform, which do not clip, so nothing revealed this until the wipe
     replaced it.

     Only the RIGHT edge travels. Everything else is held well outside the box
     so no component can lose its overhang. */
  from { clip-path: inset(-3rem 100% -3rem -3rem); }
  to   { clip-path: inset(-3rem -3rem -3rem -3rem); }
}

.reveal { }

@supports (animation-timeline: view()) {
  .reveal {
    animation: wipe-in .62s var(--ease) both;
    animation-timeline: view();
    /* Finish early. The element should be fully open well before it reaches
       the middle of the screen, not still opening as it is read. */
    animation-range: entry 0% cover 18%;
  }
}

/* .in is kept as a no-op so any page or script still adding it is harmless. */
.reveal.in { clip-path: none; }
@media (prefers-reduced-motion: reduce) {
    /* clip-path MUST be reset here. The reveal no longer uses opacity or
     transform, so resetting only those would leave every revealed element
     clipped to zero width - the content would be INVISIBLE for exactly the
     people who asked for less motion. */
  .reveal, .reveal.in {
    opacity: 1; transform: none; transition: none;
    clip-path: none !important;
    /* Cancels the scroll-driven wipe as well, not just the old transition. */
    animation: none !important;
  }
}

/* ---- legal documents --------------------------------------------------- */
.doc { max-width: 74ch; }
.doc h2 { font-size: var(--t-h3); margin-top: var(--s7); margin-bottom: var(--s3); }
.doc h2:first-of-type { margin-top: var(--s5); }
.doc h3 { font-size: 1.02rem; margin-top: var(--s5); margin-bottom: var(--s2); }
.doc p, .doc li { color: var(--ink-soft); }
.doc p + p { margin-top: var(--s4); }
.doc ul, .doc ol { margin: var(--s4) 0; padding-left: 1.3rem; display: grid; gap: var(--s2); }
.doc strong { color: var(--ink); font-weight: 600; }
.doc-meta { font-size: var(--t-small); color: var(--ink-faint); }
.doc-callout {
  background: var(--accent-wash);
  border-left: 3px solid var(--accent);
  border-radius: 0 var(--r) var(--r) 0;
  padding: var(--s5); margin: var(--s5) 0;
}
.doc-callout p { color: var(--ink); }

/* ---- utility ----------------------------------------------------------- */
.hide { display: none !important; }
.center { text-align: center; }
.row { display: flex; flex-wrap: wrap; gap: var(--s4); align-items: center; }
@media (max-width: 520px) {
  .row-cta { flex-direction: column; align-items: stretch; }
  .row-cta .btn { width: 100%; }
}

/* ============================================================================
   CHARACTER LAYER
   ----------------------------------------------------------------------------
   Everything above this line is structure and contrast, and it is verified:
   every colour clears WCAG AA in both themes. Nothing here changes a single
   one of those values. This layer only adds texture, rhythm and a few
   domain-specific components on top.

   THE IDEA. A generic SaaS layout would work here and would look like every
   other dispatch company. The one thing this business actually promises a
   carrier is "we handle your paperwork" - rate confirmations, BOLs, invoices,
   a signed agreement. So the site borrows the visual language of freight
   documents done properly: ruled lines, monospaced figures that align in a
   column, data plates like the ones riveted to a trailer, and lane markings
   as dividers. It reads as equipment rather than as marketing, which is the
   register the audience already trusts.
   ========================================================================= */

/* ---- ruled paper -------------------------------------------------------
   A manifest is ruled. This is that, at very low contrast: repeating
   hairlines on the sunk band, so the surface reads as a form rather than as
   an empty grey rectangle. Kept under 3% opacity so it never competes with
   text and never affects a contrast measurement. */
.section-sunk {
  background-image: repeating-linear-gradient(
    to bottom,
    transparent 0,
    transparent 39px,
    color-mix(in srgb, var(--ink) 4%, transparent) 39px,
    color-mix(in srgb, var(--ink) 4%, transparent) 40px
  );
  background-attachment: local;
}

/* ---- band divider ------------------------------------------------------
   The sister site draws road lane markings here - dashes in the accent, long
   mark, long gap. Copying that would be copying its most recognisable piece
   of furniture.

   This is a single hard rule that runs the full width and steps up to a short
   crimson tab at the left, like the edge of a stamped steel plate. Solid, not
   dashed; structural, not decorative. */
.rule-road {
  position: relative;
  height: 1px; border: 0; margin: 0;
  background: var(--rule);
  overflow: visible;
}
.rule-road::before {
  content: ""; position: absolute; left: 0; top: -2px;
  width: 88px; height: 5px; background: var(--accent);
}

/* ---- data plate --------------------------------------------------------
   For a figure that matters: mono, tabular, boxed in a hairline the way a
   serial plate is stamped on equipment. The label sits under the number in
   small caps, because that is the order the eye wants it in.

   Note the border is --rule-strong and the label --ink-soft, both already
   measured against every surface this can sit on. */
.plate {
  display: inline-flex; flex-direction: column; gap: var(--s1);
  padding: var(--s3) var(--s4);
  border: 1px solid var(--rule-strong);
  border-radius: var(--r);
  background: var(--surface);
}
.plate .fig {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: clamp(1.4rem, 1.1rem + 1.2vw, 2rem);
  font-weight: 500; line-height: 1; letter-spacing: -.02em;
  color: var(--ink);
}
.plate .cap {
  font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-soft); font-weight: 600;
}
/* On the inverting dark band the plate has to derive from currentColor, the
   same rule the audit established for .section-ink. */
.section-ink .plate { background: transparent; border-color: color-mix(in srgb, currentColor 26%, transparent); }
.section-ink .plate .fig { color: currentColor; }
.section-ink .plate .cap { color: color-mix(in srgb, currentColor 72%, transparent); }

/* ---- placard heading ---------------------------------------------------
   The section-header device, used twelve times, so it carries more of the
   page's character than anything else here.

   The sister site stamps a short accent rule ABOVE the heading. This one sets
   a solid crimson SLAB down its left edge instead, the way a placard is bolted
   to a bulkhead. Horizontal punctuation against vertical structure: the two
   read as different systems at a glance, which is the whole point.

   The slab is set in em so it tracks the heading it marks rather than being
   one fixed width at every level. */
.marked { position: relative; padding-left: .62em; }
.marked::before {
  content: ""; position: absolute; inset: .1em auto .12em 0;
  width: .16em; min-width: 5px; background: var(--accent);
}
/* Over a photograph the crimson loses against a bright frame, so it goes
   white and lets the scrim carry the separation. */
.shot-cap .marked::before,
.section-ink .marked::before { background: currentColor; }

/* ---- figure column -----------------------------------------------------
   Numbers in a vertical run align on their digits because they are tabular
   mono. A manifest reads down a column; so does this. */
.figures { display: grid; gap: 0; }
.figures > * {
  display: grid; grid-template-columns: 1fr auto; gap: var(--s4);
  align-items: baseline;
  padding-block: var(--s4);
  border-bottom: 1px solid var(--rule);
}
.figures > *:last-child { border-bottom: 0; }
.figures .fig {
  font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  font-weight: 500; color: var(--accent-ink); white-space: nowrap;
}

/* ============================================================================
   PHOTOGRAPH LAYER
   ----------------------------------------------------------------------------
   Every rival dispatch site runs the same three stock trucks. This one runs the
   operator's own phone photos of his own loads, which is the only thing on the
   page a competitor cannot buy. So the photographs are treated as the argument,
   not as page furniture.

   The problem that comes free with real photography is that it was not shot to
   a brief. Eight frames, eight days, eight white balances, and a 2.3x spread in
   median brightness between the brightest and the darkest daylight frame. Left
   alone they read as a phone camera roll. Three things pull them together:

     the grade      one filter, on every fleet photograph, automatically
     the trim       a per-photo exposure nudge, capped, measured
     the frame      one radius and one hairline, so they sit as a set

   None of it crushes the picture into an effect. A carrier has to be able to
   look at frame 08 and recognise two utility vehicles chained to a gooseneck,
   because recognising it is the point.

   NOTE ON WHERE THIS LIVES. It is in site.css and not motion.css because only
   five of the eleven pages load motion.css, and three of the pages carrying
   photographs - services, carriers, about - are not among them. The same trap
   that left the theme-switch freeze broken on six pages.
   ------------------------------------------------------------------------ */

/* ---- the grade ---------------------------------------------------------
   Applied by source path, not by class, and that is deliberate. A treatment
   that a page has to remember to opt into is a treatment that will be
   inconsistent by the third page. Any file under /assets/img/fleet/ is graded
   wherever it appears, including in pages written after this one. */
.shot > img,
img[src*="/assets/img/fleet/"] {
  filter: var(--photo-grade) brightness(var(--photo-exposure));
}

/* ---- the exposure trim -------------------------------------------------
   Measured with tools/photo-probe.html: each file drawn into a canvas under
   the real grade string, pixels read back, median luminance taken. The trims
   below pull the seven daylight frames toward their own median. Capped at
   +-10%, because past that the correction starts inventing a photograph that
   was never taken.

   Result, measured before and after: the daylight spread closes from 2.28x to
   1.48x. Not uniform, and it should not be - these are real trucks in real
   weather, not a catalogue.

   37 IS DELIBERATELY EXEMPT. It is the night frame, the tarped flatbed under
   work lights, and it sits at a median of 0.02 against the set's 0.24. Pulling
   it up would need +148% and would turn the one photograph that proves
   "dispatch answers at 2am" into an ordinary afternoon. Night is the content.
   Leave it dark. */
img[src*="/fleet/01-"], [data-shot="01"] { --photo-exposure: 1.10; }
img[src*="/fleet/02-"], [data-shot="02"] { --photo-exposure: 0.91; }
img[src*="/fleet/08-"], [data-shot="08"] { --photo-exposure: 1.10; }
img[src*="/fleet/09-"], [data-shot="09"] { --photo-exposure: 1.00; }
img[src*="/fleet/23-"], [data-shot="23"] { --photo-exposure: 0.90; }
img[src*="/fleet/25-"], [data-shot="25"] { --photo-exposure: 0.90; }
img[src*="/fleet/26-"], [data-shot="26"] { --photo-exposure: 1.10; }
img[src*="/fleet/37-"], [data-shot="37"] { --photo-exposure: 1.00; }

/* ---- the frame ---------------------------------------------------------
   One radius, one hairline. The hairline is not decoration: on --paper a
   photograph with a bright edge bleeds into the page and loses its shape, and
   on the dark field the opposite happens. --photo-frame flips between the two
   so the edge is always the one the photograph needs. */
.shot {
  position: relative;
  display: grid;
  overflow: hidden;
  border-radius: var(--photo-radius);
  background: var(--surface-sunk);   /* holds the space before the file lands */
  isolation: isolate;
}
.shot > img {
  grid-area: 1 / 1;
  width: 100%; height: 100%;
  object-fit: cover;
}
.shot::after {
  content: ""; position: absolute; inset: 0; z-index: 3;
  border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px var(--photo-frame);
}
/* Shapes. A phone photo cropped to a shape the layout wanted is still better
   than a stock photo that fitted. 23 of the 38 originals are portrait, which
   is why .shot-tall exists at all. */
.shot-wide  { aspect-ratio: 16 / 10; }
.shot-cine  { aspect-ratio: 21 / 9; }
.shot-tall  { aspect-ratio: 4 / 5; }
.shot-square{ aspect-ratio: 1 / 1; }

/* ---- text on a photograph ----------------------------------------------
   Two layers, and they have to arrive together. The veil is the flat dim over
   the whole frame; .shot-cap is the caption box that carries the rest. Their
   alphas compose to --scrim-total, which is the measured floor, so a caption
   that got the scrim and not the veil would sit at 4.4:1 instead of 4.9:1 -
   under AA, and by an amount nobody would ever catch by eye.

   So the veil is not left to a page agent to remember. :has() puts it there
   the moment a caption exists inside the frame. .shot-text stays as an
   explicit opt-in, for a frame that wants the veil without a caption and for
   the odd engine where :has() is missing; when both match there is still only
   one ::before, so it cannot double up.

   The caption hugs its own content rather than covering the frame, so the
   photograph stays open above it however tall the tile grows, and its scrim
   fades out over exactly --scrim-fade while its padding-top is at least that.
   No line of text can land in the fade. That is the whole trick: the floor is
   a property of the box, not a hope about where the copy ended up. */
.shot-text::before,
.shot:has(> .shot-cap)::before {
  content: ""; grid-area: 1 / 1; z-index: 1;
  background: rgba(var(--scrim-rgb), var(--photo-veil));
}
.shot-cap {
  grid-area: 1 / 1; z-index: 2;
  align-self: end;
  padding: calc(var(--scrim-fade) + var(--s4)) var(--s6) var(--s6);
  color: var(--on-photo);
  background: linear-gradient(to top,
    rgba(var(--scrim-rgb), var(--scrim-a)) 0,
    rgba(var(--scrim-rgb), var(--scrim-a)) calc(100% - var(--scrim-fade)),
    rgba(var(--scrim-rgb), 0) 100%);
}
.shot-cap > * + * { margin-top: var(--s2); }
.shot-cap h2, .shot-cap h3, .shot-cap h4 { color: var(--on-photo); }
.shot-cap p, .shot-cap .lead { color: var(--on-photo-soft); }
.shot-cap .eyebrow { color: var(--on-photo-soft); }

/* A credit or a location line under a photograph, outside the frame. Kept off
   the image on purpose: a caption that has to be scrimmed is a caption that is
   competing with the picture. */
.shot-note { font-size: var(--t-small); color: var(--ink-faint); margin-top: var(--s3); }

/* ============================================================================
   FULL-BLEED PHOTO BAND
   ----------------------------------------------------------------------------
   The site had no way to run a photograph at full width, which for a business
   whose only real differentiator is its own photography was the biggest gap in
   the system. One band, edge to edge, one headline, at most one call to action.

   Structure the page agent writes:

     <section class="photo-band">
       <img src="/assets/img/fleet/37-w1600.jpg"
            srcset="/assets/img/fleet/37-w640.jpg 640w,
                    /assets/img/fleet/37-w1000.jpg 1000w,
                    /assets/img/fleet/37-w1600.jpg 1600w"
            sizes="100vw" alt="..." width="1600" height="1200" loading="lazy">
       <div class="photo-band__body">
         <div class="photo-band__inner">
           <h2 class="display">Six words, no more</h2>
           <p class="lead">One supporting line.</p>
           <a class="btn btn-primary btn-lg" href="/contact">One action</a>
         </div>
       </div>
     </section>

   LCP. sizes="100vw" is mandatory here: without it the browser assumes the
   image is the full viewport width anyway and can still pick w1600 on a phone,
   which is 300KB of mobile data for a 375px screen. With the srcset above a
   phone takes w640. Use loading="lazy" unless the band is the first thing on
   the page, in which case drop lazy and add fetchpriority="high" - one or the
   other, never both.

   The body is full width so its scrim reaches both edges; the inner wrapper is
   what constrains the text. That is why there are two elements and not one.
   ------------------------------------------------------------------------ */
.photo-band {
  position: relative;
  display: grid;
  overflow: hidden;
  isolation: isolate;
  background: var(--surface-sunk);
  /* THE CLEAN STRIP. The body is bottom-anchored and carries the scrim, so the
     only undarkened photograph the reader gets is whatever sits above it. A
     min-height alone cannot guarantee any: measured at 375px with a headline,
     a lead and a button, the body came to 444px inside a 384px minimum and the
     clean strip was zero - a full-bleed photo band showing no photograph.

     Padding on the BAND fixes it structurally. The image is inset:0 on a
     positioned parent, so it covers the padding box, while the grid row that
     holds the body starts below the padding. The strip is therefore reserved
     at every width, by construction, whatever the copy does. Measured after:
     96px clean plus the 80px fade at 375, 160px plus the fade at 1280.

     min-height is now only a floor for a band whose body is a headline and
     nothing else. */
  padding-top: clamp(6rem, 20vw, 10rem);
  min-height: clamp(20rem, 10rem + 26vw, 34rem);
}
/* Taken OUT OF FLOW on purpose. Left as a grid item, the photograph's own
   aspect ratio sizes the row, and a 1600x1200 file in a 1265px band measured
   949px tall - a band that is really a full-page photograph with a sentence on
   it. Absolute, the band is sized by min-height and its content, and the
   photograph covers whatever that turns out to be, which is the only way a
   band works at both 375px and 1920px. */
.photo-band > img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  filter: var(--photo-grade) brightness(var(--photo-exposure));
  z-index: 0;
}
.photo-band::before {
  content: ""; grid-area: 1 / 1; z-index: 1;
  background: rgba(var(--scrim-rgb), var(--photo-veil));
}
.photo-band__body {
  grid-area: 1 / 1; z-index: 2;
  align-self: end;
  width: 100%;
  padding-block: calc(var(--scrim-fade) + var(--s5)) clamp(var(--s6), 4vw, var(--s8));
  padding-inline: var(--gutter);
  background: linear-gradient(to top,
    rgba(var(--scrim-rgb), var(--scrim-a)) 0,
    rgba(var(--scrim-rgb), var(--scrim-a)) calc(100% - var(--scrim-fade)),
    rgba(var(--scrim-rgb), 0) 100%);
}
.photo-band__inner {
  max-width: var(--wrap-wide);
  margin-inline: auto;
  color: var(--on-photo);
}
.photo-band__inner > * + * { margin-top: var(--s5); }
.photo-band__inner h1, .photo-band__inner h2, .photo-band__inner h3 { color: var(--on-photo); max-width: 20ch; }
.photo-band__inner .lead, .photo-band__inner p { color: var(--on-photo-soft); max-width: 46ch; }
.photo-band__inner .eyebrow { color: var(--on-photo-soft); }
/* The band is dark in BOTH themes, so a secondary button here takes the fixed
   white scale rather than --ink, which would vanish. Same rule the audit
   established for .section-ink, arrived at from the opposite direction: that
   band inverts and this one never does. */
.photo-band .btn-secondary { color: var(--on-photo); border-color: var(--on-dark-rule-strong); }
.photo-band .btn-secondary:hover { border-color: var(--on-photo); background: var(--on-dark-wash); }
/* Centred variant, for a band that closes a page rather than opening a
   section. Text still sits in the flat part of the scrim. */
.photo-band-center .photo-band__inner { text-align: center; }
.photo-band-center .photo-band__inner h1,
.photo-band-center .photo-band__inner h2,
.photo-band-center .photo-band__inner .lead,
.photo-band-center .photo-band__inner p { margin-inline: auto; }
.photo-band-center .row { justify-content: center; }
/* 375px: the band gets shorter, the gutter carries the inset, and the display
   size is already clamped. Nothing here can overflow because the section is a
   single grid cell at 100% width with overflow hidden. */
@media (max-width: 560px) {
  .photo-band { min-height: 22rem; }
  .photo-band__body { padding-block: calc(var(--scrim-fade) + var(--s3)) var(--s6); }
}

/* ============================================================================
   PHOTOGRAPH MOTION
   ----------------------------------------------------------------------------
   Four additions, each of which has to answer hierarchy, storytelling,
   feedback or state in one sentence, or it does not ship.
   ------------------------------------------------------------------------ */

/* STORYTELLING. The photograph settles into its frame as you reach it, so the
   frame reads as a window onto something rather than as a box with a picture
   in it. Scale only, clipped by the frame's overflow, so nothing reflows. */
.shot.reveal > img { transform: scale(1.05); }
.shot.reveal.in > img { transform: none; transition: transform 1s var(--ease); }

/* FEEDBACK. A photograph inside a link eases in under the cursor, so a tile
   that navigates is distinguishable from a tile that does not. Only ever fires
   on an actual link, which is why the selector starts at `a`. */
@media (hover: hover) {
  a .shot > img { transition: transform .6s var(--ease); }
  a:hover .shot > img { transform: scale(1.04); }
}

/* HIERARCHY. A row of items arrives in reading order instead of all at once,
   so the eye is given a first item. This replaces six data-delay attributes in
   the markup with one class on the parent; both still work. */
.reveal-seq > .reveal:nth-child(2) { transition-delay: .07s; }
.reveal-seq > .reveal:nth-child(3) { transition-delay: .14s; }
.reveal-seq > .reveal:nth-child(4) { transition-delay: .21s; }
.reveal-seq > .reveal:nth-child(5) { transition-delay: .28s; }
.reveal-seq > .reveal:nth-child(6) { transition-delay: .35s; }

/* STORYTELLING. The lane marking paints itself left to right as the section
   arrives, which is the one moment the road metaphor is doing work rather than
   sitting on the page being a dashed line. Overrides the .reveal transform
   because a rule that slides up is just a rule that slides up. */
.rule-road.reveal { opacity: .55; transform: scaleX(0); transform-origin: 0 50%; }
.rule-road.reveal.in { opacity: .55; transform: scaleX(1); transition: transform .9s var(--ease); }

@media (prefers-reduced-motion: reduce) {
  .shot.reveal > img, .shot.reveal.in > img,
  a .shot > img, a:hover .shot > img {
    transform: none !important; transition: none !important;
  }
  .reveal-seq > .reveal { transition-delay: 0s !important; }
  .rule-road.reveal, .rule-road.reveal.in {
    transform: none !important; transition: none !important; opacity: .55;
  }
}

/* ---- legal documents: sentence case, deliberately ------------------------
   The uppercase display treatment above is right for a page someone scans and
   wrong for one they have to READ. "17. Governing Law and Dispute Resolution"
   set in condensed caps is measurably slower to read: all-caps removes the
   word-shape cues a reader uses to skim, and these are the pages where a
   carrier is checking what they are agreeing to.

   So inside .doc the headings keep the display face - the page still belongs
   to this site - but drop the uppercase and take normal tracking back. */
.doc h1, .doc h2, .doc h3, .doc h4 {
  text-transform: none;
  letter-spacing: .005em;
}
.doc h2, .doc h3 { line-height: 1.2; }

/* ---- buttons: condensed, uppercase, square -------------------------------
   The label is the one piece of type a visitor is guaranteed to read, so it
   carries the same voice as the headings. Square by system rule, so a button
   is a stamped plate rather than a pill. */
.btn {
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: .1em;
  font-weight: 600;
}

/* The nav wordmark sets in the display face too, or it reads as a different
   company from the one in the headlines. */
.brand-name {
  font-family: var(--font-display);
  text-transform: uppercase;
  letter-spacing: .04em;
  font-weight: 700;
  font-size: 1.2rem;
}
.brand-sub { letter-spacing: var(--track-wide); }
.brand-mark { border-radius: 0; }
