/* ==========================================================
   Silver Bullet — shared site chrome

   Extracted from index.html so every page gets the same header, nav,
   bullet intro and hook banner from ONE source instead of a copy that
   drifts. Load AFTER tokens.css and textures.css.

   Bump ?v= on every change — see TEXTURES.md. Stale cached CSS has
   already shipped on this project once.
   ========================================================== */

    /* ==========================================================
       Silver Bullet — page styles
       Colors come from tokens.css. No raw hex in here.
       ========================================================== */

    :root {
      /* Layout */
      --nav-h:      72px;
      /* Widened from 1180px. On a 1440px screen the old cap left 130px of
         dead margin each side on top of the gutter, which read as a narrow
         column floating on a wide dark page. The reference site fills its
         width and lets full-bleed bands run edge to edge. */
      --wrap:       1360px;
      --gutter:     clamp(20px, 4vw, 48px);

      /* Vertical rhythm ladder */
      /* Tightened against inboundmedia.co, measured rather than eyeballed.
         Their largest vertical gap is ~215px; ours were 431px and 366px,
         because --space-xl is applied as BOTH top and bottom padding on
         every section, so the old 200px ceiling meant 400px between two
         sections. Halving the ceiling puts the biggest gap in their range.
         They can also stack tighter than we can because they alternate
         background colours between sections - the colour change does the
         separating. This site is dark-only, so the hairline section borders
         and the space have to do that work together. */
      --space-xl:   clamp(72px, 7.5vw, 108px);
      --space-lg:   clamp(56px, 6vw, 88px);
      --space-md:   clamp(40px, 4.5vw, 64px);
      --space-sm:   clamp(24px, 2.5vw, 32px);

      /* Intro timeline — every value the animation depends on */
      --t-fire:     0.10s;  /* muzzle blip */
      --t-fly:      1.00s;  /* bullet crosses the screen */
      --t-glint:    0.55s;  /* light catches it, mid-screen */
      --t-name:     0.60s;  /* wordmark wipes in */
      --t-shift:    1.15s;  /* logo slides to the left */
      --t-nav:      1.50s;  /* nav items arrive */
      /* Page fades up. Held at 1.80s originally, which meant <main> and the
         footer sat at opacity:0 from first paint until 2.35s — and Largest
         Contentful Paint cannot fire on an element that has not been
         painted, so LCP measured 2488ms on every page except the homepage.
         That is 12ms under Google's 2.5s threshold on a LOCAL server with a
         warm cache and no network; on a phone it fails outright.

         Brought forward so the page arrives while the bullet is still
         crossing rather than after it has finished. The sequence is intact
         — blip, flight, glint, wordmark, logo travel, nav — it now plays
         OVER a page that is already there, which is also why .intro-layer
         is transparent and always was. */
      --t-page:     0.35s;

      --ease-settle: cubic-bezier(.25, 1, .5, 1);
      /* Hard launch, long decaying tail. The opening slope is y1/x1 = 4.25x
         the average, which puts the first frames of the return at roughly the
         bullet's own speed; it then bleeds that off into the marquee drift. */
      --ease-recoil: cubic-bezier(.2, .85, .25, 1);

      /* Slogan ticker.
         The ride is deliberately a long way. The return has to cover this
         distance in its first few hundred milliseconds to leave at anything
         like the bullet's pace, and a short ride leaves the deceleration
         nothing to be felt across — measured, 144px read as a flat crawl. */
      --ticker-ride: clamp(280px, 42vw, 620px);
      --ticker-rate: 62s;   /* one full loop. Slow on purpose. */

      /* Work conveyors. Three bands run right-to-left on this page, so they
         get three clearly different speeds — matched speeds would read as
         one striped block rather than as separate systems. Roughly
         45px/s for the creative slots, 18px/s for the logo band, against
         the hook banner's 28px/s. */
      --conveyor-rate: 36s;   /* no-JS fallback; JS uses data-marquee-speed */
      --logo-rate:    112s;   /* no-JS fallback; JS uses data-marquee-speed */

      /* Set by JS: how far left the logo must travel from centre */
      --logo-shift: 0px;

      /* Reactive chrome. 0-100. Drives the hard stop positions in the chrome
         gradients via calc(). 50 = centred band, which is also the static
         state when JS is off or motion is reduced. */
      --light: 50;
    }

    * { box-sizing: border-box; }

    /* No CSS smooth scrolling: Lenis owns scrolling when motion is on, and
       the two fight if both are active. Under reduced motion Lenis never
       initialises and anchors jump instantly, which is correct.

       scroll-padding-top is what keeps the sticky header off the top of a
       section the browser jumps to on its own: landing on a URL that already
       carries a hash, a reload while deep-linked, reduced motion, or JS off.
       Lenis handles in-page CLICKS itself with its own -72 offset in
       motion.js, so this covers only the cases Lenis never sees - which is
       exactly the set that was landing section headings under the nav. */
    html {
      scroll-behavior: auto;
      scroll-padding-top: var(--nav-h);
    }

    /* Lenis base styles (required by lenis 1.x). */
    html.lenis, html.lenis body { height: auto; }
    .lenis.lenis-smooth { scroll-behavior: auto !important; }
    .lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
    .lenis.lenis-stopped { overflow: hidden; }

    body {
      margin: 0;
      background: var(--bg);
      color: var(--text);
      font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      -webkit-font-smoothing: antialiased;
      overflow-x: hidden;
    }

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

    a { color: inherit; }

    :where(a, button, input, textarea):focus-visible {
      outline: 2px solid var(--amber);
      outline-offset: 3px;
    }

    /* ---------- type ---------- */

    h1, h2, h3 {
      color: var(--text-strong);
      font-weight: 800;
      letter-spacing: -0.035em;
      margin: 0;
    }

    h1 {
      font-size: clamp(40px, 7vw, 86px);
      line-height: 0.98;
    }

    h2 {
      font-size: clamp(28px, 4vw, 46px);
      line-height: 1.02;
    }

    h3 {
      font-size: 18px;
      line-height: 1.25;
      letter-spacing: -0.02em;
    }

    p { margin: 0; }

    .lead {
      font-size: clamp(17px, 1.5vw, 20px);
      line-height: 1.6;
      color: var(--steel-300);
      max-width: 62ch;
    }

    .meta {
      font-size: 14px;
      color: var(--text-muted);
    }

    /* ---------- reactive chrome ----------
       --light slides the hard stops. It never softens them: every band is
       still a PAIR of stops at one position, the pair just moves together.
       Applied to exactly three elements — the hero headline, the bullet,
       and the machined bar above the contact heading.
       Driving a gradient from a custom property repaints that element every
       frame, so this deliberately does not scale. */

    [data-chrome] { --lo: calc((var(--light) - 50) * 0.9%); }

    /* The headline's metal is .tx-rim-text now: one soft band that leans
       toward the cursor, rather than the hard stepped bands. And it is
       hover-only - see textures.js. Previously it was driven by motion.js,
       which blends scroll in, so it moved wherever you were on the page. */

    /* The bullet sweeps less far — it is 20px tall, not 200px wide. */
    .bullet[data-chrome] { --lo: calc((var(--light) - 50) * 0.42%); }

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

    .site-header {
      position: sticky;
      top: 0;
      z-index: 40;
      height: var(--nav-h);
      /* No fill of its own. .tx-frost supplies a translucent pane, and only
         once the page has been scrolled - see textures.css. */
    }

    .site-header .wrap {
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 24px;
    }

    /* The line the bullet leaves behind. It is the nav separator. */
    .site-header__rule {
      position: absolute;
      left: 0;
      right: 0;
      bottom: 0;
      height: 1px;
      background: var(--border);
      transform-origin: left center;
    }

    .logo {
      display: inline-block;
      line-height: 0;
      text-decoration: none;
      will-change: transform;
      position: relative;
    }

    /* The wordmark is 148x12, far under any tap-target minimum. Its BOX must
       not change: intro.js measures logo.getBoundingClientRect() to compute
       --logo-shift, so padding here would move where the logo flies from.
       An absolutely positioned overlay expands only the hit area and leaves
       the anchor's own rect untouched. Safe here precisely because the logo
       has no neighbour within 44px to overlap. */
    .logo::after {
      content: "";
      position: absolute;
      left: 0; right: 0;
      top: 50%;
      transform: translateY(-50%);
      height: 44px;
    }

    /* The wipe is applied to this wrapper, so it clips whatever sits inside —
       it worked on text and it works on the image for the same reason. */
    .logo__inner { display: block; }

    .logo__img {
      display: block;
      /* A fixed width matters more than it looks. The intro measures this
         element to work out how far it has to travel, and that measurement
         happens before the image file has necessarily arrived. Without a
         width reserved in CSS the box would still be empty at that moment
         and the logo would fly to the wrong place. */
      /* The lockup is 3.3:1, so width drives height: at the old 196px cap it
         stood 59px tall inside a 72px bar, which left 6px of air. Capped to
         152px it lands at 46px and the bar breathes again. The width stays
         declared for the reason above - the intro measures it before the
         file lands - and height is now declared too, so the box is fully
         reserved and nothing reflows when the image arrives. */
      width: clamp(118px, 13vw, 152px);
      height: auto;
      aspect-ratio: 800 / 242;
    }

    .nav {
      display: flex;
      align-items: center;
      gap: clamp(18px, 2.5vw, 34px);
    }

    .nav a {
      font-size: 15px;
      color: var(--steel-500);
      text-decoration: none;
      transition: color .18s ease;
    }

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

    /* ---------- nav dropdown ----------
       Built on <details> so the thing works with the script absent: click
       opens it, the keyboard gets it for free, and nav.js only adds the
       enhancements — hover-open on a fine pointer, Escape, close on an
       outside click.

       Inbound's version holds seventeen services in three named groups. We
       have six capabilities and no groups, so this stays a single numbered
       column. Inventing a taxonomy to fill a wider panel would be inventing
       information. */

    /* Stretching to the header's full height is what lets the panel sit at
       top:100% and land exactly on the nav rule, instead of guessing an
       offset from the summary's own text height. */
    .nav-dd {
      position: relative;
      align-self: stretch;
      display: flex;
      align-items: center;
    }

    .nav-dd__toggle {
      display: flex;
      align-items: center;
      gap: 7px;
      font-size: 15px;
      color: var(--steel-500);
      cursor: pointer;
      list-style: none;          /* kills the disclosure triangle … */
      transition: color .18s ease;
    }
    .nav-dd__toggle::-webkit-details-marker { display: none; }  /* … and again for WebKit */

    .nav-dd__toggle:hover,
    .nav-dd[open] .nav-dd__toggle { color: var(--white); }

    /* Drawn rather than typed. A + character would sit on the text baseline
       and jump as the font resolves; two 1px bars do not. The upright bar is
       what leaves when the panel opens, so it reads + closed, - open. */
    .nav-dd__sign {
      position: relative;
      flex: none;
      width: 9px;
      height: 9px;
    }
    .nav-dd__sign::before,
    .nav-dd__sign::after {
      content: "";
      position: absolute;
      inset: 50% 0 auto 0;
      height: 1px;
      background: currentColor;
    }
    .nav-dd__sign::after {
      transform: rotate(90deg);
      transition: transform .18s ease, opacity .18s ease;
    }
    .nav-dd[open] .nav-dd__sign::after {
      transform: rotate(0deg);
      opacity: 0;
    }

    /* Separation is the rim-light move, same as .panel in tokens.css: lit top
       edge, ink bottom, no fill beyond bg-raised and no shadow. DESIGN.md
       rules out box-shadow for depth and this is the case it would be most
       tempting in. */
    .nav-dd__panel {
      position: absolute;
      top: 100%;
      right: 0;
      z-index: 50;
      min-width: 292px;
      padding: 8px;
      background: var(--bg-raised);
      border-top: 1px solid var(--border-lit);
      border-left: 1px solid var(--border);
      border-right: 1px solid var(--border);
      border-bottom: 1px solid var(--ink);
    }

    /* The panel hangs off the frosted bar, so it is frosted too - it reads as
       the same pane of glass rather than a solid card stuck to the underside
       of one. Same recipe as .tx-frost in textures.css, with more fill: the
       bar carries three words, this carries seven rows of 14px type and has
       to stay readable over whatever it happens to be covering.

       This is NOT the accidental see-through it replaces. That was the
       nightfield z-index tie letting .ticker and <main> paint over the panel;
       this is a controlled blur of what sits behind it, and the rim-light
       borders above still do the separating. Blur is a backdrop treatment,
       not a box-shadow - DESIGN.md's no-glow rule is about light bleeding
       OUT of an element, which this does not do.

       Guarded on @supports because the fallback has to be the opaque fill:
       without a working blur, a translucent menu is just hard to read. */
    @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
      .nav-dd__panel {
        background: color-mix(in srgb, var(--bg-raised) 82%, transparent);
        -webkit-backdrop-filter: blur(14px) saturate(108%);
        backdrop-filter: blur(14px) saturate(108%);
      }

      /* Touch pays most for a backdrop blur and gains least, exactly as the
         bar reasons in textures.css. Same call, same numbers. */
      @media (hover: none), (prefers-reduced-transparency: reduce) {
        .nav-dd__panel {
          -webkit-backdrop-filter: none;
          backdrop-filter: none;
          background: color-mix(in srgb, var(--bg-raised) 96%, transparent);
        }
      }
    }

    /* Opening answers a click, which DESIGN.md allows — it shows what
       changed. Open only; <details> removes the panel on close, so there is
       nothing left to animate out and a fake close would need JS holding the
       element in the DOM for no gain. */
    .nav-dd[open] .nav-dd__panel {
      animation: dd-open .14s var(--ease-settle) both;
    }

    @keyframes dd-open {
      from { opacity: 0; transform: translateY(-2px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    .nav-dd__list {
      margin: 0;
      padding: 0;
      list-style: none;
    }

    .nav-dd__list a {
      display: flex;
      align-items: baseline;
      gap: 12px;
      padding: 9px 12px;
      font-size: 14px;
      color: var(--steel-300);
    }

    .nav-dd__list a:hover,
    .nav-dd__list a:focus-visible {
      color: var(--white);
      background: var(--charcoal);
    }

    .nav-dd__num {
      flex: none;
      font-size: 11px;
      color: var(--steel-700);
      font-variant-numeric: tabular-nums;
    }

    .nav-dd__all {
      display: block;
      margin-top: 6px;
      padding: 11px 12px;
      border-top: 1px solid var(--border);
      font-size: 14px;
      color: var(--steel-100);
    }

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

    @media (prefers-reduced-motion: reduce) {
      .nav-dd[open] .nav-dd__panel { animation: none; }
      .nav-dd__sign::after { transition: none; }
    }

    /* Under 760px the header is logo + one action. The dropdown goes with
       the links rather than becoming a second, differently-behaved menu —
       a real mobile nav is its own job, not a side effect of this one. */
    /* ---------- the hamburger ----------
       Two bars, not three: at this weight a third reads as clutter, and the
       pair rotate into a cross without needing one of them to vanish. */

    .nav-burger {
      display: none;              /* desktop has room for the real nav */
      appearance: none;
      background: none;
      border: 0;
      padding: 10px;
      margin-right: -10px;        /* optical: the bars sit on the gutter */
      cursor: pointer;
      color: var(--steel-100);
    }
    .nav-burger:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

    .nav-burger__box {
      display: block;
      position: relative;
      width: 22px;
      height: 14px;
    }
    .nav-burger__box span {
      position: absolute;
      left: 0;
      width: 100%;
      height: 1px;               /* a hairline, like every other edge here */
      background: currentColor;
      transition: transform var(--dur-base) var(--ease-metal),
                  opacity   var(--dur-quick) linear;
    }
    .nav-burger__box span:first-child { top: 0; }
    .nav-burger__box span:last-child  { bottom: 0; }

    /* open: the two bars meet in the middle and cross */
    [aria-expanded="true"] .nav-burger__box span:first-child {
      transform: translateY(6.5px) rotate(45deg);
    }
    [aria-expanded="true"] .nav-burger__box span:last-child {
      transform: translateY(-6.5px) rotate(-45deg);
    }

    @media (max-width: 760px) {
      .nav-burger { display: block; }

      /* The panel IS the nav element - the same links, restyled. Nothing is
         duplicated into a second menu, so the two can never drift apart. */
      .nav {
        position: fixed;
        top: var(--nav-h);
        left: 0;
        right: 0;
        z-index: 39;              /* under the header, over the page */
        flex-direction: column;
        align-items: stretch;
        gap: 0;
        padding: var(--space-sm) var(--gutter) var(--space-md);
        background: var(--bg);
        border-bottom: 1px solid var(--border);
        max-height: calc(100dvh - var(--nav-h));
        overflow-y: auto;

        /* Closed. visibility keeps it out of the tab order while still
           allowing the slide to animate. */
        visibility: hidden;
        opacity: 0;
        transform: translateY(-8px);
        transition: opacity var(--dur-base) var(--ease-metal),
                    transform var(--dur-base) var(--ease-metal),
                    visibility 0s linear var(--dur-base);
      }

      body.nav-open .nav {
        visibility: visible;
        opacity: 1;
        transform: translateY(0);
        transition-delay: 0s;
      }

      /* Links become rows with a lit top edge, matching the panels. */
      .nav > a,
      .nav .nav-dd {
        border-top: 1px solid var(--border);
      }
      .nav > a { padding: 16px 0; font-size: 17px; }
      .nav .nav-link { color: var(--steel-050); }

      /* The capabilities dropdown stays a <details> here - tapping it opens
         the list in place rather than flying out a panel with nowhere to go. */
      .nav-dd { width: 100%; }
      .nav-dd__toggle { padding: 16px 0; font-size: 17px; }
      .nav-dd__panel {
        position: static;
        width: auto;
        padding: 0 0 8px;
        background: none;
        border: 0;
      }

      /* The one amber element stays the CTA, and it goes full width. */
      .nav .cta-secondary {
        margin-top: var(--space-sm);
        text-align: center;
        padding: 15px 22px;
      }

      body.nav-open { overflow: hidden; }
    }

    @media (max-width: 760px) and (prefers-reduced-motion: reduce) {
      .nav { transition: none; }
    }

    /* ---------- buttons (base classes live in tokens.css) ---------- */

    .cta, .cta-secondary {
      display: inline-block;
      padding: 13px 22px;
      font: inherit;
      font-size: 15px;
      font-weight: 600;
      text-decoration: none;
      cursor: pointer;
      transition: background-color .18s ease, color .18s ease, border-color .18s ease;
    }

    .nav .cta-secondary { padding: 9px 16px; font-size: 14px; }

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

    section { padding: var(--space-xl) 0; }

    .hero {
      padding-top: var(--space-md);
      padding-bottom: var(--space-xl);
    }

    /* The VSL. Separation is a lit top edge, not a drop shadow — the same
       move as .panel in tokens.css. */
    .hero__video {
      position: relative;
      aspect-ratio: 16 / 9;
      margin-bottom: var(--space-md);
      background: var(--bg-raised);
      border-top: 1px solid var(--border-lit);
      border-left: 1px solid var(--border);
      border-right: 1px solid var(--border);
      border-bottom: 1px solid var(--ink);
      overflow: hidden;
    }

    .hero__video > iframe,
    .hero__video > video {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      border: 0;
    }

    /* Visible until a real embed replaces it. Loud on purpose. */
    .hero__video-placeholder {
      position: absolute;
      inset: 0;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 10px;
      padding: 24px;
      text-align: center;
      color: var(--steel-700);
    }

    .hero__video-placeholder strong {
      color: var(--steel-100);
      font-size: 18px;
      letter-spacing: -0.02em;
    }

    .hero h1 { max-width: 15ch; }

    .hero .lead { margin-top: var(--space-sm); }

    .cta-row {
      display: flex;
      gap: 12px;
      flex-wrap: wrap;
      margin-top: var(--space-md);
    }

    .section-head { max-width: 62ch; margin-bottom: var(--space-md); }
    .section-head .lead { margin-top: 20px; }

    /* Explicit columns, not auto-fit: both grids hold 3 or 6 cards, so a
       fixed 3 / 2 / 1 ladder never leaves an empty cell showing the
       hairline background as a solid block. */
    .grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 1px;
      background: var(--border);
      border: 1px solid var(--border);
    }

    @media (max-width: 900px) { .grid { grid-template-columns: repeat(2, 1fr); } }
    @media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }

    /* Cards are separated by lit edges, not fills or shadows. */
    .grid .panel {
      border: none;
      border-top: 1px solid var(--border-lit);
      padding: var(--space-sm) 28px calc(var(--space-sm) + 8px);
    }

    .panel h3 + p { margin-top: 12px; }
    .panel p { font-size: 15px; color: var(--steel-500); }

    .index {
      display: block;
      font-size: 13px;
      font-weight: 600;
      color: var(--steel-900);
      margin-bottom: 14px;
    }

    /* ---------- stats ---------- */

    .stats {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
      gap: var(--space-sm);
      margin-top: var(--space-lg);
      padding-top: var(--space-md);
      border-top: 1px solid var(--border);
    }

    .stat .num {
      display: block;
      font-size: clamp(30px, 3.6vw, 44px);
      font-weight: 800;
      letter-spacing: -0.04em;
      line-height: 1;
      color: var(--steel-050);
    }

    .stat .label {
      display: block;
      margin-top: 10px;
      font-size: 14px;
      color: var(--text-muted);
    }

    /* ---------- punchline band ---------- */

    /* The ground is .tx-bore, in bore.css. Everything here is just the
       box it sits in and the type on top.

       Type is the site's normal h2 stack on purpose. The sandbox demo
       used a serif and it looked better, but a display face is a brand
       decision still marked open in CLAUDE.md, so this does not quietly
       make it. Swap it in one place when that call gets made. */

    .punchline {
      min-height: clamp(460px, 64vh, 720px);
      display: grid;
      place-items: center;
      border-top: 1px solid var(--border);
    }

    .punchline .bore-content {
      text-align: center;
      padding-block: var(--space-md);
    }

    .punchline h2 {
      /* Wider than .contact h2 — this line wants to break where it is
         written, not where the measure runs out. */
      max-width: 20ch;
      margin: 0 auto;
      color: var(--text-strong);
      text-wrap: balance;
    }

    .punchline h2 em {
      font-style: italic;
      color: var(--steel-050);
    }

    /* ---------- contact ---------- */

    .contact {
      border-top: 1px solid var(--border-lit);
      background: var(--bg-raised);
    }

    /* The thermal ground sits behind the hero and is clipped to it. */
    .hero-ground { position: relative; }

    /* The site's third and last metal moment. Thin on purpose: at this size
       it reads as a machined part, and anything taller starts reading as
       decoration. */
    .contact__bar {
      height: clamp(10px, 1.3vw, 15px);
      width: 100%;
      margin-bottom: var(--space-sm);
    }

    .contact h2 { max-width: 18ch; }
    .contact .lead { margin-top: 20px; }

    /* ---------- legal pages ----------
   Privacy, terms and cookies. One shared block rather than a <style> in each
   of the three, which is how the case studies do it - they differ from each
   other, these do not.

   Measure is the whole job here. Body copy elsewhere on the site is short
   enough that .lead's 62ch cap carries it; a policy is the one place someone
   reads several hundred words in a row, so the column is capped at 68ch and
   the headings get real space above them to give the eye somewhere to rest. */

.legal {
  padding: var(--space-lg) 0 var(--space-xl);
  max-width: 68ch;
}

.legal__eyebrow { margin: 0 0 12px; }

.legal h1 { margin: 0 0 12px; }

.legal__date {
  margin: 0 0 var(--space-md);
  font-size: 14px;
  color: var(--steel-700);
}

.legal h2 {
  margin: var(--space-md) 0 14px;
  font-size: clamp(20px, 2.4vw, 26px);
}

/* The summary is a real panel, so the reader can take the gist without the
   rest. Reuses .panel from tokens.css: lit top edge, ink bottom, no shadow.

   Declared AFTER .legal h2 on purpose. Both selectors are one class and one
   type, so they carry identical specificity and source order is the only
   tie-break - written the other way round, the general rule won and the
   summary heading picked up a --space-md top margin it should not have.

   margin-bottom is --space-sm, not --space-lg: the next h2 brings its own
   --space-md above it, and the two stacked read as a hole in the page. */
.legal__summary {
  padding: clamp(20px, 2.4vw, 30px);
  margin-bottom: var(--space-sm);
}

.legal__summary h2 {
  margin: 0 0 10px;
  font-size: clamp(17px, 1.9vw, 20px);
  color: var(--steel-050);
}

.legal__summary p { margin: 0; color: var(--steel-100); }

.legal h3 {
  margin: var(--space-sm) 0 10px;
  font-size: clamp(15px, 1.6vw, 17px);
  color: var(--steel-050);
}

.legal p,
.legal li {
  color: var(--steel-300);
  line-height: 1.75;
}

.legal p { margin: 0 0 16px; }

.legal ul {
  margin: 0 0 16px;
  padding-left: 20px;
}

.legal li { margin-bottom: 10px; }

.legal strong { color: var(--steel-050); font-weight: 600; }

.legal code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .9em;
  padding: 2px 6px;
  color: var(--steel-050);
  background: var(--charcoal);
  border: 1px solid var(--border);
}

.legal a {
  color: var(--steel-100);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.legal a:hover { color: var(--white); }

/* ---------- inline enquiry form ----------
       Fields are --charcoal, which tokens.css nominates for exactly this
       job. Separation is the rim-light move the whole site uses - lit top
       edge, --border sides, --ink bottom - so an input reads as the same
       kind of surface as .panel and .hero__video rather than as a widget
       borrowed from somewhere else. No radius, no inset shadow.

       Labels sit above their field in sentence case. DESIGN.md rules out
       tracked-out all-caps eyebrows, and a form is where that habit
       usually creeps back in.

       Focus needs nothing here: the global :focus-visible rule already
       puts an amber outline on input and textarea. */

    /* One column with a gap rather than margins on each field. The margin
       version left the textarea's label crowding the row above it, because
       the grid children had their margin zeroed and the grid itself carried
       none - the classic collapse this avoids entirely. */
    .cform {
      margin-top: var(--space-md);
      max-width: 760px;
      display: flex;
      flex-direction: column;
      gap: var(--space-sm);
    }

    .cform__grid {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: var(--space-sm) clamp(16px, 2.4vw, 28px);
    }

    .cform__field {
      display: flex;
      flex-direction: column;
      gap: 8px;
      margin: 0;
    }

    .cform__field label {
      font-size: 14px;
      color: var(--steel-500);
    }

    .cform__field input,
    .cform__field textarea {
      font: inherit;
      font-size: 15px;
      width: 100%;
      padding: 12px 14px;
      color: var(--steel-050);
      background: var(--charcoal);
      border-top: 1px solid var(--border-lit);
      border-left: 1px solid var(--border);
      border-right: 1px solid var(--border);
      border-bottom: 1px solid var(--ink);
      border-radius: 0;
      transition: border-color .18s ease, background-color .18s ease;
    }

    .cform__field textarea { resize: vertical; min-height: 118px; }

    .cform__field input::placeholder { color: var(--steel-900); }

    .cform__field input:hover,
    .cform__field textarea:hover { border-top-color: var(--steel-900); }

    .cform__actions {
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: 16px clamp(18px, 2.4vw, 28px);
      margin-top: 4px;
    }

    .cform__note {
      flex: 1 1 320px;
      font-size: 14px;
      line-height: 1.6;
      /* --steel-500, not --steel-700. On the homepage the contact section is
         --bg-raised, where steel-700 measures 4.05 and fails WCAG AA; on the
         inner pages the section is transparent and it scrapes past on --bg at
         4.51. One token that passes on both grounds beats one that depends on
         which page it landed on. */
      color: var(--steel-500);
    }

    .cform__note a { color: var(--steel-100); text-decoration: underline; }
    .cform__note a:hover { color: var(--white); }

    @media (max-width: 640px) {
      .cform__grid { grid-template-columns: 1fr; }
    }

    /* 16px is a hard floor on touch, not a preference. iOS Safari zooms the
       whole page in when a field with a computed font-size under 16px is
       focused, and it does not zoom back out - so tapping "Your name" on an
       iPhone would leave the visitor scrolled sideways inside a magnified
       page for the rest of the form. 15px everywhere else keeps the field
       text matched to the labels beside it.

       Keyed to pointer:coarse rather than a width, because the zoom is a
       property of the input method: a touch tablet in landscape is well past
       any mobile breakpoint and still does it. Same reasoning the texture
       pack uses for its (hover: none) branch. */
    @media (pointer: coarse) {
      .cform__field input,
      .cform__field textarea { font-size: 16px; }
    }

    @media (prefers-reduced-motion: reduce) {
      .cform__field input,
      .cform__field textarea { transition: none; }
    }

    /* ---------- footer ----------
       Modeled on the vaynermedia.com footer: a full-bleed social strip
       across the top, then a block of two link columns with the wordmark
       and a single action right-aligned opposite them, and the copyright
       line underneath.

       Two deliberate departures from the reference:

       1. Vayner's social strip is a saturated accent field. Ours is too,
          but in --petrol rather than an accent colour: a strip of amber
          that size would spend the page's one warm accent on a row of
          icons, and the contact CTA above the footer is what needs it.
          Full reasoning sits on .foot-social below.
       2. Vayner's left column is partner badges — Google, Meta, TikTok,
          Snap. We don't hold those, so that slot is a site nav column. If
          real partner badges ever land, this is where they go. */

    .site-footer {
      border-top: 1px solid var(--border);
    }

    /* Full-bleed by design: it should read as a band across the page, not
       as a row sitting inside the content column. */
    /* A real band, not a tint. Modelled on vaynermedia.com's footer social
       strip, which works because it is a hard full-bleed colour change that
       separates the page from the footer in one move. Ours was --bg-raised
       on --bg: a 12-point step nobody sees, so the strip read as part of the
       footer rather than as the seam above it.

       --petrol, not the obvious --amber. DESIGN.md is explicit that amber
       marks the primary action and nothing else, one per viewport, and the
       footer's own CTA is already the action here - a 50px amber bar beside
       it would cancel it. --paper was the other candidate and is deliberately
       not used either: it is the only light surface on the site and the
       full-colour platform logos are the reason it exists. --petrol is the
       token for "cool depth behind steel elements", it is cold like the rest
       of the page, and it costs nothing from the warm budget.

       Rim light holds: lit top edge, ink bottom, mixed in-family so the
       highlight belongs to the petrol rather than sitting on it as grey. */
    .foot-social {
      background: var(--petrol);
      border-top: 1px solid color-mix(in srgb, var(--petrol) 58%, var(--steel-100));
      border-bottom: 1px solid var(--ink);
    }

    .foot-social__list {
      max-width: var(--wrap);
      margin: 0 auto;
      padding: 14px var(--gutter);
      list-style: none;
      display: flex;
      flex-wrap: wrap;
      justify-content: center;
      gap: clamp(18px, 5vw, 56px);
    }

    /* --steel-050 rather than --steel-500: on petrol the muted value sank
       into the band and the icons stopped reading as marks. */
    .foot-social__link {
      display: block;
      padding: 6px;   /* touch target only — reads as no padding at all */
      color: var(--steel-050);
      transition: color .18s ease;
    }

    .foot-social__link:hover,
    .foot-social__link:focus-visible { color: var(--white); }

    .foot-social__link svg {
      display: block;
      width: 22px;
      height: 22px;
      fill: currentColor;
    }

    /* Link columns left, brand block right, copyright spanning underneath.
       .wrap supplies the horizontal gutter; this only adds the vertical. */
    .foot {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, auto);
      gap: var(--space-sm) clamp(24px, 5vw, 72px);
      align-items: start;
      padding-top: var(--space-md);
      padding-bottom: var(--space-sm);
    }

    .foot-col {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      /* gap is 0 because .foot-link now carries its own vertical padding to
         reach a 44px tap target. Head margin (4px) + the first link's top
         padding (10px) reproduces the 14px that the old 4px + 10px gap gave,
         so the head sits exactly where it did. */
      gap: 0;
    }

    /* Overrides the global h1-h3 rule: at 13px the display tracking of
       -0.035em closes the letters up into a smudge. */
    .foot-col__head {
      margin: 0 0 4px;
      font-size: 13px;
      font-weight: 600;
      line-height: 1;
      letter-spacing: 0;
      color: var(--steel-700);
    }

    /* Google's mobile usability guidance wants a 44-48px tap target and
       WCAG 2.5.8 sets 24px as the hard floor. These measured 25px tall —
       one pixel above an outright failure — because an inline <a> at 15px
       is only as tall as its line box. Padding makes the target real.

       Padding rather than a stretched ::after pseudo-element: these sit
       10px apart in a column, so a 44px overlay on each would overlap its
       neighbours and cause mis-taps, which is worse than a small target. */
    .foot-link {
      display: block;
      padding-block: 10px;          /* 25px line box + 20px = 45px */
      font-size: 15px;
      text-decoration: none;
      color: var(--steel-300);
      transition: color .18s ease;
    }

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

    .foot-brand {
      display: flex;
      flex-direction: column;
      align-items: flex-end;
      gap: 20px;
      text-align: right;
    }

    /* width/height in the markup are the file's real pixels, so the box is
       the right shape before the image lands. */
    .foot-brand__logo {
      display: block;
      width: clamp(170px, 20vw, 240px);
      height: auto;
    }

    .foot-copy {
      grid-column: 1 / -1;
      margin: 0;
      padding-top: var(--space-sm);
      border-top: 1px solid var(--border);
      font-size: 13px;
      line-height: 1.7;
      text-align: right;
      color: var(--steel-700);
    }

    /* ---- legal links, on the copyright line ----
       Modelled on inboundmedia.co, which runs "© INBOUND 2026 | PRIVACY
       POLICY" as one line of small print. theinfiniteagency.com goes further
       and carries no legal links at all, which is not an option here.

       So: the copyright's own 13px, the copyright's own --steel-700, no
       underline, no column heading, no tap-target padding. They read as part
       of the small print rather than as somewhere to go, which is exactly
       what small print is for.

       WHAT WAS DELIBERATELY NOT DONE, and why the brief stopped here:
       they are not styled to look unclickable. --steel-700 on --bg measures
       4.51:1, just over the 4.5 floor AA sets for small text; the hover lift
       stays; and the global :focus-visible outline still finds them. Quiet
       is a style choice. Inconspicuous is a legal one, and it runs straight
       into the reason these pages exist - CCPA wants a "clear and
       conspicuous" privacy link, courts decline to enforce terms nobody
       could reasonably find, and Google Ads and Meta both check for a
       reachable policy before they will run traffic. A link too dead to
       click is a link that costs you the ad account.

       A hairline divider rather than a middle dot: DESIGN.md rules those
       out, and a rule is this site's own way of separating things anyway. */
    .foot-legal {
      display: inline-flex;
      flex-wrap: wrap;
      gap: 0 14px;
      margin-left: 14px;
      padding-left: 14px;
      border-left: 1px solid var(--slate);
    }

    .foot-legal a {
      color: inherit;              /* same value as the copyright beside it */
      text-decoration: none;
      transition: color .18s ease;
    }

    .foot-legal a:hover,
    .foot-legal a:focus-visible { color: var(--steel-050); }

    /* Once the copyright wraps, a left rule on a new line reads as a stray
       mark. Below that it sits under the line instead, still quiet. */
    @media (max-width: 560px) {
      .foot-legal {
        display: flex;
        margin-left: 0;
        padding-left: 0;
        border-left: 0;
        margin-top: 8px;
        justify-content: flex-end;
      }
    }

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

    /* Brand block leads once the three columns stop fitting — the wordmark
       is the thing worth seeing first on a phone, not the nav list. */
    @media (max-width: 860px) {
      .foot { grid-template-columns: repeat(2, minmax(0, 1fr)); }

      .foot-brand {
        order: -1;
        grid-column: 1 / -1;
        align-items: flex-start;
        text-align: left;
      }

      .foot-copy { text-align: left; }
    }

    @media (max-width: 460px) {
      .foot { grid-template-columns: minmax(0, 1fr); }
    }

    @media (prefers-reduced-motion: reduce) {
      .foot-social__link,
      .foot-link { transition: none; }
    }

    /* ---------- slogan ticker ----------
       A rotating strip of slogans in the slot under the nav rule. As with the
       intro, the base state below is the FINISHED state: the marquee is simply
       running and the strip is in place. .is-intro only adds the arrival.

       Two nested transforms, deliberately:
         .ticker__travel  intro only  — rides out with the bullet, comes back
         .ticker__track   forever     — the constant-rate marquee
       They compose. Through the return the travel layer is decelerating hard
       while the track holds its steady pace, so the composite velocity runs
       fast -> slow -> steady drift with no moment where one hands to the other. */

    .ticker {
      position: relative;
      display: flex;
      align-items: stretch;
      border-bottom: 1px solid var(--border);
      background: var(--bg);
    }

    /* Clipping moved off .ticker and onto this, so the pinned CTA is a
       sibling of the scrolling area rather than something inside it that
       would be carried away by the marquee. */
    .ticker__viewport {
      flex: 1;
      min-width: 0;
      overflow: hidden;
    }

    /* The one persistent conversion path on the page. Amber, and the only
       amber in this band — see the accent rule in DESIGN.md. */
    .ticker__cta {
      flex: none;
      display: flex;
      align-items: center;
      padding: 0 clamp(16px, 2.2vw, 28px);
      border-left: 1px solid var(--border);
      background: var(--accent);
      color: var(--ink);
      font-size: 14px;
      font-weight: 700;
      letter-spacing: -0.01em;
      text-decoration: none;
      white-space: nowrap;
      transition: background-color .18s ease;
    }

    .ticker__cta:hover  { background: var(--accent-hover); }
    .ticker__cta:active { background: var(--accent-active); }

    /* Promoted unconditionally. This used to be gated on .is-onstage to save
       texture, but adding and removing will-change CREATES AND DESTROYS the
       compositor layer, and tearing one down while the band is mid-motion
       leaves the last painted frame behind as a ghost - a still copy sitting
       over the moving one. Worst on the inner pages, where the band is the
       only thing on screen. A stable layer costs memory; a thrashing one
       costs correctness. The off-screen saving that actually mattered is the
       skipped gsap.set in bindMarquee(), which is untouched. */
    .ticker__travel { will-change: transform; }

    .ticker__track {
      display: flex;
      width: max-content;
      animation: marquee-left var(--ticker-rate) linear infinite;
      /* Negative delay starts the loop already part-way through, so there is
         always content off to the left. Without it the strip rides out and
         drags an empty gap in behind itself. */
      animation-delay: calc(var(--ticker-rate) * -0.55);
    }

    /* NOTE: deliberately no animation-play-state rule here. motion.js sets
       track.style.animation = 'none' and drives the band from GSAP, so the
       CSS animation only ever runs as the no-JS fallback — and pausing it
       on :not(.is-onstage) would freeze every band permanently with JS off,
       since nothing would be there to add the class. The off-screen saving
       is made in bindMarquee() instead, which is where the motion lives. */

    /* The bands are draggable. pan-y keeps vertical page scrolling working
       on touch while horizontal drags come to us. */
    [data-marquee-viewport] {
      cursor: grab;
      touch-action: pan-y;
    }

    [data-marquee-viewport].is-dragging { cursor: grabbing; }

    /* Without this a drag selects the slogans instead of moving the band. */
    [data-marquee-viewport].is-dragging,
    [data-marquee-viewport].is-dragging * { user-select: none; }

    /* The pinned CTA is a button, not a drag handle. */
    .ticker__cta { cursor: pointer; }

    /* Hover slows the drift to 0.4x rather than stopping it — that is done
       in motion.js via playbackRate, because changing animation-duration
       here would remap the loop's progress and make it jump.

       Keyboard focus still stops it dead. Focus is an explicit "I am using
       this" signal where hover is incidental, and someone tabbing through
       needs the thing to actually hold still. This also keeps a real stop
       available without a pointer. */
    .ticker:focus-within .ticker__track { animation-play-state: paused; }

    .ticker__set {
      display: flex;
      margin: 0;
      padding: 0;
      list-style: none;
    }

    .ticker__set li {
      position: relative;
      flex: none;
      padding: 13px clamp(22px, 3vw, 40px);
      font-size: 14px;
      color: var(--steel-500);
      white-space: nowrap;
    }

    /* The hook — rev share / profit share / skin in the game — is the whole
       pitch, so it opens the loop, returns at the halfway point, and carries
       the emphasis value off the steel ramp. The supporting lines sit a step
       down. Two bright items in ten is the ration; brighten them all and the
       strip flattens into one grey band. */
    .ticker__set li.is-hook {
      color: var(--steel-100);
      font-weight: 600;
    }

    /* Separator is a hairline, not a middle dot — see "Do not" in DESIGN.md. */
    .ticker__set li::before {
      content: "";
      position: absolute;
      left: 0;
      top: 50%;
      width: 1px;
      height: 12px;
      transform: translateY(-50%);
      background: var(--slate);
    }

    /* Two identical sets sit side by side, so translating the track by exactly
       -50% lands the second set where the first began. Shared by all three
       bands — every one of them uses paired sets and no flex `gap`, which is
       what keeps -50% exact. Put a gap on the track and this breaks. */
    @keyframes marquee-left {
      from { transform: translateX(0); }
      to   { transform: translateX(-50%); }
    }

    /* ---------- a glimpse at our work ---------- */

    .work { padding: var(--space-xl) 0; }
    .work__head { margin-bottom: var(--space-md); }

    /* ---------- 404 ----------
       Reuses the page furniture rather than inventing a layout: same wrap,
       same type scale, same single amber CTA. The list is the whole point of
       the page, so it gets the lit-edge separation the panels use instead of
       being a bare <ul>. */
    .notfound { padding-top: var(--space-lg); padding-bottom: var(--space-xl); }

    .notfound__code {
      font-size: 13px;
      letter-spacing: 0.16em;
      color: var(--steel-900);
      margin: 0 0 12px;
    }

    .notfound h1 { max-width: 18ch; }
    .notfound .lead { margin-top: var(--space-sm); }

    .notfound__list {
      list-style: none;
      margin: var(--space-md) 0 0;
      padding: 0;
      display: grid;
      gap: 1px;
      background: var(--border);
      border-top: 1px solid var(--border-lit);
      border-bottom: 1px solid var(--ink);
    }

    .notfound__list li {
      background: var(--bg);
      padding: 18px 20px;
      display: grid;
      gap: 4px;
    }

    .notfound__list a {
      color: var(--steel-050);
      font-size: 17px;
      font-weight: 600;
      letter-spacing: -0.01em;
      text-decoration: none;
    }
    .notfound__list li:hover a { color: var(--white); }
    .notfound__list a:focus-visible { outline: 2px solid var(--steel-100); outline-offset: 3px; }
    .notfound__list span { color: var(--steel-500); font-size: 14px; }


    /* Inline links in body copy. The site sets `a { color: inherit }`
       globally, which is right for cards and nav items wrapped in an anchor
       but leaves a genuine inline link indistinguishable from the sentence
       around it. These get a lit underline instead of a colour change: the
       rim-light move, not a second accent, so the amber stays rationed to
       the CTA. */
    .work__cases { margin-top: var(--space-sm); }

    .work__cases a {
      color: var(--steel-050);
      text-decoration: none;
      border-bottom: 1px solid var(--border-lit);
      padding-bottom: 1px;
      transition: color var(--dur-base) var(--ease-metal),
                  border-color var(--dur-base) var(--ease-metal);
    }
    .work__cases a:hover { color: var(--white); border-color: var(--steel-500); }
    .work__cases a:focus-visible { outline: 2px solid var(--steel-100); outline-offset: 3px; }

    .conveyor,
    .logo-band { overflow: hidden; }

    .conveyor__track,
    .logo-band__track {
      display: flex;
      width: max-content;
    }

    /* Promoted for the life of the page — see the note on .ticker__travel
       above. Gating this on .is-onstage was the cause of the ghosting. */
    .conveyor__track,
    .logo-band__track,
    .ticker__track { will-change: transform; }

    .conveyor__track  { animation: marquee-left var(--conveyor-rate) linear infinite; }
    .logo-band__track { animation: marquee-left var(--logo-rate)     linear infinite; }

    .conveyor__set,
    .logo-band__set {
      display: flex;
      margin: 0;
      padding: 0;
      list-style: none;
    }

    /* Four in view. Width is derived from the viewport rather than set in
       px, so the count stays exactly right at every width instead of
       drifting to three-and-a-bit. No flex `gap` anywhere — the spacing is
       margin on the item, because a gap on the track would break the -50%
       loop. */
    .conveyor {
      --cols: 4;
      --slot-gap: clamp(12px, 1.6vw, 24px);
    }

    .slot {
      flex: none;
      width: calc((100vw - var(--cols) * var(--slot-gap)) / var(--cols));
      /* 3:2, because that is what the photographs are. Cropping 3:2 sources
         into a 4:3 box threw away 11% of every frame for no reason. */
      aspect-ratio: 3 / 2;
      margin-right: var(--slot-gap);
      display: flex;
      align-items: center;
      justify-content: center;
      /* Lit top edge, per DESIGN.md. No drop shadow. */
      background: var(--bg-raised);
      border-top: 1px solid var(--border-lit);
      border-left: 1px solid var(--border);
      border-right: 1px solid var(--border);
      border-bottom: 1px solid var(--ink);
    }

    .slot span {
      color: var(--steel-700);
      font-size: 14px;
    }

    /* cover, not contain: the slot is a window onto the photo, and the crop
       was already chosen per image when the file was generated. */
    .slot img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      display: block;
    }

    @media (max-width: 900px) { .conveyor { --cols: 2; } }
    @media (max-width: 560px) { .conveyor { --cols: 1; } }

    .logo-band {
      margin-top: var(--space-md);
      border-top: 1px solid var(--border);
      border-bottom: 1px solid var(--border);
    }

    /* A stacked lockup matched by HEIGHT reads far smaller than a wide one,
       because almost all of its area is vertical. Matched optically instead. */
    .logo-band__set li img.is-stacked { height: 54px; }

    .logo-band__set li img {
      display: block;
      height: 34px;              /* marks are matched by height, never width */
      width: auto;
      /* Greyscale is not a preference. A green mascot and an orange wordmark
         next to cold steel would each read as a second accent, and the palette
         allows one. Stripped of hue they become texture. */
      filter: grayscale(1) brightness(1.12) contrast(.95);
      opacity: .7;               /* context, not the subject */
      transition: opacity var(--dur-base) var(--ease-metal);
    }
    .logo-band__set li:hover img { opacity: 1; }

    .logo-band__set li {
      flex: none;
      padding: 22px clamp(28px, 4vw, 56px);
      color: var(--steel-700);
      font-size: 15px;
      font-weight: 600;
      letter-spacing: -0.01em;
      white-space: nowrap;
    }

    /* ---------- capabilities ----------
       Separation by hairline, not by a repeated rounded box. The 1px grid
       gap lets the container colour through as the rules, so there is one
       border to change rather than twenty-four. */

    .capabilities,
    .verticals {
      padding: var(--space-xl) 0;
      border-top: 1px solid var(--border);
    }


    /* Same ladder as .grid. Six items divide cleanly by 3, 2 and 1, so no
       breakpoint can leave a hole. */


    /* Hover is a RIM LIGHT, not a fill: a 1px inset highlight on the top
       edge with zero blur, which DESIGN.md names as the other correct form
       of rim light alongside a 1px border. It moved off ::before because
       the arrival sweep now owns that pseudo-element, and the two need to
       be independent - hovering mid-sweep must not fight it.

       Nothing bleeds and no metal is spent. The page already carries its
       three metallic moments and six more would cancel them. */

    /* The index. .tx-etched presses it INTO the surface - a 1px hard offset
       each way, zero blur. This is the home that texture never had: it was
       built for labels and numbers, and the brief rules out the eyebrow
       labels that were the obvious alternative. */

    /* Subheads step down the steel ramp. --white is rationed to headlines;
       six of them at full white would flatten the hierarchy. */


    /* ---------- who we work with: the vertical cards ----------
       Each card is one <a>. The whole tile is the target, so there is no
       second click target inside it and nothing to miss on a phone.

       Built on .panel's rule, not a fill: lit top edge, --border down the
       sides, --ink along the bottom so it falls away into the page. Square
       corners, because the only border-radius on this site is the 50% on
       two circles and DESIGN.md rules out a single radius applied to
       everything. */

    /* ---- the rail ----------------------------------------------------
       A scroll-snap strip, not a grid. Native scrolling does the dragging,
       the momentum and the touch handling, which means no pointer maths to
       get wrong and no library to load: trackpad, touch, shift-wheel and
       the keyboard all work because the browser already knows how.

       With JS off this is the whole component - six readable cards you can
       scroll. vertical-rail.js only adds the depth falloff and the arrows. */
    .vrail {
      display: flex;
      gap: var(--space-sm);
      margin: var(--space-md) 0 0;
      padding: 0;
      list-style: none;
      overflow-x: auto;
      overscroll-behavior-x: contain;
      /* proximity, NOT mandatory. Mandatory has to land on a snap point at
         all times, so on load it immediately ate the centring padding below
         and pulled a single card to the middle - the exact thing the
         padding exists to prevent. Proximity leaves the two-card straddle a
         stable resting position and still settles a near-miss. */
      scroll-snap-type: x proximity;
      /* No leading pad. Centring a pair inside the rail meant 232px of
         dead space before the first card, which read as a broken margin
         rather than as a carousel. The cards start on the same line as the
         heading; what moves instead is the point the falloff measures from,
         which vertical-rail.js puts over the leading pair. See --k there.

         The trailing pad is what lets the LAST pair reach that point. */
      padding-inline-start: 0;
      padding-inline-end: max(0px, calc(100% - 2 * var(--vcard-w) - var(--space-sm)));
      scrollbar-width: none;
      /* Room for the lifted centre cards to rise into without clipping. */
      padding-block: 18px;
    }
    .vrail::-webkit-scrollbar { display: none; }

    .vrail > li {
      flex: 0 0 var(--vcard-w);
      scroll-snap-align: center;
      display: flex;
    }

    /* Sized down from the grid it replaced: two of these sit in the middle
       at 1440 with the neighbour showing at the edge, which is what makes it
       read as a rail rather than a row that happens to scroll.

       Declared on the component, not :root. :root is the site's token
       layer; a card width is this component's business, the same way
       .conveyor keeps --cols and --slot-gap to itself. */
    .verticals { --vcard-w: clamp(258px, 27vw, 384px); }

    /* --k is published per card by vertical-rail.js: 0 in the middle of the
       rail, 1 out at the edges. Everything below is that one number spent
       three ways - the centre pair sit forward, full strength and lifted;
       everything else sinks, dims and steps back.

       transform and opacity only, so the whole falloff runs on the
       compositor and a drag never touches layout. */
    .vcard {
      --k: 0;
      display: flex;
      flex-direction: column;
      width: 100%;
      background: var(--bg-raised);
      transform: scale(calc(1 - 0.11 * var(--k, 0)))
                 translateY(calc(14px * var(--k, 0)));
      opacity: calc(1 - 0.58 * var(--k, 0));
      /* --dur-base, not --dur-slow. textures.css reserves --dur-slow (1.15s)
         for "one orchestrated moment, used sparingly", and this is the
         opposite kind of motion: --k is retargeted every frame the rail
         scrolls, so a 1.15s transition damps the cards a full second behind
         the drag. 0.42s tracks the pointer with just enough lag to read as
         weight - the same argument the reactive chrome makes at 0.3s. */
      transition: transform var(--dur-base, .42s) var(--ease-metal, ease-out),
                  opacity var(--dur-base, .42s) var(--ease-metal, ease-out),
                  border-top-color var(--dur-base, .42s) var(--ease-metal, ease-out);
      will-change: transform, opacity;
      border-top: 1px solid var(--border-lit);
      border-left: 1px solid var(--border);
      border-right: 1px solid var(--border);
      border-bottom: 1px solid var(--ink);
    }

    /* The lit top edge is the one thing that gets BRIGHTER at the centre
       rather than just less dim. Rim light, per DESIGN.md - a 1px edge, no
       blur, nothing bleeding into the space around it. */
    .vrail .vcard { border-top-color: color-mix(in srgb, var(--border-lit), var(--steel-100) calc((1 - var(--k, 0)) * 100%)); }

    /* No hover state, and no cursor change: these cards do not go anywhere
       yet. A tile that lights up under the pointer promises a click it
       cannot honour, which is the same rule the footer's social icons are
       waiting on. When the vertical pages exist, wrap each card's contents
       in an <a> and the hover treatment comes back with it. */

    /* Reduced motion gets the rail and none of the depth: every card at
       full size and full strength, scrolling and nothing else. */
    @media (prefers-reduced-motion: reduce) {
      .vcard {
        transform: none;
        opacity: 1;
        transition: none;
      }
      .vrail { scroll-behavior: auto; }
    }

    /* ---- arrows ----
       Steel, not amber. Secondary actions do not get warmth, and the page
       spends its one amber element on the CTA. */
    .vrail-nav {
      display: flex;
      gap: 8px;
      margin-top: var(--space-sm);
    }

    .vrail-btn {
      width: 44px;
      height: 44px;
      display: grid;
      place-items: center;
      background: transparent;
      color: var(--steel-100);
      border: 1px solid var(--border-lit);
      cursor: pointer;
      transition: color var(--dur-base) var(--ease-metal),
                  border-color var(--dur-base) var(--ease-metal);
    }
    .vrail-btn svg { width: 20px; height: 20px; stroke-width: 1.5; }
    .vrail-btn:hover:not(:disabled) { color: var(--white); border-color: var(--steel-700); }
    .vrail-btn:disabled { color: var(--steel-900); border-color: var(--border); cursor: default; }

    /* ---- media: photo and icon share every dimension ----
       The two treatments have to read as one system, so the box is defined
       here once and the children differ, never the frame. */
    .vcard__media {
      display: block;
      aspect-ratio: 16 / 9;
      border-bottom: 1px solid var(--border);
      background: var(--charcoal);
      overflow: hidden;
    }

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

    /* The no-photo treatment. Same box, same hairline, one centred outline
       mark. Deliberately not a placeholder: nothing here is waiting on an
       asset. */
    .vcard__media--mark {
      display: grid;
      place-items: center;
    }

    .vcard__mark {
      width: 68px;
      height: 68px;
      /* Thinner as it scales up: 2 is Tabler's weight at 24px, and holding
         it at 84px turns a line drawing into a slab. */
      stroke-width: 1;
      color: var(--steel-500);
    }

    .vcard__body {
      display: block;
      /* The bottom padding lives here, not on the footer's margin, because
         the footer's top margin is auto and auto collapses to 0 the moment
         the copy fills the card - which would butt the tags into the rule. */
      padding: 22px 20px 20px;
    }

    /* An <h3>, not a <span>, since the merge. Six industry names is six
       commercial-intent phrases, and as spans they carried no structural
       weight under the section's h2 - the same finding as the capability
       titles. margin is the only thing a heading adds that a span did not,
       so it is the only thing reset. Rendered output is unchanged. */
    /* The name is what the card is scanned for, so it takes the house
       heading treatment rather than overriding it down to something
       lighter than the body copy underneath it. 800 and --text-strong are
       what every other h1/h2/h3 on the site carries; this was the only
       heading opting out. */
    .vcard__name {
      margin: 0 0 12px;
      display: block;
      font-size: clamp(18px, 1.7vw, 22px);
      font-weight: 800;
      letter-spacing: -0.035em;
      line-height: 1.1;
      color: var(--text-strong);
    }

    /* Problem and fix are both body copy, so both sit on the text ramp.
       The fix is the one that has to land, so it takes the brighter step. */
    .vcard__problem {
      display: block;
      font-size: 15px;
      line-height: 1.55;
      color: var(--text);
      margin-bottom: 10px;
    }

    .vcard__fix {
      display: block;
      font-size: 15px;
      line-height: 1.55;
      color: var(--steel-100);
    }

    /* ---- capability tags ----
       Steel, not amber: warmth is spent once per viewport and the page
       already spends it on the CTA. Sentence case, because the brief rules
       out tracked-out all-caps labels. */
    .vcard__tags {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      margin-top: 16px;
    }

    .vtag {
      font-size: 12px;
      line-height: 1;
      color: var(--steel-500);
      border: 1px solid var(--border);
      padding: 6px 9px;
    }

    /* ---- footer row ----
       margin-top:auto pins it to the bottom edge, so the hairline lands on
       the same line across a row however long the copy above it runs. */
    /* margin-top:auto pins this to the bottom of the card. The grid already
       stretches every card in a row to the same height, but without this the
       footer floats up behind whichever card has the shortest copy, and the
       hairline lands at three different heights across the row. */
    /* Sits beside .vcard__scope in the foot's existing flex row, which
       already had the gap for a second child. A lit underline rather than a
       colour change: the rim-light move, so the amber stays on the CTA. */
    .vcard__case {
      flex: none;
      margin-left: auto;
      font-size: 12px;
      color: var(--steel-100);
      text-decoration: none;
      border-bottom: 1px solid var(--border-lit);
      padding-bottom: 1px;
      transition: color var(--dur-base) var(--ease-metal),
                  border-color var(--dur-base) var(--ease-metal);
    }
    .vcard__case:hover { color: var(--white); border-color: var(--steel-500); }
    .vcard__case:focus-visible { outline: 2px solid var(--steel-100); outline-offset: 3px; }

    .vcard__foot {
      display: flex;
      align-items: baseline;
      gap: 16px;
      margin: auto 20px 0;
      padding: 13px 0 20px;
      border-top: 1px solid var(--border);
    }

    .vcard__scope {
      font-size: 12px;
      /* --steel-500, not --text-muted (= --steel-700). The cards sit on
         --bg-raised, where steel-700 measures 4.05 and fails WCAG AA. Same
         step as .tq-what and .cform__note: a token that clears AA on the
         page ground by 0.01 does not clear it on the raised one. */
      color: var(--steel-500);
    }

    @media (max-width: 380px) {
      .vcard__body,
      .vcard__foot { padding-left: 0; padding-right: 0; }
      .vcard__body { padding: var(--space-sm) 18px 0; }
      .vcard__foot { margin-left: 18px; margin-right: 18px; }
    }

    /* The line under the grid. Not a card and deliberately not styled as
       one: it widens the frame rather than adding a seventh vertical. */
    .vgrid-note {
      margin: var(--space-md) 0 0;
      padding-top: var(--space-sm);
      border-top: 1px solid var(--border);
      font-size: clamp(15px, 1.2vw, 17px);
      line-height: 1.6;
      color: var(--steel-100);
      max-width: 72ch;
    }
    .vgrid-note a { color: var(--white); }

    /* ---------- who we work with ----------
       This began as a grid sharing .cap-grid's rules with the capabilities
       section. That section is no longer on this page - the rail replaced
       it - and .cap-grid went with it, so these rules stand on their own
       now and the sweep that keyed off it is gone. Nothing here inherits;
       what the rail needs is declared here. */

    .verticals .lead { margin-top: 20px; }

    /* The problem line is BODY COPY, not meta, so it sits at --text on the
       steel ramp. It reads as a caption in this slot and the temptation is
       to hand it .meta, but DESIGN.md puts --steel-700 and darker off
       limits for anything you actually have to read - it is for labels. */
    .vert-line {
      color: var(--text);
      font-size: 15px;
      line-height: 1.55;
      margin-bottom: 12px;
    }

    /* The problem lines run to two or three lines depending on the vertical,
       which left the scope rules sitting at three different heights across a
       row - a lattice with a ragged inner edge. Making the cell a column and
       pushing the scope down with margin-top:auto lands every rule on the
       same line, so the grid reads as one ruled surface rather than six
       independently sized boxes. */

    /* The sub-verticals underneath ARE meta - scanned, not read - so this
       one is correctly on --text-muted. Commas rather than middle dots:
       the brief rules dotted meta strings out by name. */
    .vert-scope {
      margin-top: auto;
      color: var(--text-muted);
      line-height: 1.5;
      border-top: 1px solid var(--border);
      padding-top: 12px;
    }

    /* At 1-up the cards are full width, so the problem line would run to
       the ~90ch the viewport allows. Body copy is capped at 62ch site-wide
       for the same reason. */
    @media (max-width: 600px) {
      .vert-line { max-width: 62ch; }
    }


    /* ---------- the capabilities arrival sweep ----------
       Six gold hairlines, one per cell, lit in reading order and let go as
       the pass moves on. One orchestrated moment, on arrival, once.

       This replaced a beam that walked the grid's own hairlines on a route
       of hardcoded percentages - 33.3% for the verticals, 50% for the
       middle line. That only ever described a 3-column, 2-row grid, so it
       drew lines through the middle of the cells at every other width, and
       it was measured against a wrapper that also contained the "more"
       link, which pushed it off the row line even at 3-up.

       Nothing here is positioned. The rim is the cell's own top edge, so
       there is no geometry to get wrong: 3-up, 2-up or 1-up, it lands on
       the same edges, and adding a seventh capability needs no new numbers.

       Only opacity animates, on a pseudo-element that is 1px tall. */


    /* The warmth left behind. Very low: six lit cells at any real strength
       would be six amber elements, and the palette allows one. */

    /* The sweep never runs under reduced motion - initGridSweep bails before
       it constructs anything - so both values stay at 0 on their own. This
       is belt and braces for the case where something else sets them. */
    @media (prefers-reduced-motion: reduce) {
    }

    /* ==========================================================
       INTRO — the bullet
       Base state below is the FINISHED state. The animation only
       runs while <body class="tx-grain"> carries .is-intro, which JS adds. No JS,
       or reduced motion, and the page is simply already correct.
       ========================================================== */

    .intro-layer {
      position: fixed;
      inset: 0;
      z-index: 60;
      pointer-events: none;
      display: none;
      overflow: hidden;
    }

    body.is-intro .intro-layer { display: block; }

    /* -- the muzzle blip: two hard bars crossing. no blur. -- */
    .blip {
      position: absolute;
      top: var(--nav-h);
      left: 0;
      width: 1px;
      height: 1px;
      opacity: 0;
    }

    .blip::before,
    .blip::after {
      content: "";
      position: absolute;
      left: 50%;
      top: 50%;
      background: var(--white);
      transform: translate(-50%, -50%);
    }

    .blip::before { width: 60px; height: 1px; }
    .blip::after  { width: 1px;  height: 18px; }

    /* -- the bullet: a tapered steel shard -- */
    .bullet {
      position: absolute;
      top: calc(var(--nav-h) - 10px);
      left: 0;
      width: 150px;
      height: 20px;
      transform: translateX(-260px);
      will-change: transform;
    }

    .bullet__body,
    .bullet__glint {
      position: absolute;
      inset: 0;
      clip-path: polygon(100% 50%, 76% 0, 0 0, 0 100%, 76% 100%);
    }

    /* Cylindrical metal: dark top, hot band at the centre line, dark base. */
    /* Chrome, not plastic.

       Every stop is PAIRED at an identical percentage, which produces a hard
       edge with no blend between bands. Smooth gradients read as plastic;
       abrupt value jumps read as metal, because real chrome reflects a
       cut-up environment rather than a soft falloff.

       Neutral --chrome-* ramp only (see "Two metal ramps" in DESIGN.md).
       Tonal split is roughly 36% shadow / 46% midtone / 19% highlight, with
       only 5% at the brightest band. Most of chrome is dark. */
    .bullet__body {
      background: linear-gradient(
        to bottom,
        var(--chrome-900) calc(  0% + var(--lo)), var(--chrome-900) calc(  5% + var(--lo)),
        var(--chrome-700) calc(  5% + var(--lo)), var(--chrome-700) calc( 22% + var(--lo)),
        var(--chrome-500) calc( 22% + var(--lo)), var(--chrome-500) calc( 33% + var(--lo)),
        var(--chrome-400) calc( 33% + var(--lo)), var(--chrome-400) calc( 45% + var(--lo)),
        var(--chrome-300) calc( 45% + var(--lo)), var(--chrome-300) calc( 51% + var(--lo)),
        var(--chrome-100) calc( 51% + var(--lo)), var(--chrome-100) calc( 58% + var(--lo)),
        var(--chrome-050) calc( 58% + var(--lo)), var(--chrome-050) calc( 63% + var(--lo)),
        var(--chrome-100) calc( 63% + var(--lo)), var(--chrome-100) calc( 70% + var(--lo)),
        var(--chrome-300) calc( 70% + var(--lo)), var(--chrome-300) calc( 78% + var(--lo)),
        var(--chrome-500) calc( 78% + var(--lo)), var(--chrome-500) calc( 86% + var(--lo)),
        var(--chrome-700) calc( 86% + var(--lo)), var(--chrome-700) calc( 96% + var(--lo)),
        var(--chrome-900) calc( 96% + var(--lo)), var(--chrome-900) 100%
      );
    }

    /* The airstream behind it. */
    .bullet__wake {
      position: absolute;
      right: 88%;
      top: 50%;
      width: 260px;
      height: 2px;
      transform: translateY(-50%);
      background: linear-gradient(
        to right,
        color-mix(in srgb, var(--steel-500) 0%, transparent),
        color-mix(in srgb, var(--steel-500) 55%, transparent) 70%,
        var(--steel-100)
      );
    }

    /* Friction heat building at the nose. */
    .bullet__heat {
      position: absolute;
      right: -1px;
      top: 50%;
      width: 72px;
      height: 20px;
      transform: translateY(-50%);
      clip-path: polygon(100% 50%, 44% 0, 0 22%, 0 78%, 44% 100%);
      background: linear-gradient(
        to left,
        var(--gold)        0%,
        var(--amber)      30%,
        var(--amber-burnt) 58%,
        color-mix(in srgb, var(--amber-burnt) 0%, transparent) 100%
      );
      opacity: 0;
    }

    /* Hard specular band — a sharp edge of light, not a bloom. */
    .bullet__glint {
      background: linear-gradient(
        112deg,
        color-mix(in srgb, var(--gold) 0%, transparent) 42%,
        var(--gold)  47%,
        var(--amber) 53%,
        color-mix(in srgb, var(--amber) 0%, transparent) 58%
      );
      opacity: 0;
    }

    /* ---------- intro: the animations ---------- */

    body.is-intro .site-header__rule {
      animation: rule-draw var(--t-fly) linear var(--t-fire) both;
    }

    body.is-intro .blip {
      animation: blip .18s ease-out var(--t-fire) both;
    }

    body.is-intro .bullet {
      animation: fly var(--t-fly) linear var(--t-fire) both;
    }

    body.is-intro .bullet__heat {
      animation: heat .60s ease-in .34s both;
    }

    body.is-intro .bullet__glint {
      animation: glint .2s linear var(--t-glint) both;
    }

    body.is-intro .logo {
      animation: logo-home .70s var(--ease-settle) var(--t-shift) both;
    }

    /* Two phases, two animations, because each needs its own easing — a single
       keyframe set with per-keyframe timing functions did not hold, and the
       return measured flat instead of decelerating.

       They never overlap, and the fill modes are what keep them apart:
       ticker-home is `forwards` and deliberately NOT `backwards`, so through
       its 1.15s delay it does not apply at all and ticker-ride owns the
       transform. From --t-shift it is active and, being later in the list,
       wins — which is the same instant the logo starts home. */
    body.is-intro .ticker__travel {
      animation:
        ticker-ride var(--t-fly) linear var(--t-fire) both,
        ticker-home 1.10s var(--ease-recoil) var(--t-shift) forwards;
    }

    body.is-intro .logo__inner {
      animation: name-wipe .26s var(--ease-settle) var(--t-name) both;
    }

    /* The banner CTA arrives on the nav's beat rather than riding out with
       the bullet — it is pinned, so travelling would read as a mistake. */
    body.is-intro .ticker__cta {
      animation: nav-in .40s ease both;
      animation-delay: calc(var(--t-nav) + .24s);
    }

    body.is-intro .nav > * {
      animation: nav-in .40s ease both;
      animation-delay: var(--t-nav);
    }
    body.is-intro .nav > *:nth-child(2) { animation-delay: calc(var(--t-nav) + .06s); }
    body.is-intro .nav > *:nth-child(3) { animation-delay: calc(var(--t-nav) + .12s); }
    body.is-intro .nav > *:nth-child(4) { animation-delay: calc(var(--t-nav) + .18s); }

    /* `backwards`, not `both`. The two are visually identical here, because
       page-in's `to` state (opacity 1) is already the base state — backwards
       still holds opacity 0 through the --t-page delay, which is the only
       part of the fill that is doing any work.

       `both` costs 1.6s of LCP. A forwards-filling animation never leaves its
       active phase, so as far as Chrome is concerned <main> has an opacity
       animation running on it right up until .is-intro comes off at 2400ms,
       and it will not report a largest-contentful-paint underneath one.
       LCP landed at ~2470ms — the moment the class was removed — rather than
       when the text actually became legible at ~900ms. */
    body.is-intro main,
    body.is-intro .site-footer {
      animation: page-in .55s var(--ease-settle) var(--t-page) backwards;
    }

    @keyframes rule-draw {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }

    @keyframes blip {
      0%   { opacity: 0; transform: scale(.25); }
      22%  { opacity: 1; transform: scale(1); }
      100% { opacity: 0; transform: scale(1.6); }
    }

    @keyframes fly {
      from { transform: translateX(-260px); }
      to   { transform: translateX(calc(100vw + 260px)); }
    }

    @keyframes heat {
      from { opacity: 0; }
      to   { opacity: 1; }
    }

    @keyframes glint {
      0%   { opacity: 0; transform: translateX(-70%); }
      30%  { opacity: 1; }
      100% { opacity: 0; transform: translateX(70%); }
    }

    /* Out with the bullet: same direction, same linear pace, fading up. */
    @keyframes ticker-ride {
      0%   { opacity: 0; transform: translateX(0); }
      70%  { opacity: 1; }
      100% { opacity: 1; transform: translateX(var(--ticker-ride)); }
    }

    /* Back with the logo. Leaves at the bullet's pace and decays for a full
       second; that tail is what hands over to the marquee unnoticed. */
    @keyframes ticker-home {
      from { transform: translateX(var(--ticker-ride)); }
      to   { transform: translateX(0); }
    }

    /* Logo starts centred on screen, then travels to its resting place. */
    @keyframes logo-home {
      from { transform: translateX(var(--logo-shift)); }
      to   { transform: translateX(0); }
    }

    /* The name is wiped in left-to-right, in the bullet's direction. */
    @keyframes name-wipe {
      from { clip-path: inset(0 100% 0 0); }
      to   { clip-path: inset(0 0 0 0); }
    }

    @keyframes nav-in {
      from { opacity: 0; }
      to   { opacity: 1; }
    }

    @keyframes page-in {
      from { opacity: 0; }
      to   { opacity: 1; }
    }

    /* ---------- reduced motion: no intro at all ---------- */

    @media (prefers-reduced-motion: reduce) {
      html { scroll-behavior: auto; }
      .intro-layer { display: none !important; }
      /* The marquees run off the base stylesheet, not off .is-intro, so the
         blanket rule below does not reach them. Stop all three by attribute,
         which also covers any band added later. */
      [data-marquee-track] { animation: none !important; }
      body.is-intro *,
      body.is-intro *::before,
      body.is-intro *::after {
        animation: none !important;
        transition: none !important;
      }
    }


/* Route from the home page's six capabilities to the full tab. */
.cap-more { margin: var(--space-sm) 0 0; }


    /* ---------- case study art ----------
       .case-art is a 21:9 frame declared inline on each case page, but no
       rule ever sized the image inside it - so a 1200x800 photo sat there
       at natural size and spilled out of the frame, cropped by wherever the
       flex centring happened to land. cover fits it to the frame instead. */
    .case-art img,
    .case-art--wide img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      display: block;
    }

    /* The crew shot is a group portrait, so it gets a squarer frame than the
       21:9 hero - five faces in a letterbox lose their heads. */
    .case-art--wide { aspect-ratio: 3 / 2; }


/* ---------- companies who trust us ----------
   Inbound's client band, which is a heading, one line, and then just the
   logos. No cells, no boxes, no rules — the marks sit on the page with air
   around them and nothing else competing.

   Every logo is pre-keyed to a flat white silhouette on transparency, so
   there is no filter doing the monochrome work at runtime and no logo
   arriving with its own brand colour. Opacity is the only variable. */

.clients { padding: var(--space-xl) 0; border-top: 1px solid var(--border); }
.clients__head { max-width: 58ch; margin-bottom: var(--space-lg); }
.clients__head .lead { margin-top: var(--space-sm); }

.client-wall {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: clamp(38px, 6vw, 86px);
  margin: 0;
  padding: 0;
  list-style: none;
}

.client-wall img {
  display: block;
  width: auto;
  opacity: .48;
  transition: opacity .3s ease;
}

.client-wall li:hover img { opacity: 1; }

/* Optical sizing, not mechanical. A wide wordmark and a stacked mark set to
   the same height do not read as the same size, so each shape gets its own
   cap. This is the difference between a logo wall and a row of mismatched
   images. */
.client-wall .is-wide  { max-height: clamp(22px, 2.4vw, 30px); }
.client-wall .is-mid   { max-height: clamp(28px, 3vw, 38px); }
.client-wall .is-tall  { max-height: clamp(46px, 5vw, 62px); }

@media (max-width: 620px) {
  .client-wall { gap: 34px 42px; }
}

/* ==========================================================
   Proficient in
   Structure mapped from inboundmedia.co's press band: centred
   head, one line under it, then a 3-up grid of marks wrapping
   to two rows. The band now carries ad platforms rather than
   publications; the class names still say press (see the note
   in index.html).

   This is the only light band on the site, and the logos are
   the reason. They are full-colour brand marks - Meta's blue,
   Google's four, YouTube red, TikTok's cyan/magenta - which
   cannot be keyed to one value the way the client wall's are
   without destroying the thing that identifies them. On --bg
   each one would need its own light box, which is six boxes
   and no band. One light surface for the whole section is the
   cheaper answer, and it is what the reference does.

   The colour change is also what separates this band from the
   sections either side, so it carries no hairline border - the
   same trade the spacing comment at the top of this file
   describes the reference making.
   ========================================================== */

.press {
  padding: var(--space-xl, 96px) 0;
  background: var(--paper, #F2F0EA);
  /* The marks fly in from off the viewport edges. Clipping here rather than
     leaning on body{overflow-x:hidden} keeps the travel contained to this
     band, so nothing can widen the document mid-flight. */
  overflow: hidden;
}

.press__head {
  max-width: 62ch;
  margin: 0 auto var(--space-lg, 72px);
  text-align: center;
}

/* Dark type on the light band. --charcoal rather than --ink, which
   DESIGN.md reserves for linework on light. */
.press h2 { color: var(--charcoal, #202222); }

/* .lead is capped at 62ch and left-aligned by default; both have to be
   undone or the line sits off-centre under a centred heading. */
.press__head .lead {
  max-width: none;
  margin-top: var(--space-sm, 28px);
  color: var(--steel-900, #50636E);
}

/* TWO columns, not three, and the reveal is the reason. The marks arrive in
   pairs that converge from opposite edges, so each pair has to be a row - on
   a 3-up grid the pairs straddle rows and the two halves cross the full width
   past each other, which reads as a mistake rather than as a meeting.

   Six marks still divide cleanly by 2 and 1, so no breakpoint leaves a hole.
   Row gap is wider than it was: three rows of evenly-capped marks need more
   air between them than two rows of mixed heights did. */
.press-wall {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  align-items: center;
  justify-items: center;
  gap: clamp(30px, 3.6vw, 52px) clamp(30px, 4vw, 64px);
  /* Held well inside .wrap. On the full 1360px column the two marks in a row
     finish about 690px apart with nothing between them, which reads as two
     separate lists rather than as a pair that just met. */
  max-width: 940px;
  margin: 0 auto;
  padding: 0;
  list-style: none;
}

/* Sized by HEIGHT, with width only as an upper bound. This used to cap
   width alone, which worked while all six crops sat within a narrow band
   of aspect ratios. The current marks do not: Meta is 4.96:1 and Google
   is 2.96:1, so one width cap renders Google about 68% taller than Meta
   and the row stops reading as a set. Capping the height is how a logo
   wall is normally balanced - it is what .client-wall already does - and
   it makes optical size independent of how long each wordmark happens
   to be.

   Both caps stay below natural size, so every mark is still downscaled
   and none is ever soft. width/height on the <img> hold the aspect ratio
   so the band does not jump while the images load.

   No opacity dimming and no hover lift here. That treatment belongs to
   the client wall, where the marks are one keyed value; dimming a
   full-colour logo just makes it look faded. */
.press-wall img {
  display: block;
  width: auto;
  height: auto;
  max-width: clamp(150px, 18vw, 230px);
  max-height: clamp(32px, 3.4vw, 46px);
}

/* One column below 560px. The pairs stop being pairs there, but the reveal
   still alternates sides item by item, so it keeps reading as deliberate. */
@media (max-width: 560px) {
  .press-wall { grid-template-columns: 1fr; }
}

/* ==========================================================
   Testimonials band
   Structure mapped from inboundmedia.co's .testimonials-sec.

   Their measurements, kept: 860px quote column, 28px quote at
   1.36 line-height, 73px round author mark, 14px attribution
   tracked to 0.1em, 5px dots on 8px centres, 500ms ease-in-out
   cross-fade. Their colours, not kept — see tokens.css.

   Two states in one stylesheet:
     - default        : every quote stacked and readable. This is
                        what you get with JS off, and it is not a
                        broken version of anything.
     - .is-slider     : added by testimonial-slider.js. Slides
                        collapse onto one grid cell and cross-fade.

   The grid-stack is deliberate. Absolutely positioning the slides
   would need a measured height on the viewport and would jump when
   a long quote follows a short one; putting every slide in
   `grid-area: 1/1` makes the container size itself to the tallest
   quote once, with no JS measurement and no reflow on change.
   ========================================================== */

.testimonials {
  /* --space-lg, not the --space-xl every other section gets. This band is a
     single centred block rather than a heading plus content, so the same
     padding reads as far more air around far less structure - 109px top and
     bottom against one paragraph. --space-lg also lands on the reference's
     own 90px. */
  padding: var(--space-lg) 0;
  /* Panel doctrine from tokens.css, run full-bleed: lit top edge,
     bottom edge falls away into the page. No shadow. */
  background: var(--bg-raised);
  border-top: 1px solid var(--border-lit);
  border-bottom: 1px solid var(--ink);
}

.tq-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Stacked fallback: quotes need air between them, and a hairline so
   two centred paragraphs do not read as one run-on. The slider state
   below strips both, because then only one is ever on screen. */
/* Applied twice over - margin and padding - so this is the gap between two
   stacked quotes, not half of it. At --space-lg that was 176px. */
.tq-item + .tq-item {
  margin-top: var(--space-md);
  padding-top: var(--space-md);
  border-top: 1px solid var(--border);
}

.tq-fig {
  margin: 0;
  text-align: center;
}

.tq-quote {
  max-width: 860px;
  margin: 0 auto;
}

.tq-quote p {
  margin: 0;
  /* Lands on the reference's 28px at desktop and steps down cleanly
     rather than at a breakpoint. */
  font-size: clamp(19px, 1.9vw, 28px);
  line-height: 1.36;
  /* Stepped down from --white on purpose. Rule 2 in DESIGN.md: white
     is for headlines, and a quote this long set at peak white would
     out-shout the h2 it sits under. --steel-050 is the subhead step. */
  color: var(--steel-050);
  letter-spacing: -0.01em;
  text-wrap: pretty;
}

/* The attribution belongs to the quote above it, so it sits close enough to
   read as attached to it. At --space-md the 73px mark floated alone in the
   middle of the band with 64px of nothing on either side, which is what made
   the section feel empty rather than composed. */
.tq-by {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  margin-top: var(--space-sm);
}

/* The reference's round author photo. Holding initials until real
   images exist — same box either way, so dropping an <img> in here
   changes nothing else. */
.tq-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 73px;
  height: 73px;
  margin-bottom: 12px;
  border-radius: 50%;
  background: var(--charcoal);
  /* Rim light, not glow: a lit top edge tight to the form. */
  border-top: 1px solid var(--border-lit);
  border-left: 1px solid var(--border);
  border-right: 1px solid var(--border);
  border-bottom: 1px solid var(--ink);
  color: var(--steel-500);
  font-size: 15px;
  letter-spacing: 0.08em;
  overflow: hidden;
}

.tq-mark img { width: 100%; height: 100%; object-fit: cover; }

.tq-who {
  color: var(--steel-300);
  font-size: 14px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

/* Meta, not body copy. --steel-500 rather than the --steel-700 the ramp
   would suggest, because this band is --bg-raised, not --bg: steel-700
   clears WCAG AA against the page ground by 0.01 (4.51 against 4.50) and
   fails against the raised one at 4.05. steel-500 is 5.75 there and still
   reads a clear step below the attribution above it. */
.tq-what {
  color: var(--steel-500);
  font-size: 14px;
}

/* ---------- slider state ---------- */

.tq.is-slider .tq-list {
  display: grid;
  /* Height is set per slide by the script and eased between them, which is
     what the reference does. Without it the grid cell sizes to the TALLEST
     quote and every shorter one leaves its difference as dead space under
     the attribution - 111px of it between the longest and shortest of the
     four, which reads as a bug rather than as a slider.

     No height here on purpose: until the script sets one the cell is auto,
     i.e. the tallest slide, which is the safe fallback. auto does not
     interpolate, so the first assignment snaps instead of animating. */
  overflow: hidden;
  transition: height .5s ease-in-out;
}

.tq.is-slider .tq-item {
  grid-area: 1 / 1;
  /* Natural height, not stretched to the cell - the script measures this. */
  align-self: start;
  margin: 0;
  padding: 0;
  border-top: 0;
  opacity: 0;
  /* visibility, not display: display:none would drop the slide out of
     the grid and let the container shrink to the current quote, which
     is the height jump the grid-stack exists to avoid. The delay holds
     it visible for the length of the fade out. */
  visibility: hidden;
  transition: opacity .5s ease-in-out, visibility 0s linear .5s;
}

.tq.is-slider .tq-item.is-current {
  opacity: 1;
  visibility: visible;
  transition: opacity .5s ease-in-out;
}

/* ---------- dots ---------- */

.tq-dots {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;          /* the reference's 5px dot on 8px margins */
  margin: var(--space-sm) 0 0;
  padding: 0;
  list-style: none;
}

.tq-dot {
  display: block;
  width: 5px;
  height: 5px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--grey);
  cursor: pointer;
  transition: background-color .3s ease, transform .3s ease;
}

.tq-dot:hover { background: var(--steel-300); }

.tq-dot[aria-current="true"] {
  background: var(--steel-050);
  transform: scale(1.4);
}

/* The dot is 5px. A focus ring drawn tight to it is invisible, so it
   gets pushed out to something you can actually see. */
.tq-dot:focus-visible {
  outline: 1px solid var(--steel-100);
  outline-offset: 6px;
}

@media (max-width: 620px) {
  .tq-mark { width: 56px; height: 56px; font-size: 13px; }
  .tq-who  { letter-spacing: 0.06em; }
}

/* Reduced motion: the slider still works, it just cuts instead of
   fading. Nothing here moves on its own in either case. */
@media (prefers-reduced-motion: reduce) {
  .tq.is-slider .tq-list,
  .tq.is-slider .tq-item,
  .tq.is-slider .tq-item.is-current { transition: none; }
  .tq-dot { transition: none; }
}
